Продовжую потрохи налаштовувати домашній 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 або blockdirection: in або outlog: чи записувати подію в лог-файлquick: корисна штука – чи перевіряти правила далі, чи, якщо пакет підпадає під поточне правило, зупинити обробку і не перевіряти решту правил (далі буде приклад)interface: імʼя інтерфейсу або групи, до яких застосовується правило (якщо не вказано – правило діє на всі інтерфейси)protocol: TCP або UDP, чи інший із/etc/protocolssrc_addrтаdst_addr: можна вказати таблицю або макрос (про них теж далі), повне доменне ім’я, ім’я інтерфейсу чи групи або anysrc_portтаdst_port: аналогічно – порти, номер(и), або ім’я з файлу/etc/servicestcp_flags: фаги TCP, які мають бути в заголовках пакета, аби правило було застосованеstate:no state/keep state: чи зберігати стан підключення в state tablemodulate state: налаштування Initial Sequence Numberssynproxy state: обробка TCP-handshake на самомуpf(SYN/SYN-ACK), без передачі його одразу до сервісу (див. TCP/IP: моделі OSI та TCP/IP, TCP-пакети, Linux sockets і порти)
Важливо: 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 ще багато – див. посилання нижче.