FreeBSD: знайомство з Packet Filter (PF) firewall

Автор |  17/12/2025
 

Продовжую потрохи налаштовувати домашній NAS на FreeBSD, і перше, з чим хочеться розібратись – це FreeBSD firewalls.

Колись я працював з IPFW – FreeBSD: начальная настройка IPFW, 2012 рік.

Зараз в системі є три “штатних” фаєрволи – Packet Filter (PF), IP Firewall (IPFW) та IP Filter (IPF):

  • pf: зараз фактично дефолтна опція, був портований до FreeBSD з OpenBSD
  • ipfw: “історичний” фаєрвол ля FreeBSD, був доданий ще в 90-х роках
  • ipf: open source фаєрвол, портований в багато різних систем, в т.ч. OpenBSD та FreeBSD

Фаєрволи діляться на кілька типів: “inclusive” або “exclusive”, та “stateful” або “stateless”:

  • inclusive/exclusive: поведінка за замовчуванням – або дозволяти трафік, якщо для нього немає явного правила block (inclusive), або блокувати весь трафік, якщо для нього немає явного правила allow (exclusive)
  • stateful/stateless: чи зберігає firewall стан підключень, у stateful-режимі відповідний вхідний трафік для вже встановленого зʼєднання дозволяється автоматично, а у stateless-режимі firewall не зберігає стан і перевіряє кожен пакет окремо за правилами

Давайте спробуємо pf – потикав його, виглядає доволі простим, має всі потрібні можливості і приємний синтаксис.

Для управління pf є окрема CLI утиліта pfctl, для логування запитів – pflog, а для перевірки того, що зараз відбувається у pf – утиліта pftop.

Початок роботи з Packet Filter (PF)

Спершу зробимо якийсь базовий конфіг, перевіримо, як воно все працює, а далі вже подивимось на цікаві деталі.

Додаємо pf_enable=yes та pflog_enable=yes до /etc/rc.conf для запуску pf та pflog при рестарті системи:

root@setevoy-nas:/home/setevoy # sysrc pf_enable=yes
pf_enable: NO -> yes

root@setevoy-nas:/home/setevoy # sysrc pflog_enable=yes
pflog_enable: NO -> yes

Але поки не запускаємо, бо pf буде шукати дефолтний файл /etc/pf.conf, якого ще нема.

Packet Filter Rules Syntax

Детально є в документації OpenBSD, PF – Packet Filtering, зараз коротко глянемо основне:

action [direction] [log] [quick] [on interface] [af] [proto protocol]
       [from src_addr [port src_port]] [to dst_addr [port dst_port]]
       [flags tcp_flags] [state]

Тут:

  • action: pass або block
  • direction: in або out
  • log: чи записувати подію в лог-файл
  • quick: корисна штука – чи перевіряти правила далі, чи, якщо пакет підпадає під поточне правило, зупинити обробку і не перевіряти решту правил (далі буде приклад)
  • interface: імʼя інтерфейсу або групи, до яких застосовується правило (якщо не вказано – правило діє на всі інтерфейси)
  • protocol: TCP або UDP, чи інший із /etc/protocols
  • src_addr та dst_addr: можна вказати таблицю або макрос (про них теж далі), повне доменне ім’я, ім’я інтерфейсу чи групи або any
  • src_port та dst_port: аналогічно – порти, номер(и), або ім’я з файлу /etc/services
  • tcp_flags: фаги TCP, які мають бути в заголовках пакета, аби правило було застосоване
  • state:

Важливо: pf працює за принципом “last match wins” – firewall перевіряє всі правила від першого до останнього, і вирішальним є останнє правило, під яке підпадає пакет.

В ipfw, навпаки – важливий номер правила, і спрацьовує перше правило, умови якого відповідають пакету (“first match wins”). В ipf (ipfilter) використовується така ж модель – правила також обробляються за принципом first match wins.

Далі буде приклад того, як порядок правил впливає на доступи.

З коробки в системі є набір прикладів:

root@setevoy-nas:/home/setevoy # ls -l /usr/share/examples/pf/
total 44
-r--r--r--  1 root wheel 1233 Jun  6  2025 ackpri
-r--r--r--  1 root wheel  925 Jun  6  2025 faq-example1
-r--r--r--  1 root wheel 3129 Jun  6  2025 faq-example2
-r--r--r--  1 root wheel 4711 Jun  6  2025 faq-example3
-r--r--r--  1 root wheel 1062 Jun  6  2025 pf.conf
-r--r--r--  1 root wheel  848 Jun  6  2025 queue1
-r--r--r--  1 root wheel 1110 Jun  6  2025 queue2
-r--r--r--  1 root wheel  480 Jun  6  2025 queue3
-r--r--r--  1 root wheel  900 Jun  6  2025 queue4
-r--r--r--  1 root wheel  256 Jun  6  2025 spamd

Додамо для перевірки просте правило – блокувати всі підключення окрім SSH з мого робочого ноутбуку.

Знаходимо IP ноутбука:

[setevoy@setevoy-work ~]  $ ip a s wlan0
5: wlan0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
...
    inet 192.168.0.165/24 brd 192.168.0.255 scope global dynamic noprefixroute wlan0
...
    inet 192.168.0.164/24 brd 192.168.0.255 scope global secondary dynamic noprefixroute wlan0
...

Додаємо правила в /etc/pf.conf:

# skip loopback traffic
set skip on lo

# default deny
block all

# allow SSH only from specific hosts
pass in proto tcp from { 192.168.0.165, 192.168.0.164 } to any port 22 keep state

# allow all outgoing traffic
pass out all keep state

Тут ми:

  • ігноруємо трафік на loopback interface
  • блокуємо все
  • дозволяємо вхідний трафік на порт 22 (SSH) з двох адрес – 192.168.0.165 і 192.168.0.164
  • дозволяємо весь outgoing трафік

Замість port 22 можна вказати ім’я “ssh” – тоді pf перевірить список імен і портів у файлі /etc/services:

$ cat /etc/services | grep ssh
ssh                22/tcp
ssh                22/udp

Аби перевірити синтаксис /etc/pf.conf, але не застосовувати правила – виконуємо pfctl -vnf:

  • -v: verbose (можна два v для ще більшої деталізації)
  • -n: no apply, тільки перевірка синтаксису
  • -f: ім’я файлу для перевірки
root@setevoy-nas:/home/setevoy # pfctl -vnf /etc/pf.conf
set skip on { lo }
block drop all
pass in inet proto tcp from 192.168.0.165 to any port = ssh flags S/SA keep state
pass in inet proto tcp from 192.168.0.164 to any port = ssh flags S/SA keep state
pass out all flags S/SA keep state

Тут звертаємо увагу на дві цікаві речі:

  • flags S/SA: pf застосовує правило лише до початку TCP-зʼєднання (SYN), щоб створити state, і подальші пакети цього підключення обробляються вже через state table, а не через повну перевірку правил
  • keep state: це як раз про stateful/stateless – pf зберігає стан зʼєднання в state table, і якщо пакет належить до вже встановленого підключення, він пропускається без повторної перевірки всього ruleset

Хоча ми не задавали TCP-флаги явно в /etc/pf.conf, pf автоматично додає flags S/SA до stateful TCP-правил для коректної обробки нових зʼєднань.

Тестовий запуск і SSH

Важливий момент: якщо робимо по SSH, то при запуску pf підключення буде розірване, а якщо помилились в правилах – то не зможемо підключитись.

Тому є такий лайфхак:

root@setevoy-nas:/home/setevoy # sleep 120; pfctl -d

Запускаємо pf на 2 хвилини, перевіряємо, чи працює SSH.

Якщо робили по SSH – підключення буде розірване (бо не було SYN/SYN-ACK на момент роботи pf), перепідключаємось по SSH ще раз.

І, якщо все ОК, то вже стартуємо сам сервіс pf та pflog для логування:

root@setevoy-nas:/home/setevoy # service pflog start
Starting pflog.
root@setevoy-nas:/home/setevoy # service pf start
Enabling pf

Головне правило при налаштуванні pf по SSH – не виконувати start/restart, бо підключення буде розірване.

Натомість якщо треба змінити конфіг – то використовуємо спочатку pfctl -n для перевірки синтаксису, а потім pfctl -f /etc/pf.conf або service pf reload.

Можемо глянути з nmap з робочої машинки як наш сервер виглядає “ззовні”:

[setevoy@setevoy-work ~] $ nmap -Pn -p 1-1024 192.168.0.181
...
Not shown: 1023 filtered tcp ports (no-response)
PORT   STATE SERVICE
22/tcp open  ssh

Nmap done: 1 IP address (1 host up) scanned in 7.02 seconds

Власне, тільки 22 порт і бачимо, і то тільки тому, що nmap запускався з хоста, якому дозволений SSH.

Packet Filter Rules Ordering

Простий приклад того, як порядок правил впливає на те, чи буде пакет пропущений до системи, тобто підхід “last match wins”.

Поки закоментуємо block all, робимо pfctl -f /etc/pf.conf або service pf reload, і запускаємо “сервер” з NetCat, який слухає порт 10000:

root@setevoy-nas:/home/setevoy # nc -v -l 10000

Тепер з іншою машини відправляємо текст:

[setevoy@setevoy-work ~] $ echo Test | nc 192.168.0.2 10000
192.168.0.2 10000 (ndmp) open

І він приходить на “сервер” – все працює:

root@setevoy-nas:/home/setevoy # nc -v -l 10000
Connection from 192.168.0.165 58018 received!
Test

Тепер робимо тест: спочатку додаємо pass port 10000 перед block all:

...
# this will not work
pass in proto tcp to any port 10000 keep state

# default deny
block all
...

Робимо reload:

root@setevoy-nas:/home/setevoy # pfctl -vnf /etc/pf.conf && service pf reload

Запускаємо на сервері nc -v -l 10000, пробуємо на клієнті ще раз, з -w вказуємо таймаут для підключення,  отримуємо помилку “Connection timed out”:

$ echo Test | nc -v -w 3 192.168.0.2 10000
192.168.0.2 10000 (ndmp): Connection timed out

А тепер переносимо pass після block:

...
# default deny
block all

# this will work
pass in proto tcp to any port 10000 keep state
...

Ще раз service pf reload, ще раз запускаємо nc -v -l 10000, і тепер з клієнта:

$ echo Test | nc -v -w 3 192.168.0.2 10000
192.168.0.2 10000 (ndmp) open

Знов маємо “Test” на сервері.

Packet Filter та quick в rules

Про quick вже згадував в Rules Syntax, давайте глянемо, як це працює на ділі.

Повертаємо pass 10000 перед block:

...
# allow nc demo
pass in proto tcp to any port 10000 keep state

block all
...

Робимо reload, перевіряємо з nc – знов отримуємо “Connection timed out”.

А тепер, не змінюючи порядок – додамо до правила з pass in 10000 ключ quick:

...
# allow nc demo
pass in quick proto tcp to any port 10000 keep state

block all
...

Тепер підключення працює, бо запит попав під правило з pass і pf не перевіряє правила далі, а просто пропускає пакет.

Packet Filter logging з pflog

Запуск pflog ми вже додали в /etc/rc.conf і зробили service pflog start, але зараз він нічого не пише.

Аби включити запис логів для правила – додаємо його після action і, якщо є, то direction, наприклад:

...
# default deny
block log all
...

Перевіряємо конфіг, застосовуємо зміни:

root@setevoy-nas:/home/setevoy # pfctl -nf /etc/pf.conf
root@setevoy-nas:/home/setevoy # service pf reload

pflog для логування створює окремий інтерфейс – pflog0 (ім’я можна перевизначити з pflog_flags):

root@setevoy-nas:/home/setevoy # ifconfig pflog0
pflog0: flags=1000141<UP,RUNNING,PROMISC,LOWER_UP> metric 0 mtu 33152
        options=0
        groups: pflog

Пробуємо підключитись з якогось “лівого” хосту, на який нема правила pass in:

setevoy@test-nas-1:~ $ ssh 192.168.0.181

Перевірити логи можемо наживо прямо з інтерфейсу:

root@setevoy-nas:/home/setevoy # tcpdump -n -e -ttt -i pflog0
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on pflog0, link-type PFLOG (OpenBSD pflog file), snapshot length 262144 bytes
 00:00:00.000000 rule 0/0(match): block in on em0: 192.168.0.113.44161 > 255.255.255.255.6667: UDP, length 172
 00:00:02.301893 rule 0/0(match): block in on em0: 192.168.0.86.19569 > 192.168.0.181.22: Flags [S], seq 982711563, win 65535, options [mss 1460,nop,wscale 6,sackOK,TS val 370325371 ecr 0], length 0
 00:00:01.026055 rule 0/0(match): block in on em0: 192.168.0.86.19569 > 192.168.0.181.22: Flags [S], seq 982711563, win 65535, options [mss 1460,nop,wscale 6,sackOK,TS val 370326401 ecr 0], length 0

По дефолту pflog пише в файл /var/log/pflog, і пише не в text, а в pcap:

root@setevoy-nas:/home/setevoy # file /var/log/pflog
/var/log/pflog: pcap capture file, microsecond ts (little-endian) - version 2.4 (OpenBSD PFLOG, capture length 116)

Тому читаємо його не cat/less, а теж tcpdump – тільки замість -i (interface) вказуємо -r і файл (“read from a saved packet file rather than to read packets from a network interface“):

root@setevoy-nas:/home/setevoy # tcpdump -n -e -ttt -r /var/log/pflog | head
reading from file /var/log/pflog, link-type PFLOG (OpenBSD pflog file), snapshot length 116
 00:00:00.000000 rule 0/0(match): block in on em0: 192.168.0.165.17500 > 255.255.255.255.17500: UDP, length 131
 00:00:00.000100 rule 0/0(match): block in on em0: 192.168.0.165.17500 > 192.168.0.255.17500: UDP, length 131
...

Логи для SSH only

Зараз у нас логуються всі заблоковані запити, і їх може бути дуже багато.

Якщо хочемо записувати події тільки по SSH – то прибираємо log з block all і додаємо нове правило з block log port 22:

...
# default deny
block all

# log and block SSH from everyone else
block log proto tcp to any port 22

# allow SSH only from specific hosts
pass in proto tcp from { 192.168.0.165, 192.168.0.164 } to any port 22 keep state
...

Тепер у нас блокується все по дефолту, але без записів в лог, і блокується SSH для всіх окрім 192.168.0.165, 192.168.0.164, але подія зберігається в лог:

root@setevoy-nas:/home/setevoy # tcpdump -n -e -ttt -i pflog0
...
 00:00:00.000000 rule 1/0(match): block in on em0: 192.168.0.86.27268 > 192.168.0.2.22: [...]
...

Якщо хочемо логувати ще і всі нові дозволені підключення – просто додаємо log до pass in 22:

...
# allow SSH only from specific hosts
pass in log proto tcp from { 192.168.0.165, 192.168.0.164 } to any port 22 keep state
...

І отримуємо запис з “pass” в лог-файлі:

...
 00:00:00.000000 rule 2/0(match): pass in on em0: 192.168.0.165.60218 > 192.168.0.2.22: [...]
...

 Packet Filter та макроси

Macros, по суті, це як змінні в програмуванні. Точніше кажучи, це аналог const – вони підставляються під час завантаження конфігурації і не змінюється під час роботи pf.

Наприклад, можемо створити список портів і IP-адрес, для яких дозволений доступ:

# allowed tcp ports
allowed_tcp_ports = "{ 22, 10000 }"

# allowed ssh clients
ssh_clients = "{ 192.168.0.4, 192.168.0.165, 192.168.0.164 }"

І далі використовуємо їх в правилах як $ssh_clients та $allowed_tcp_ports:

# allowed tcp ports
allowed_tcp_ports = "{ 22, 10000 }"

# allowed ssh clients
ssh_clients = "{ 192.168.0.4, 192.168.0.165, 192.168.0.164 }"

# skip loopback traffic
set skip on lo

# default deny
block all

# restrict ssh source addresses
pass in proto tcp from $ssh_clients to any port $allowed_tcp_ports keep state

# allow all outgoing traffic
pass out all keep state

Тепер, якщо нам треба додати порт або адресу – не треба шукати їх по всьому файлі, а просто міняємо значення в одному місці, де “оголошується” макрос.

Packet Filter та Tables

Ідея використання Tables аналогічна до макросів – ми створюємо “змінну”, яка містить набір значень (IP-адрес або мереж), які будуть використовуватись в правилах.

Але якщо макроси використовуються для спрощення роботи з конфігураційним файлом – то таблиці призначені для великих списків, бо пошук по них відбувається дуже швидко.

Крім того, таблиці можемо міняти під час роботи pf без необхідності робити service pf reload для застосування змін.

Виносимо список адрес в таблицю з іменем ssh_clients:

...
# ssh allowed clients
#ssh_clients = "{ 192.168.0.4, 192.168.0.165, 192.168.0.164 }"
table <ssh_clients> { 192.168.0.4, 192.168.0.165, 192.168.0.164 }
...

І потім використовуємо в правилах – тільки тепер замість $ssh_clients вказуємо як <ssh_clients>:

# macros
allowed_tcp_ports = "{ 22, 10000 }"

# ssh allowed clients
#ssh_clients = "{ 192.168.0.4, 192.168.0.165, 192.168.0.164 }"
table <ssh_clients> { 192.168.0.4, 192.168.0.165, 192.168.0.164 }

set skip on lo

# allow nc demo
pass in quick proto tcp to any port 10000 keep state

block all

# allow ssh only from specific hosts
pass in proto tcp from <ssh_clients> to any port 22 keep state

...

Перевіряємо:

root@setevoy-nas:/home/setevoy # pfctl -vnf /etc/pf.conf 
allowed_tcp_ports = "{ 22, 10000 }"
table <ssh_clients> { 192.168.0.4 192.168.0.165 192.168.0.164 }
...

Робимо reload, і підключення працює.

Також можна вказувати негативне значення з ! для виключення якоїсь адреси.

Наприклад, дозволяємо SSH з усієї мережі 192.168.0.0/24, але забороняємо з 192.168.0.86:

...
table <ssh_clients> { 192.168.0.0/24, !192.168.0.86 }
...

Тепер у нас все ще є доступ з робочого ноутбуку, але не буде з хоста 192.168.0.86:

...
00:00:01.012829 rule 1/0(match): block in on em0: 192.168.0.86.50451 > 192.168.0.2.22: [...]
...

Tables в окремих файлах

Якщо списки великі – їх можна винести в додаткові файли, які потім підключають через /etc/pf.conf, наприклад – робимо /etc/pf_clients.conf з тими самими адресами:

root@setevoy-nas:/home/setevoy # cat /etc/pf_clients.conf
192.168.0.0/24
!192.168.0.86

А у /etc/pf.conf замість “table <ssh_clients> { 192.168.0.4, 192.168.0.165, 192.168.0.164 }” вказуємо таблицю з persist file:

...
# ssh allowed clients
table <ssh_clients> persist file "/etc/pf_clients.conf"

...

# allow ssh only from specific hosts
pass in proto tcp from <ssh_clients> to any port 22 keep state
...

pfctl та зміни в Tables

Перевіряємо таблицю зараз:

root@setevoy-nas:/home/setevoy # pfctl -t ssh_clients -T show
   192.168.0.0/24
  !192.168.0.86

Тепер, якщо хочемо видалити !192.168.0.86 – виконуємо:

root@setevoy-nas:/home/setevoy # pfctl -t ssh_clients -T delete !192.168.0.86
1/1 addresses deleted.

Перевіряємо ще раз:

root@setevoy-nas:/home/setevoy # pfctl -t ssh_clients -T show
   192.168.0.0/24

І SSH з 192.168.0.86 працює.

Додати адресу – аналогічно, тільки add замість delete:

root@setevoy-nas:/home/setevoy # pfctl -t ssh_clients -T add !192.168.0.100
1/1 addresses added.
root@setevoy-nas:/home/setevoy # pfctl -t ssh_clients -T show
   192.168.0.0/24
  !192.168.0.100

Можна навіть повністю очистити таблицю вручну з flush – поточна сесія SSH залишиться активною, але нові підключення працювати не будуть:

root@setevoy-nas:/home/setevoy # pfctl -t ssh_clients -T flush
2 addresses deleted.
root@setevoy-nas:/home/setevoy # pfctl -t ssh_clients -T show
root@setevoy-nas:/home/setevoy #

Перевіряємо:

$ ssh -o ConnectTimeout=3 192.168.0.2
ssh: connect to host 192.168.0.2 port 22: Connection timed out

Збереження змін pfctl в Tables

Важливо, що зміни зроблені з pfctl зникнуть після reload чи рестарту, бо вони будуть тільки в пам’яті.

Аби зберігти їх – робимо зміни у /etc/pf.conf (або /etc/pf_clients.conf, якщо підключали файлом).

Наприклад, у нас в /etc/pf_clients.conf зараз є дві адреси:

root@setevoy-nas:/home/setevoy # cat /etc/pf_clients.conf
192.168.0.0/24
!192.168.0.86

І вони ж є у pf runtime:

root@setevoy-nas:/home/setevoy # pfctl -t ssh_clients -T show
   192.168.0.0/24
  !192.168.0.86

Видаляємо !192.168.0.86 в рантаймі:

root@setevoy-nas:/home/setevoy # pfctl -t ssh_clients -T delete !192.168.0.86
1/1 addresses deleted.

Якщо хочемо відновити правила, які є у файлі /etc/pf_clients.conf – робимо або service pf reload, або pfctl replace і файл, з якого треба взяти правила:

root@setevoy-nas:/home/setevoy # pfctl -t ssh_clients -T replace -f /etc/pf_clients.conf
1 addresses added.

!192.168.0.86 знов на місці.

Якщо ж хочемо навпаки – зберігти зміни з пам’яті у файл, то виконуємо простий редірект з pfctl show > /etc/pf_clients.conf:

root@setevoy-nas:/home/setevoy # pfctl -t ssh_clients -T delete !192.168.0.86
1/1 addresses deleted.

root@setevoy-nas:/home/setevoy # pfctl -t ssh_clients -T show > /etc/pf_clients.conf

root@setevoy-nas:/home/setevoy # cat /etc/pf_clients.conf
   192.168.0.0/24

Packet Filter та Anchors

Anchors дозволяють описувати правила в окремих файлах і підключати їх до основного /etc/pf.conf. Це схоже на використання окремих файлів для tables (наприклад, /etc/pf_clients.conf), але замість списків адрес anchors містять повноцінні rulesets.

Крім того, anchors можуть динамічно завантажуватись і змінюватись за допомогою pfctl під час роботи pf.

Створюємо іменований anchor:

...
block all

# allow ssh only from specific hosts
pass in proto tcp from <ssh_clients> to any port 22 keep state

# new dynamic anchor here
anchor "ssh"

# allow all outgoing traffic
pass out all keep state

Це – динамічний anchor, бо для нього не вказаний файл з правилами, і правила в ньому при service pf restart будуть видалені, але при service pf reload залишаться.

Можемо додати правило прямо “на льоту”:

root@setevoy-nas:/home/setevoy # echo 'pass in proto tcp from <ssh_clients> to any port 22 keep state' | pfctl -a ssh -f -

Перевіряємо:

root@setevoy-nas:/home/setevoy # pfctl -a ssh -s rules
pass in proto tcp from <ssh_clients> to any port = ssh flags S/SA keep state

Або створюємо файл, і виконуємо pfctl -a ssh -f /path/to/anchor.conf.

Інший варіант – заповнювати anchors під час reload чи restart, вказавши файл прямо до /etc/pf.conf в самому anchor.

Наприклад, додаємо нове правило:

root@setevoy-nas:/home/setevoy # cat /etc/pf.anchors/ssh
pass in proto tcp from 10.0.0.100 to any port 22 keep state

І описуємо його load у /etc/pf.conf:

...

anchor "ssh"
load anchor "ssh" from "/etc/pf.anchors/ssh"

# allow all outgoing traffic
pass out all keep state

Перевіряємо конфіг:

root@setevoy-nas:/home/setevoy # pfctl -vnf /etc/pf.conf
...
pass out all flags S/SA keep state

Loading anchor ssh from /etc/pf.anchors/ssh
pass in inet proto tcp from 10.0.0.100 to any port = ssh flags S/SA keep state

Виконуємо reload:

root@setevoy-nas:/home/setevoy # service pf reload
Reloading pf rules.

І ще раз дивимось правила в anchor "ssh":

root@setevoy-nas:/home/setevoy # pfctl -a ssh -s rules
pass in inet proto tcp from 10.0.0.100 to any port = ssh flags S/SA keep state

Ну і на цьому, мабуть, все, хоча можливостей у pf ще багато – див. посилання нижче.

Корисні посилання