Продовжуємо налаштовувати домашній NAS на FreeBSD.
Власне, NAS – Network System, і хочеться мати до нього доступ з інших девайсів – з Linux та Windows хостів, з телефонів, телевізорів.
Тут у нас на вибір дві основні опції – Samba та NFS. Можна, звісно, згадати і sshfs – але це рішення точно не для домашньої мережі (хоча простіше).
Я для себе вирішив для Windows (на ігровому ПК), Android телефонів та Android TV зробити доступ з Samba share – а NFS буде чисто для Linux систем для бекапів.
В цьому пості налаштуємо Samba на FreeBSD, а в наступному – додамо NFS.
Всі частини серії по налаштуванню домашнього NAS на FreeBSD:
без можливості логіна в систему – /usr/sbin/nologin
але якщо планується виконувати локальні операції від юзера – то створюємо з -s /bin/sh, а SSH все одно буде заблокований в sshd config (про SSH буде окремим постом)
і з -g smbshare вказуємо primary group
Перевіряємо юзера:
root@setevoy-nas:/home/setevoy # id smbshare
uid=1004(smbshare) gid=1004(smbshare) groups=1004(smbshare)
І права доступу до каталога тепер – можна глянути розширену інформацію з getfacl, аби отримати POSIX/NFSv4 ACL, які ZFS використовує замість класичних Unix-бітів:
Samba буде встановлювати власні права доступу, тому в параметрах ZFS ACL треба переключити режими.
Samba керує правами доступу самостійно, тому для ZFS ACL потрібно увімкнути режими passthrough, щоб ZFS не змінював ACL.
Перевіряємо параметри датасету зараз:
root@setevoy-nas:/home/setevoy # zfs get acltype,aclmode,aclinherit nas/share
NAME PROPERTY VALUE SOURCE
nas/share acltype nfsv4 default
nas/share aclmode discard default
nas/share aclinherit restricted default
Задаємо aclmode та aclinherit в значення passthrough – тоді ZFS буде використовувати ті права, які задає Samba:
root@setevoy-nas:/home/setevoy # zfs set aclmode=passthrough nas/share
root@setevoy-nas:/home/setevoy # zfs set aclinherit=passthrough nas/share
Перевіряємо тепер:
root@setevoy-nas:/home/setevoy # zfs get acltype,aclmode,aclinherit nas/share
NAME PROPERTY VALUE SOURCE
nas/share acltype nfsv4 default
nas/share aclmode passthrough local
nas/share aclinherit passthrough local
На відміну від acltype=off, який повністю вимикає ACL і залишає лише POSIX-права, режим passthrough дозволяє Samba керувати ACL без втручання ZFS.
Надалі зміни в директорії /nas/share краще виконувати від імені smbshare або root, а не звичайного користувача, щоб не порушувати модель прав доступу Samba.
Або більше детально, просто явно вказуємо деякі дефолтні опції:
[global]
workgroup = WORKGROUP
security = user
[shared]
path = /nas/share
read only = no
browseable = yes
valid users = @smbshare
create mask = 0660
directory mask = 2770
Перевіряємо синтаксис:
root@setevoy-nas:/home/setevoy # testparm
Load smb config files from /usr/local/etc/smb4.conf
Loaded services file OK.
Weak crypto is allowed
Server role: ROLE_STANDALONE
Press enter to see a dump of your service definitions
# Global parameters
[global]
security = USER
idmap config * : backend = tdb
[shared]
create mask = 0660
directory mask = 02770
path = /nas/share
read only = No
valid users = @smbshare
Додаємо Samba-юзера:
root@setevoy-nas:/home/setevoy # smbpasswd -a smbshare
New SMB password:
Retype new SMB password:
Added user smbshare.
smbpasswd збереже його пароль в /var/db/samba4/private/passdb.tdb.
Запускаємо сервіс:
root@setevoy-nas:~ # service samba_server start
Performing sanity check on Samba configuration: OK
Starting nmbd.
Starting smbd.
root@setevoy-nas:/home/setevoy # smbclient //localhost/shared -U smbshare
Password for [WORKGROUP\smbshare]:
Try "help" to get a list of possible commands.
smb: \>
Робимо list files – поки тут пусто:
smb: \> ls
. D 0 Fri Dec 26 15:46:55 2025
.. D 0 Fri Dec 26 15:46:55 2025
3771191492 blocks of size 1024. 3771191396 blocks available
Копіюємо щось із системи:
smb: \> put /etc/hosts hosts.test
putting file /etc/hosts as \hosts.test (252.7 kb/s) (average 252.7 kb/s)
smb: \> ls hosts.test
hosts.test A 1035 Fri Dec 26 16:14:29 2025
3771191480 blocks of size 1024. 3771191380 blocks available
І тепер маємо файл в /nas/share:
root@setevoy-nas:/home/setevoy # ls -l /nas/share
total 5
-rw-rw---- 1 smbshare smbshare 1035 Dec 26 16:14 hosts.test
cifs-utils: утиліти для роботи з Samba share через CIFS:
mount -t cifs
запису в /etc/fstab
systemd automount
Перевіряємо підключення з Linux до Samba на FreeBSD:
[setevoy@setevoy-work ~] $ smbclient //192.168.0.2/shared -U smbshare
Can't load /etc/samba/smb.conf - run testparm to debug it
Password for [WORKGROUP\smbshare]:
Try "help" to get a list of possible commands.
smb: \>
Ще раз list – все на місці:
smb: \> ls hosts.test
hosts.test A 1035 Fri Dec 26 16:14:29 2025
3771191480 blocks of size 1024. 3771191380 blocks available
Налаштування /etc/fstab та systemd-automount
Якщо хочемо, аби шара підключалась автоматично – налаштуємо /etc/fstab та systemd-automount.
На клієнті створюємо каталог, в який буде підключатись /nas/share з серверу:
Через файл mnt-nas\x2dshared.automount systemd відстежує доступ до /mnt/nas-shared, і як тільки ми виконаємо якусь дію (наприклад, cd /mnt/nas-shared) – systemd виконає mnt-nas\x2dshared.mount, який власне підключить розділ.
57.2 MB/s, тобто 457.6 Mbit/s. При тому, що ноут зараз на WiFi – цілком нормальна швидкість.
Підключення Samba клієнтів
І приклади того, як до Samba підключитись з різних девайсів.
Samba та KDE Dolphin
Взагалі, ми вже зробили automount через systemd, але можна відкрити напряму з KDE Dolphin file manager – вказуємо адресу smb://192.168.0.2, вводимо логін-пароль:
root@setevoy-nas:~ # camcontrol devlist
<Samsung SSD 870 EVO 4TB SVT03B6Q> at scbus0 target 0 lun 0 (pass0,ada0)
<Samsung SSD 870 EVO 4TB SVT03B6Q> at scbus1 target 0 lun 0 (pass1,ada1)
<CT500P310SSD8 VACR001> at scbus7 target 0 lun 1 (pass3,nda0)
Тут в мене ada1 та ada2 – це SATA-диски для самого NAS storage, а nda0 – NVMe, на якому встановлена система (там зараз UFS, але потім, скоріш за все, перевстановлю із ZFS теж).
Імена пристроїв на кшталт /dev/ada0, /dev/ada1 або /dev/nda0 можуть змінюватися залежно від порядку підключення дисків, а тому напряму використовувати їх у ZFS не рекомендується – далі створимо власні GPT labels.
Створення GPT tables
Про всяк випадок – видаляємо існуючі таблиці розділів.
ВАЖЛИВО: таблиця розділів буде знищена. Дані залишаються на носії, але стануть недоступними.
В моєму випадку – диски нові, тому бачимо помилку “Invalid argument“:
Для подальшої роботи з дисками створимо постійні GPT lables – іменовані ідентифікатори розділів, які зберігаються в GPT-таблиці розділів і читаються при старті системи.
Вони не змінюються після reboot, не залежать від порядку SATA портів, не залежать від того, як ядро виявило диск.
root@setevoy-nas:~ # glabel status
Name Status Components
gpt/zfs_disk1 N/A ada0p1
gptid/67ebfac9-dcce-11f0-98bf-00d861f3bff0 N/A ada0p1
diskid/DISK-S758NX0Y701757D N/A ada0
gpt/zfs_disk2 N/A ada1p1
gptid/6a9c3ee5-dcce-11f0-98bf-00d861f3bff0 N/A ada1p1
diskid/DISK-S758NX0Y701756A N/A ada1
Диски готові – переходимо до ZFS, нам треба:
налаштувати ZFS pool з mirror
створити datasets
подивитись на шифрування даних
перевірити, як працювати зі snapshots
І в кінці окремо поговоримо про моніторинг дисків та ZFS pool.
Створення ZFS mirror pool
Використовуємо такі параметри:
ashift=12: розмір фізичного сектора, який ZFS використовує для I/O
розмір сектора визначається як 2^ashift байт, тобто 2¹² = 4096 байт
замінити значення ashift після створення Pool не можна
atime=off: вимикаємо оновлення часу доступу до файлів, зменшує кількість зайвих записів на диск
compression=lz4: швидка компресія даних на диску з мінімальним CPU-оверхедом
xattr: налаштування зберігання атрибутів файлі:
xattr=on: старий і default варіант, extended attributes зберігаються як окремі приховані файли
xattr=sa: xattr зберігаються безпосередньо в dnode файлу (аналог inode в UFS/ext4), без створення окремих прихованих файлів – менше звернень до диска і краща продуктивність
mirror: використовуємо ZFS mirror (аналог RAID1) – дані синхронно записуються на обидва диски (див. також vdev)
root@setevoy-nas:~ # zpool status
pool: nas
state: ONLINE
config:
NAME STATE READ WRITE CKSUM
nas ONLINE 0 0 0
mirror-0 ONLINE 0 0 0
gpt/zfs_disk1 ONLINE 0 0 0
gpt/zfs_disk2 ONLINE 0 0 0
errors: No known data errors
Додаємо підключення при ребутах:
root@setevoy-nas:/home/setevoy # sysrc zfs_enable=YES
zfs_enable: NO -> YES
Створення ZFS datasets
ZFS dataset – це окрема файлова система всередині ZFS pool, яка має власні властивості (compression, quota, mountpoint тощо) і керується незалежно від інших datasets.
Зараз у нас один, корневий dataset:
root@setevoy-nas:~ # zfs list
NAME USED AVAIL REFER MOUNTPOINT
nas 420K 3.51T 96K /nas
Корінь pool – це технічний корінь, а не місце для даних, тому створимо кілька окремих.
Не факт, що в мене датасети залишаться такими і надалі, але просто як ідея того, як можна розділити простір на дисках:
nas/data: основний dataset для збереження всяких даних типу музики-фільмів
nas/backups: сюди можна буде копіювати якісь periodic бекапи з робочого і домашнього ноутбуків
nas/private: зашифрований розділ для приватних даних та/або баз даних типу KeePass або бекапів 1Password
nas/shared: загальнодоступний dataset для доступу з телефонів і ноутбуків через Samba share (про налаштування Samba – окремим постом, вже є в чернетках)
Створюємо новий датасет з іменем nas/data:
root@setevoy-nas:~ # zfs create nas/data
Перевіряємо список ще раз:
root@setevoy-nas:~ # zfs list
NAME USED AVAIL REFER MOUNTPOINT
nas 540K 3.51T 96K /nas
nas/data 96K 3.51T 96K /nas/data
І каталог цього датасету:
root@setevoy-nas:~ # ls -la /nas/data/
total 1
drwxr-xr-x 2 root wheel 2 Dec 19 13:41 .
drwxr-xr-x 3 root wheel 3 Dec 19 13:41 ..
Він жеж має власну точку підключення:
root@setevoy-nas:/home/setevoy # mount | grep data
nas/data on /nas/data (zfs, local, noatime, nfsv4acls)
Яка задана у властивостях датасету:
root@setevoy-nas:/home/setevoy # zfs get mountpoint nas/data
NAME PROPERTY VALUE SOURCE
nas/data mountpoint /nas/data default
Додамо ще один:
root@setevoy-nas:~ # zfs create nas/backups
Ще раз список:
root@setevoy-nas:~ # zfs list
NAME USED AVAIL REFER MOUNTPOINT
nas 696K 3.51T 96K /nas
nas/backups 96K 3.51T 96K /nas/backups
nas/data 96K 3.51T 96K /nas/data
Шифрування dataset
Хочеться мати окремий dataset для чутливих даних, тому глянемо, як це зроблено у ZFS.
включити шифрування можна тільки створенні dataset – але можна відключити після
якщо батьківський dataset не зашифрований – дочірній можна зашифрувати
якщо батьківський зашифрований – дочірні успадковують його шифрування
Створимо новий dataset, вказуємо, що шифрується з паролем:
root@setevoy-nas:~ # zfs create -o encryption=on -o keyformat=passphrase -o keylocation=prompt nas/private
Enter new passphrase:
Re-enter new passphrase:
Перевіряємо цей датасет:
root@setevoy-nas:~ # zfs get encryption,keyformat,keylocation nas/private
NAME PROPERTY VALUE SOURCE
nas/private encryption aes-256-gcm -
nas/private keyformat passphrase -
nas/private keylocation prompt local
Як це буде виглядати після reboot:
pool nas імпортується
nas/private буде заблокований, і mountpoint не зʼявиться, доки вручну не ввести пароль і не розблокувати його
Для розблокування потім використовуємо:
root@setevoy-nas:~ # zfs load-key nas/private
root@setevoy-nas:~ # zfs mount nas/private
На відміну від, наприклад, шифрування розділів з LUKS – ZFS дозволяє змінювати пароль для зашифрованого dataset без перешифрування даних. Фактично змінюється лише ключ, який захищає основний ключ шифрування.
Якщо хочемо змінити пароль – виконуємо zfs change-key:
root@setevoy-nas:~ # zfs change-key nas/private
Enter new passphrase for 'nas/private':
Замість використання паролю можемо створити файл-ключ, який буде використаний про ребутах для підключення датасету.
root@setevoy-nas:/home/setevoy # zfs get encryption,keyformat,keylocation nas/private
NAME PROPERTY VALUE SOURCE
nas/private encryption aes-256-gcm -
nas/private keyformat raw -
nas/private keylocation file:///root/nas-private-pass.key local
Ребутаємо машину:
root@setevoy-nas:/home/setevoy # shutdown -r now
Shutdown NOW!
shutdown: [pid 13519]
І потім перевіряємо розділи:
root@setevoy-nas:/home/setevoy # mount | grep nas
nas on /nas (zfs, local, noatime, nfsv4acls)
nas/backups on /nas/backups (zfs, local, noatime, nfsv4acls)
nas/data on /nas/data (zfs, local, noatime, nfsv4acls)
Та з zfs list:
root@setevoy-nas:/home/setevoy # zfs list
NAME USED AVAIL REFER MOUNTPOINT
nas 200G 3.32T 112K /nas
nas/backups 200K 500G 104K /nas/backups
nas/data 96K 3.51T 96K /nas/data
nas/private 200K 3.32T 200K /nas/private
Налаштування dataset quotas
ZFS datasets підтримують задання квот на розмір датасетів – тобто, максимальний розмір, який він може займати.
Зручно, наприклад, аби якийсь backups/ випадково не забив весь диск.
Є ще dataset reservation, але про це трохи далі.
Задамо ліміт в 500 гігабайт на nas/backups:
root@setevoy-nas:~ # zfs set quota=500G nas/backups
Перевіряємо:
root@setevoy-nas:~ # zfs get quota,used,available nas/backups
NAME PROPERTY VALUE SOURCE
nas/backups quota 500G local
nas/backups used 96K -
nas/backups available 500G -
Налаштування dataset reservation
ZFS дозволяє зарезервувати місце для датасету, гарантуючи доступний простір незалежно від заповненості пулу.
Важливо, що ZFS Reservation резервує місце незалежно від того, використовується воно чи ні.
Задамо мінімально доступний розмір основного датасету nas/data:
root@setevoy-nas:~ # zfs set reservation=200G nas/data
Перевіряємо:
root@setevoy-nas:~ # zfs get reservation,used,available nas/data
NAME PROPERTY VALUE SOURCE
nas/data reservation 200G local
nas/data used 96K -
nas/data available 3.51T -
Аби змінити reservation чи видалити – просто раз виконуємо zfs set з новим значенням:
root@setevoy-nas:~ # zfs set reservation=100G nas/data
root@setevoy-nas:~ # zfs set reservation=none nas/data
Використання ZFS snapshots
ZFS Snapshots – це миттєві read-only знімки стану dataset, які дозволяють швидко відкотитись до попереднього стану.
ZFS працює за принципом COW (Copy On Write), тобто при зміні в блоках даних – зміни робляться в новому блоку, а старі блоки не перезаписуються, поки на них є активні посилання
при створенні снапшоту ZFS не копіює дані, а створює таке посилання на цей блок
далі, коли ми робимо зміни в даних датасета, для якого є снапшот – то зміни на диску робляться в нових блоках даних, а доступ до старих зберігається через снапшот
root@setevoy-nas:/home/setevoy # zfs list -t snapshot nas/data@test-snap
NAME USED AVAIL REFER MOUNTPOINT
nas/data@test-snap 0B - 104K -
Його атрибути:
root@setevoy-nas:/home/setevoy # zfs get creation,used,referenced nas/data@test-snap
NAME PROPERTY VALUE SOURCE
nas/data@test-snap creation Sat Dec 20 15:46 2025 -
nas/data@test-snap used 0B -
nas/data@test-snap referenced 104K -
Відновлення зі snapshot
Снапшоти зберігаються в каталозі .zfs датасета – /nas/data/.zfs/snapshot/:
root@setevoy-nas:/home/setevoy # ll /nas/data/.zfs/snapshot/test-snap/
total 1
-rw-r--r-- 1 root wheel 10 Dec 20 15:45 test-snap.txt
І звідси ми можемо отримати доступ і до нашого тестового файлу:
root@setevoy-nas:/home/setevoy # rm /nas/data/test-snap.txt
root@setevoy-nas:/home/setevoy # file /nas/data/test-snap.txt
/nas/data/test-snap.txt: cannot open `/nas/data/test-snap.txt' (No such file or directory)
Аби відновити зі снапшоту – можна або просто скопіювати з каталога /nas/data/.zfs/snapshot/test-snap/ з cp, або, якщо треба відкотити весь датасет, то використати zfs rollback – але в такому разі всі зміни, які були зроблені після створення снапшоту будуть втрачені:
Ну і ZFS Boot Environments працюють через ті самі снапшоти – під час виконання freebsd-update install автоматично створюється копія даних, на яку можна відкотитись в разі проблем.
Взагалі ZFS Boot Environments дуже цікава штука, може, окремо про неї напишу.
Замість повного rollback – аби не перезаписувати дані на поточному датасеті – можна зробити клонування снапшоту в новий датасет:
root@setevoy-nas:/home/setevoy # cat /usr/local/etc/periodic/daily//402.zfSnap
#!/bin/sh
# If there is a global system configuration file, suck it in.
#
if [ -r /etc/defaults/periodic.conf ]; then
. /etc/defaults/periodic.conf
source_periodic_confs
fi
...
Включити запуск скриптів можна в файлі /etc/periodic.conf (або, краще, /etc/periodic.conf.local):
Запустити виконання всіх daily задач, для яких в /etc/defaults/periodic.conf задано “YES” – з командою periodic:
root@setevoy-nas:/home/setevoy # periodic daily
І тепер нас є новий снапшот:
root@setevoy-nas:/home/setevoy # zfs list -t snapshot
NAME USED AVAIL REFER MOUNTPOINT
...
nas/data@daily-2025-12-21_16.41.03--1w 0B - 104K -
Моніторинг ZFS
Для моніторингу у нас є цілий набір утиліт – як дефолтні від самої файлової системи, так і додаткові, які можна встановити окремо.
Є класний документ Monitoring ZFS, хоча і 2017 року, але все ще актуальний.
З основного, чим можемо користуватись і що бажано моніторити:
SMART: перевірка самих дисків
zpool status: перевірка, що нема проблем на самих ZFS pools
zfs scrub: не зовсім про моніторинг, але може показати проблеми
zpool events: події пулів
arcstats: корисно перевіряти ефективність роботи кешу ZFS
Перевірка S.M.A.R.T. для SSD
Диски зовсім нові, але just in case і на майбутнє – налаштуємо S.M.A.R.T. (Self-Monitoring, Analysis, and Reporting Technology).
Встановлюємо пакет:
root@setevoy-nas:~ # pkg install smartmontools
І перевіряємо стан дисків:
root@setevoy-nas:~ # smartctl -a /dev/ada0
root@setevoy-nas:~ # smartctl -a /dev/ada1
Нам цікаві в основному такі показники:
SMART overall-health self-assessment test result: PASSED: перевірку пройдено (але зазвичай FAILED тут з’являється, коли все зовсім погано)
помилки:
Reallocated_Sector_Ct:
кількість битих секторів, які диск знайшов і замінив резервними
0 – ідеально, зростання цього значення – поганий сигнал
Runtime_Bad_Block:
кількість некоректних блоків, знайдених під час нормальної роботи або деградацію лінка (наприклад, падіння швидкості SATA)
0 – ідеально, зростання цього значення – поганий сигнал
Uncorrectable_Error_Cnt:
кількість помилок читання/запису, які неможливо було виправити
0 – обов’язково має залишатися, зростання – вже серйозна проблема
Wear/Used reserve:
Wear_Leveling_Count:
показує знос елементів памʼяті SSD
0 означає, що диск практично новий або знос мінімальний
Used_Rsvd_Blk_Cnt_Tot:
скільки резервних блоків уже використано для заміни зношених
0 – ідеальний стан
Power_On_Hours:
кількість годин, протягом яких диск був увімкнений
83 години – диски тільки нещодавно купив і підключив
CRC_Error_Count:
кількість помилок передачі даних між диском і контролером (кабель, порт)
0 – норма, зростання часто означає проблеми з кабелем, а не з самим диском
Total_LBAs_Written / Host_Writes / NAND_Writes:
скільки даних реально записано на диск
порівнюємо зі TBW (Total Bytes Written) від виробника
в моєму випадку Total_LBAs_Written = 77982, де LBA – це Logical Block Address, який зазвичай SMART рахує по 512 байт, тобто записано на диск ~40 мегабайт – при заявлених Samsung 2400 TB
Temperature / Temperature_Celsius / Airflow_Temperature_Cel
температура дисків, в мене зараз 28 градусів
SMART Periodic
Для запуска SMART є власні скрипти в /usr/local/etc/periodic.
Аби включити перевірку і репорти – додаємо запуск smartd в автостарт:
І налаштовуємо periodic в /etc/periodic.conf.local.
Аби включити перевірку – треба явно задати диски, по яким цю перевірку робити:
daily_status_smart_devices="/dev/ada0 /dev/ada1"
Результат в репорті:
ZFS Pool Status
zpool status вже запускали вище, тепер додамо запуск по крону і відправку повідомлень.
Скрипти для ZFS /etc/periodic/daily/, наприклад /etc/periodic/daily/404.status-zfs.
Додаємо до /etc/periodic.conf.local:
daily_status_zfs_enable="YES"
Запускаємо скрипти:
root@setevoy-nas:/home/setevoy # periodic daily
Отримуємо листа:
root@setevoy-nas:/home/setevoy # mail -u root
Mail version 8.1 6/6/93. Type ? for help.
"/var/mail/root": 12 messages 4 new 12 unread
...
N 12 root@setevoy-nas Sun Dec 21 17:20 82/3368 "setevoy-nas daily run output"
&
Під час scrub ZFS порівнює checksum кожного блоку зі збереженим значенням і у разі виявлення помилки система фіксує її в логах та, за наявності mirror, автоматично відновлює дані з другої копії.
Оскільки для цього виконується багато I/O операцій, тому не варто запускати scrubbing часто – раз на місяць буде достатньо.
Запускаємо вручну:
root@setevoy-nas:/home/setevoy # zpool scrub nas
І перевіряємо в zpool status:
root@setevoy-nas:/home/setevoy # zpool status
pool: nas
state: ONLINE
scan: scrub repaired 0B in 00:00:00 with 0 errors on Fri Dec 19 16:54:04 2025
config:
NAME STATE READ WRITE CKSUM
nas ONLINE 0 0 0
mirror-0 ONLINE 0 0 0
gpt/zfs_disk1 ONLINE 0 0 0
gpt/zfs_disk2 ONLINE 0 0 0
errors: No known data errors
Тепер в scan є “scrub repaired 0B” – все добре.
Скрипт – /etc/periodic/daily/800.scrub-zfs, в якому перевіряється значення daily_scrub_zfs_default_threshold, і, якщо пройшло більше днів, ніж задано в threshold – то запускається zpool scrub.
З daily_scrub_zfs_pools можна вказати, які саме пули перевіряти.
З zpool events можна перевірити всі останні події в пулі:
root@setevoy-nas:/home/setevoy # zpool events
TIME CLASS
Dec 20 2025 16:46:39.702350180 sysevent.fs.zfs.history_event
Dec 20 2025 16:46:39.711350328 ereport.fs.zfs.config_cache_write
Dec 20 2025 16:46:39.711350328 sysevent.fs.zfs.config_sync
Dec 20 2025 16:46:39.711350328 sysevent.fs.zfs.pool_import
Dec 20 2025 16:46:39.712349914 sysevent.fs.zfs.history_event
Dec 20 2025 16:46:39.720349727 sysevent.fs.zfs.config_sync
Dec 20 2025 14:46:44.749348450 sysevent.fs.zfs.config_sync
...
Dec 21 2025 17:26:30.905209986 sysevent.fs.zfs.history_event
Аби побачити деталі – додаємо -z:
root@setevoy-nas:/home/setevoy # zpool events -v
TIME CLASS
Dec 20 2025 16:46:39.702350180 sysevent.fs.zfs.history_event
version = 0x0
class = "sysevent.fs.zfs.history_event"
pool = "nas"
pool_guid = 0x2f9ad6b17a5e8426
pool_state = 0x0
pool_context = 0x0
history_hostname = ""
history_internal_str = "pool version 5000; software version zfs-2.2.7-0-ge269af1b3; uts 14.3-RELEASE 1403000 amd64"
history_internal_name = "open"
history_txg = 0x2d65
history_time = 0x6946b6cf
time = 0x6946b6cf 0x29dd0364
eid = 0x1
...
А з zpool history можна подивитись всі команди, які запускались:
root@setevoy-nas:/home/setevoy # zfs-stats -E
------------------------------------------------------------------------
ZFS Subsystem Report Sun Dec 21 18:07:59 2025
------------------------------------------------------------------------
ARC Efficiency: 104.72 k
Cache Hit Ratio: 99.87% 104.58 k
Cache Miss Ratio: 0.13% 137
Actual Hit Ratio: 99.87% 104.58 k
Data Demand Efficiency: 100.00% 0
CACHE HITS BY CACHE LIST:
Most Recently Used: 23.21% 24.28 k
Most Frequently Used: 76.79% 80.30 k
Most Recently Used Ghost: 0.00% 0
Most Frequently Used Ghost: 0.00% 0
CACHE HITS BY DATA TYPE:
Demand Data: 0.00% 0
Prefetch Data: 0.00% 0
Demand Metadata: 99.96% 104.54 k
Prefetch Metadata: 0.04% 39
CACHE MISSES BY DATA TYPE:
Demand Data: 0.00% 0
Prefetch Data: 0.00% 0
Demand Metadata: 91.24% 125
Prefetch Metadata: 8.76% 12
Або використати zfs-mon:
Ну і по моніторингу ZFS цього, мабуть, вистачить.
Ще окремо будемо говорити вже про повноцінний моніторинг системи – планую з VictoriaMetrics, і тоді можна буде додати якийсь експортер для ZFS, наприклад zfs_exporter.
Але зараз, аби підключитись до якогось хосту в мережах треба вказувати IP-адресу.
Можна, звісно, прописувати все в файлах /etc/hosts, але це і не дуже зручно, і будуть клієнти типу Android-телефонів, і взагалі – хочеться все красівоє.
Тому зробимо локальний DNS, на якому буде централізований DNS для всієї інфраструктури:
власну локальну DNS-зону .setevoy
DNS-відповіді для .setevoy, що залежать від мережі, з якої приходить запит (office/home/VPN)
резолвінг зовнішніх доменів через forward-DNS (Cloudflare/Google)
Всі частини серії по налаштуванню домашнього NAS на FreeBSD:
Я колись мав BIND, який обслуговував навіть зону самого rtfm.co.ua – але для маленької домашньої мережі це зовсім overkill.
Мав справу з dnsmasq – простий, швидкий, вміє в DHCP, але трохи обмежений в можливостях.
І Unbound – рідний для FreeBSD, теж простий і швидкий, вміє в DNSSEC validation прям з коробки, вміє в views/access-control – те, що треба.
Routers та DHCP зі Static IP
Взагалі, можна було б заморочитись і автоматизувати навіть видачу IP для хостів в мережах, але, по-перше, ну камон – в мене 3 машинки у двох мережах 🙂
По-друге – робочий ноут і ThinkCenter знаходяться в офісі, а домашній ноутбук – вдома. Тобто, робити DHCP на хості з FreeBSD – така собі затєя, бо домашній ноутбук може бути не підключеним до VPN.
Тому – прибиваємо все статично в налаштуваннях роутера (чекаю, чекаю, коли до мене доїде MikroTik hAP ax3!):
Тепер для робочого ноута, коли він підключений по Ethernet, адреса завжди буде 192.168.0.3.
root@setevoy-nas:/home/setevoy # service unbound start
Перевіряємо локально, з хоста з FreeBSD з drill замість dig:
root@setevoy-nas:/home/setevoy # drill nas.setevoy @127.0.0.1
...
;; ANSWER SECTION:
nas.setevoy. 3600 IN A 192.168.0.2
...
І якийсь зовнішній домен:
root@setevoy-nas:/home/setevoy # drill google.com @127.0.0.1
...
;; ANSWER SECTION:
google.com. 300 IN A 142.251.98.101
google.com. 300 IN A 142.251.98.139
...
Тепер при підключенні VPN буде задаватись 10.8.0.1 як основний, і адреса домашнього роутера 192.168.100.1 як fallback опція.
Ще можна буде налаштувати split-DNS, задавши Domains = ~setevoy, аби до 10.8.0.1 робити запити тільки для зони .setevoy, див. systemd-resolved та systemd-networkd.
Налаштування forward-only DNS
З поточним конфігом наш unbound виконує повний рекурсивний пошук.
Тобто, коли ми з клієнта робимо запит google.com – то:
unbound звертається до root-серверів DNS
там шукає Name Servers для зони .com
звертається до цих Name Servers, запитуючи інформацію про Name Servers для google.com
і тільки після цього йде на Name Servers google.com, звідки бере інформацію по IP, яку потім повертає клієнту
Натомість можемо налаштувати forward-only, і тоді Unbound одним запитом сходить на 1.1.1.1 або 8.8.8.8, і відразу поверне відповідь клієнту.
root@setevoy-nas:/home/setevoy # unbound-checkconf && service unbound reload
Тепер Unbound в порядку пріорітету спочатку перевірить свою локальну зону, якщо google.com в нього на обслуговуванні нема – то він переадресує запит до Cloudflare або Google.
Налаштування логів
Робив для того, аби подивись як впливає налаштування forward-zone – тому теж нехай тут буде.
Тепер відключаємо forward-zone, з робочого ноутбука робимо запит про google.com до нашого DNS:
[setevoy@setevoy-work ~] $ time dig +short google.com @192.168.0.2
...
real 0m0.109s
Час виконання – 0.109s.
Дивимось лог Unbound:
...
info: 192.168.0.3 google.com. A IN
...
info: priming . IN NS
info: sending query: . NS IN
info: reply from <.> 199.7.83.42#53
info: priming successful for . NS IN
...
info: sending query: com. A IN
info: reply from <.> 202.12.27.33#53
info: query response was REFERRAL
...
info: sending query: google.com. A IN
info: reply from <google.com.> 216.239.38.10#53
info: query response was ANSWER
...
info: 192.168.0.3 google.com. A IN NOERROR 0.101574
...
Тут добре видно, як Unbound працює як рекурсивний резолвер :
спочатку виконує запит до root-зони “.“, де отримує Name Servers для зони .com
далі звертається до Name Servers зони .com, де отримує адреси Name Servers зони google.com
і лише після цього звертається вже до Name Servers домену google.com, отримуючи фінальну A-відповідь з IP-адресами серверів Google
А тепер повертаємо forward-zone, але залишаємо відключеним кешування (cache-min/max-ttl) і робимо запит з клієнта ще раз:
[setevoy@setevoy-work ~] $ time dig +short google.com @192.168.0.2
...
real 0m0.016s
Тепер час на отримання відповіді – 0.030s, а без forward-zone він був 0.109s (або з логу – google.com. A IN NOERROR 0.101574). Майже в 3 раз швидше. Іноді буває і 0.01s.
І логи Unbound тепер набагато менші:
...
debug: forwarding request
...
debug: ip4 1.1.1.1 port 53
debug: ip4 8.8.8.8 port 53
info: sending query: google.com. A IN
debug: sending to target: <.> 8.8.8.8#53
...
info: 192.168.0.3 google.com. A IN NOERROR 0.039006
...
Все, що він зробив – це передав запит до 8.8.8.8 і повернув результат, отриманий від нього.
Unbound DNS views для різних мереж
Зараз у нас з дому, який за VPN, при запиті nas.setevoy повернеться адреса 192.168.0.1:
...
local-data: "nas.setevoy. A 192.168.0.2"
...
Але якщо хочемо ходити чисто через нетворк VPN – можемо розділити зони з views і для кожної мережі створити власні зони:
server:
interface: 0.0.0.0
do-daemonize: yes
do-ip6: no
hide-identity: yes
hide-version: yes
# map client networks to views
access-control-view: 127.0.0.0/8 office
access-control-view: 192.168.0.0/24 office
access-control-view: 10.8.0.0/24 vpn
#######################
### Hosts list note ###
#######################
# -Office:
# - setevoy-work: Arch Linux laptop
# - setevoy-pc: Arch Linux/Windows gaming PC
# - setevoy-nas: FreeBSD ThinkCentre
# - TP-Link router
# - Home:
# - setevoy-home: Arch Linux laptop
# - TP-Link router
################
# OFFICE VIEW #
################
view:
name: "office"
local-zone: "setevoy." static
local-data: "nas.setevoy. A 192.168.0.2"
local-data: "work.setevoy. A 192.168.0.3"
local-data: "pc.setevoy. A 192.168.0.4"
#############
# VPN VIEW #
#############
view:
name: "vpn"
local-zone: "setevoy." static
local-data: "nas.setevoy. A 10.8.0.1"
# not VPN-connected, but Home has routing to the 192.168.0.0/24
local-data: "work.setevoy. A 192.168.0.3"
local-data: "pc.setevoy. A 192.168.0.4"
#################
# FORWARD ONLY #
#################
forward-zone:
name: "."
forward-addr: 1.1.1.1
forward-addr: 8.8.8.8
Тут в access-control-view: 10.8.0.0/24 vpn прив’язує клієнтів з VPN-мережі 10.8.0.0/24 до view з ім’ям vpn, для якого далі окремо описується DNS-логіка.
Конкретно в моєму сетапі домашній ноутбук все одно має роутинг в мережу 192.168.0.0/24 через 10.8.0.1, і можна повертати адреси з неї – але в цілому штука корисна.
Перевіряємо, застосовуємо:
root@setevoy-nas:/home/setevoy # unbound-checkconf && service unbound reload
unbound-checkconf: no errors in /usr/local/etc/unbound/unbound.conf
Перевіряємо з дому – отримуємо адресу з мережі VPN:
Основна ідея – поєднати (нарешті!) мій “офіс” і квартиру, а пізніше, можливо, ще і підключити сервер, на якому зараз працює rtfm.co.ua – аби бекапи блогу і баз даних зберігати відразу на ZFS пулі з RAID домашнього сервера.
WireGuard vs OpenVPN
Коли діло дійшло до вибору який конкретно VPN сервер вибрати, то я спочатку думав взяти OpenVPN – бо працюю з ним не один рік, і на RTFM про нього навіть є якісь матеріали.
Але, трохи подумавши вирішив, що для домашнього VPN рішення типу OpenVPN або Pritunl будуть трохи оверхедом, і можна спробувати WireGuard.
Системи дуже різні, але якщо коротко, то:
WireGuard набагато менший по коду – наприклад, Linux-реалізація це близько 4000 строк в ядрі, тоді як в OpenVPN це близько 100,000 строк в user space
WireGuard працює як модуль ядра – обробка пакетів і криптографія виконуються безпосередньо в kernel space, а OpenVPN є user space сервісом і працює через TCP або UDP socket та взаємодіє з ядром через стандартний мережевий стек ядра
туди ж – шифрування, бо WireGuard має вбудовану криптографію, яка є частиною самого протоколу і працює в kernel space, а OpenVPN використовує стандартний SSL/TLS стек (OpenSSL, LibreSSL тощо) в user space, що додає складність і накладні витрати CPU/RAM
модель роботи WireGuard – peer-to-peer, тобто протокол не має вбудованих ролей “сервер” чи “клієнт”, є лише Peers з ключами і дозволеними IP, тоді як OpenVPN побудований навколо класичної клієнт-серверної архітектури
В результаті WireGuard можна сприймати не як окремий сервіс, а як зашифрований мережевий інтерфейс, тоді як OpenVPN залишається класичним прикладним VPN-сервісом.
“офіс”: окрема локальна мережа 192.168.0.0/24, на вході – роутер TP-LINK Archer AX12
в цій мережі є робочий ноутбук з Arch Linux і Lenovo ThinkCentre з FreeBSD
на FreeBSD буде NAS, NFS і, власне, саме WireGuard
хоча Archer AX12 має власні вбудовані OpenVPN і WireGuard – але хочеться зробити самому, руками, плюс все ж більше контролю
дома: там мережа 192.168.100.0/24, на вході точно такий же роутер Archer AX12
там єдиний клієнт – це домашній ноутбук з Arch Linux
І що я хочу зробити:
на FreeBSD буде WireGuard в ролі VPN-сервера
на роутері Archer AX12 буде NAT port-forwarding для підключення до WireGuard на FreeBSD
мережа VPN – 10.8.0.1/24
на FreeBSD – Paket Filter firewall для контролю трафіка
обидва ноутбуки повинні мати доступ один до одного і до майбутнього NAS на FreeBSD
Як це в результаті виглядає схематично:
Запуск WireGuard на FreeBSD
У FreeBSD (власне, як і в Linux) WireGuard – це kernel module + userspace tools: основна “робоча” частина завантажується як модуль ядра, а для роботи з ним встановлюється окремий пакет.
роутити інтернет через VPN не потрібно – тільки трафік між мережами дома і офісу
Конфіг pf зараз – мінімалістичний, з попереднього поста:
allowed_tcp_ports = "{ 22 }"
allowed_clients = "{ 192.168.0.0/24, 192.168.1.0/24 }"
set skip on lo
block all
# allow ssh only from specific hosts
pass in proto tcp from $allowed_clients to any port $allowed_tcp_ports keep state
# allow all outgoing traffic
pass out all keep state
Що до нього треба додати:
дозволити вхідні UDP-з’єднання на порт WireGuard (51820) для handshake
дозволити трафік з VPN-мережі 10.8.0.0/24 до самого FreeBSD-хоста (ping, SSH)
дозволити транзитний трафік з VPN-мережі 10.8.0.0/24 до локальних мереж офісу і дому (192.168.0.0/24 та 192.168.100.0/24)
дозволити ICMP і SSH з VPN-мережі та домашньої мережі до FreeBSD-хоста
дозволити вихідний трафік з FreeBSD
Я додав макроси в конфіг, але поки пишу і тестую – вказую всі порти та адреси явно прямо в конфігу, простіше читати.
Тепер /etc/pf.conf буде виглядати так:
##################
### Interfaces ###
##################
# lan_if = "em0"
# wg_if = "wg0"
################
### Networks ###
################
# lan_net = "192.168.0.0/24"
# home_net = "192.168.100.0/24"
# wg_net = "10.8.0.0/24"
# vpn_nets = "{ 10.8.0.0/24, 192.168.100.0/24 }"
################
### Services ###
################
# ssh_ports = "{ 22 }"
# wg_port = "51820"
######################
### Basic settings ###
######################
# do not filter loopback traffic
set skip on lo
######################
### Default policy ###
######################
# block everything by default
block all
#######################
### Inbound traffic ###
#######################
### SSH
# allow SSH from Office LAN (192.168.0.0/24) to FreeBSD host
pass in log on em0 proto tcp from 192.168.0.0/24 to (em0) port 22 keep state
# allow SSH from Home network (192.168.100.0/24) to FreeBSD host
pass in log on em0 proto tcp from 192.168.100.0/24 to (em0) port 22 keep state
# allow SSH from VPN clients to FreeBSD host
pass in on wg0 proto tcp from 10.8.0.0/24 to (wg0) port 22 keep state
### VPN
# allow WireGuard handshake (UDP/51820) on LAN interface
pass in on em0 proto udp to (em0) port 51820 keep state
# allow VPN clients (10.8.0.0/24) to access FreeBSD host itself
# this allows ping, ssh, etc. to the wg0 address
pass in on wg0 from 10.8.0.0/24 to (wg0) keep state
# allow VPN clients to access Office LAN (192.168.0.0/24)
pass in on wg0 from 10.8.0.0/24 to 192.168.0.0/24 keep state
# allow VPN clients to access Home network (192.168.100.0/24)
pass in on wg0 from 10.8.0.0/24 to 192.168.100.0/24 keep state
# allow ICMP (ping) from VPN clients to FreeBSD host
pass in on wg0 proto icmp from 10.8.0.0/24 to (wg0) keep state
# allow ICMP (ping) from Home network to FreeBSD host
pass in on em0 proto icmp from 192.168.100.0/24 to (em0) keep state
############################
### outbound traffic ###
############################
# allow all outbound traffic from FreeBSD
pass out keep state
Перевіряємо синтаксис:
root@setevoy-nas:/home/setevoy # pfctl -vnf /etc/pf.conf
set skip on { lo }
block drop all
pass in log on em0 inet proto tcp from 192.168.0.0/24 to (em0) port = ssh flags S/SA keep state
pass in log on em0 inet proto tcp from 192.168.100.0/24 to (em0) port = ssh flags S/SA keep state
pass in on wg0 inet from 10.8.0.0/24 to (wg0) flags S/SA keep state
pass in on wg0 inet proto icmp from 10.8.0.0/24 to (wg0) keep state
pass in on wg0 inet from 10.8.0.0/24 to 192.168.0.0/24 flags S/SA keep state
pass in on wg0 inet from 10.8.0.0/24 to 192.168.100.0/24 flags S/SA keep state
pass in on em0 inet proto icmp from 192.168.100.0/24 to (em0) keep state
pass in on em0 proto udp from any to (em0) port = 51820 keep state
pass out all flags S/SA keep state
Виконуємо reload:
root@setevoy-nas:/home/setevoy # service pf reload
Reloading pf rules.
Тепер можемо підготувати запуск WireGuard.
Конфігурація WireGuard
Тут все дуже просто – створити ключі, написати конфіг-файл.
Створення ключів
Комунікація і криптографія у WireGuard побудована на стандартній схемі асиметричних ключів:
на “сервері” зберігається приватний ключі
на клієнті вказується публічний
під час handshake клієнт переконується, що підключився саме до того сервера, публічний ключ якого він знає
а далі, з цими ключами, відбувається і шифрування даних
Слово “сервер” все ж беру в лапки, бо, як було зазначено вище – WireGuard це P2P, а не client-server.
Після установки wireguard-tools створюється каталог /usr/local/etc/wireguard – переходимо туди, і з wg genkey створюємо приватний та публічний ключ:
root@setevoy-nas:/home/setevoy # cd /usr/local/etc/wireguard
root@setevoy-nas:/usr/local/etc/wireguard # wg genkey | tee server.key | wg pubkey > server.pub
root@setevoy-nas:/usr/local/etc/wireguard # chmod 600 server.key
root@setevoy-nas:/usr/local/etc/wireguard # ll
total 12
-rw------- 1 root wheel 45 Dec 17 15:58 server.key
-rw-r--r-- 1 root wheel 45 Dec 17 15:58 server.pub
Базовий конфіг для WireGuard
Можна створити кілька різних конфігурацій в /usr/local/etc/wireguard/, кожен на власному порті та/або IP та з власним ключем і мати кілька різних VPN-підключень, а керувати ними використовуючи ім’я файлу – wg0, wg1, etc.
Блок Interface визначає параметри WireGuard-інтерфейсу wg0 – його IP-адресу, UDP-порт і приватний ключ, який використовується для шифрування трафіку.
Тут жеж можна вказати які DNS використовувати, чи робити апдейт в таблицях маршрутизації клієнтів (default – true) і запуск скриптів з PreUp, PostUp, PreDown, PostDown.
Запускаємо сам WireGuard:
root@setevoy-nas:/home/setevoy # wg-quick up wg0
[#] ifconfig wg create name wg0
[#] wg setconf wg0 /dev/stdin
[#] ifconfig wg0 inet 10.8.0.1/24 alias
[#] ifconfig wg0 mtu 1420
[#] ifconfig wg0 up
[+] Backgrounding route monitor
Перевіряємо інтерфейс:
root@setevoy-nas:/home/setevoy # ifconfig wg0
wg0: flags=10080c1<UP,RUNNING,NOARP,MULTICAST,LOWER_UP> metric 0 mtu 1420
options=80000<LINKSTATE>
inet 10.8.0.1 netmask 0xffffff00
groups: wg
nd6 options=109<PERFORMNUD,IFDISABLED,NO_DAD>
Та статус WireGuard:
root@setevoy-nas:/home/setevoy # wg show
interface: wg0
public key: xLWA/FgF3LBswHD5Z1uZZMOiCbtSvDaUOOFjH4IF6W8=
private key: (hidden)
listening port: 51820
Поки у нас нема ніяких клієнтів – переходимо до них.
TP-Link Dynamic DNS та NAT port-forwarding
Аби з дому підключатись до хосту з FreeBSD і WireGuard – на роутері в офісі додаємо форвард портів:
protocol: UDP
зовнішній порт на роутері: 51830 (трохи замаскувати від ботів)
куди форвардити: 192.168.0.2 (хост з FreeBSD)
на який порт форвардити: 51830 (WireGuard на em0 на FreeBSD)
На TP-Link Archer AX12 це виглядає так:
Якщо Internet IP в офісі динамічний – в Archer AX12 є можливість налаштування Dynamic DNS:
Хоча в мене він статичний, але DDNS заради інтересу налаштував з https://www.noip.com.
Запуск WireGuard на Arch Linux
В Linux все аналогічно – в ядрі є модулі, нам треба тільки встановити пакет з утилітами.
Тут в AllowedIPs вказуємо мережі в які, по-перше, буде доступ взагалі, по-друге – вони будуть додані в таблиці маршрутизації (“Acts as a routing table and access control list“).
Запускаємо на клієнті:
[root@setevoy-wg-test setevoy]# wg-quick up wg0
[#] ip link add dev wg0 type wireguard
[#] wg setconf wg0 /dev/fd/63
[#] ip -4 address add 10.8.0.3/24 dev wg0
[#] ip link set mtu 1420 up dev wg0
[#] ip -4 route add 10.8.0.3/32 dev wg0
[#] ip -4 route add 192.168.0.0/24 dev wg0
Тут:
ip -4 address add: Interface - Address заданий для wg0
ip -4 route add 10.8.0.3/32 та 192.168.0.0/24: додані нові роути через інтерфейс wg0 для мереж VPN та офісної локалки
Перевіряємо:
root@setevoy-home:/etc/wireguard # ip r s 10.8.0.0/24
10.8.0.0/24 dev wg0 proto kernel scope link src 10.8.0.3
root@setevoy-home:/etc/wireguard # ip r s 192.168.0.0/24
192.168.0.0/24 dev wg0 scope link
Додаємо Peer на сервері, файл /usr/local/etc/wireguard/wg0.conf тепер буде таким:
В принципі, на цьому вже майже все готове – доступ є, все працює.
Але чого хочеться ще – це мати прямий доступ з домашнього ноута на робочий і з робочого на домашній, бо на робочому ноуті VPN нема – він там і не потрібен, бо FreeBSD/NAS в тій самій локальній мережі.
Конфігурація cross-LAN доступу
Тож що треба зробити – це налаштувати прямий доступ між ноутами в домашній мережі 192.168.100.0/24 і офісній 192.168.0.0/24, бо зараз з робочого ноутбука на ноутбук вдома і навпаки доступ не працює.
Картина зараз така:
IP ноута в офісі: 192.168.0.165
IP ноута вдома: 192.168.100.205
на робочому ноуті WireGuard нема
з офісу на домашній ноут конекта нема
з дому на робочий ноут конекта нема
з дому на FreeBSD конект є
Налаштування Routing tables
Поки робимо – закоментуємо block all в /etc/pf.conf, потім до нього повернемось.
В результаті того, що зараз будемо робити – вийде ось така схема: тут головне – це маршрути, спеціально робив схемою, аби те, що буде описано далі було простіше зрозуміти:
Перевіряємо роути з домашнього ноута на FreeBSD:
root@setevoy-home:/etc/wireguard # ip route get 192.168.0.2
192.168.0.2 dev wg0 src 10.8.0.3 uid 0
І на робочий ноут:
root@setevoy-home:/etc/wireguard # ip route get 192.168.0.165
192.168.0.165 dev wg0 src 10.8.0.3 uid 0
Трафік йде через wg0, і Source Address для пакета задається як 10.8.0.3.
А на робочому ноуті роут на домашній ноут йде через 192.168.0.1:
[setevoy@setevoy-work ~] $ ip route get 192.168.100.205
192.168.100.205 via 192.168.0.1 dev wlan0 src 192.168.0.165 uid 1000
Тут 192.168.0.1 – дефолтний гейтвей, роутер в офісі, який нічого не знає про домашню мережу 192.168.100.0/24.
Тому перше – додаємо роут в домашню мережу через хост з FreeBSD:
[setevoy@setevoy-work ~] $ sudo ip route add 192.168.100.0/24 via 192.168.0.2
Перевіряємо ще раз:
[setevoy@setevoy-work ~] $ ip route get 192.168.100.205
192.168.100.205 via 192.168.0.2 dev wlan0 src 192.168.0.165 uid 1000
Тепер є контакт з офісу додому:
[setevoy@setevoy-work ~] $ ping 192.168.100.205 -c 1
PING 192.168.100.205 (192.168.100.205) 56(84) bytes of data.
64 bytes from 192.168.100.205: icmp_seq=1 ttl=63 time=62.0 ms
--- 192.168.100.205 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
Але з домашнього все ще нема, бо з дому ми шлемо:
з домашнього ноута з IP 192.168.100.205
через FreeBSD з IP 192.168.0.2
на робочий ноутбук з IP 192.168.0.165
Але у нас з домашнього ноутбука задається Source IP як 10.8.0.3:
root@setevoy-home:/etc/wireguard # ip route get 192.168.0.165
192.168.0.165 dev wg0 src 10.8.0.3 uid 0
Бо маршрут в 192.168.0.0/24 заданий через VPN інтерфейс wg0:
root@setevoy-home:/etc/wireguard # ip r s 192.168.0.0/24
192.168.0.0/24 dev wg0 scope link
А у wg0 заданий IP 10.8.0.3:
root@setevoy-home:/etc/wireguard # ip a s wg0
20: wg0: <POINTOPOINT,NOARP,UP,LOWER_UP> mtu 1420 qdisc noqueue state UNKNOWN group default qlen 1000
link/none
inet 10.8.0.3/24 scope global wg0
А робочий ноут нічого про мережу 10.8.0.0/24 не знає і не може повернути відповідь.
Тому на робочому ноуті додаємо ще один маршрут:
[setevoy@setevoy-work ~] $ sudo ip route add 10.8.0.0/24 via 192.168.0.2 dev wlan0
Перевіряємо:
[setevoy@setevoy-work ~] $ ip r s 10.8.0.0/24
10.8.0.0/24 via 192.168.0.2 dev wlan0
І тепер з домашнього ноута на робочий доступ теж є:
root@setevoy-home:/etc/wireguard # ping -c1 192.168.0.165
PING 192.168.0.165 (192.168.0.165) 56(84) bytes of data.
64 bytes from 192.168.0.165: icmp_seq=1 ttl=63 time=6.19 ms
--- 192.168.0.165 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
Аби ці роути додати постійно – можна зробити з NetworkManager CLI.
Видаляємо те, що додали вручну:
[setevoy@setevoy-work ~] $ sudo ip route del 10.8.0.0/24 via 192.168.0.2
[setevoy@setevoy-work ~] $ sudo ip route del 192.168.100.0/24 via 192.168.0.2
Знаходимо ім’я підключення:
[setevoy@setevoy-work ~] $ nmcli connection show
NAME UUID TYPE DEVICE
setevoy-tp-link-21-5 3a12a60d-7b37-4c20-b573-d27c47a94ae5 wifi wlan0
...
[setevoy@setevoy-work ~] $ nmcli connection show setevoy-tp-link-21-5 | grep ipv4.routes
ipv4.routes: { ip = 10.8.0.0/24, nh = 192.168.0.2 }; { ip = 192.168.100.0/24, nh = 192.168.0.2 }
Перезапускаємо підключення:
[setevoy@setevoy-work ~] $ sudo nmcli connection down setevoy-tp-link-21-5 && sudo nmcli connection up setevoy-tp-link-21-5
Connection 'setevoy-tp-link-21-5' successfully deactivated (D-Bus active path: /org/freedesktop/NetworkManager/ActiveConnection/15)
Connection successfully activated (D-Bus active path: /org/freedesktop/NetworkManager/ActiveConnection/16)
Перевіряємо роути тепер:
[setevoy@setevoy-work ~] $ ip route get 10.8.0.3
10.8.0.3 via 192.168.0.2 dev wlan0 src 192.168.0.165 uid 1000
[setevoy@setevoy-work ~] $ ip route get 192.168.100.205
192.168.100.205 via 192.168.0.2 dev wlan0 src 192.168.0.165 uid 1000
Тепер у нас є ping з офісного ноутбука на домашній:
[setevoy@setevoy-work ~] $ ping -c1 192.168.100.205
PING 192.168.100.205 (192.168.100.205) 56(84) bytes of data.
64 bytes from 192.168.100.205: icmp_seq=1 ttl=63 time=5.95 ms
--- 192.168.100.205 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
І з домашнього на робочий:
root@setevoy-home:/etc/wireguard # ping -c1 192.168.0.165
PING 192.168.0.165 (192.168.0.165) 56(84) bytes of data.
64 bytes from 192.168.0.165: icmp_seq=1 ttl=63 time=5.67 ms
--- 192.168.0.165 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
Налаштування Packet Filter
Але якщо ми включимо block all в pf, то підключення з офісу на домашній ноут зламається, бо у нас зараз правила тільки для FreeBSD host:
...
# allow SSH from Office LAN (192.168.0.0/24) to FreeBSD host
pass in log on em0 proto tcp from 192.168.0.0/24 to (em0) port 22 keep state
...
# allow ICMP (ping) from Home network to FreeBSD host
pass in on em0 proto icmp from 192.168.100.0/24 to (em0) keep state
...
Тут:
перше правило – дозволяє SSH з офісної мережі на IP інтерфейсу em0 хоста з FreeBSD
друге – дозволяє ping з домашньої мережі на IP інтерфейсу em0 хоста з FreeBSD
Тому додаємо ще два правила – з SSH і ping з офісу додому:
...
# allow SSH from Office network to Home network
pass in on em0 proto tcp from 192.168.0.0/24 to 192.168.100.0/24 port 22 keep state
...
# allow ICMP from Home network to Office network
pass in on em0 proto icmp from 192.168.0.0/24 to 192.168.100.0/24 keep state
...
Перевіряємо, перечитуємо конфіг pf:
root@setevoy-nas:/usr/local/etc/wireguard # pfctl -vnf /etc/pf.conf && service pf reload
set skip on { lo }
block drop log all
pass in log on em0 inet proto tcp from 192.168.0.0/24 to (em0) port = ssh flags S/SA keep state
pass in log on em0 inet proto tcp from 192.168.100.0/24 to (em0) port = ssh flags S/SA keep state
pass in on wg0 inet from 10.8.0.0/24 to (wg0) flags S/SA keep state
pass in on wg0 inet proto icmp from 10.8.0.0/24 to (wg0) keep state
pass in on wg0 inet from 10.8.0.0/24 to 192.168.0.0/24 flags S/SA keep state
pass in on wg0 inet from 10.8.0.0/24 to 192.168.100.0/24 flags S/SA keep state
pass in on em0 inet proto tcp from 192.168.0.0/24 to 192.168.100.0/24 port = ssh flags S/SA keep state
pass in on em0 inet proto icmp from 192.168.0.0/24 to 192.168.100.0/24 keep state
pass in on em0 proto udp from any to (em0) port = 51820 keep state
pass out all flags S/SA keep state
Reloading pf rules.
І тепер у нас є пінг з дому в офіс:
root@setevoy-home:/etc/wireguard # ping -c1 192.168.0.165
PING 192.168.0.165 (192.168.0.165) 56(84) bytes of data.
64 bytes from 192.168.0.165: icmp_seq=1 ttl=63 time=8.09 ms
--- 192.168.0.165 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
##################
### Interfaces ###
##################
# lan_if = "em0"
# wg_if = "wg0"
################
### Networks ###
################
# lan_net = "192.168.0.0/24"
# home_net = "192.168.100.0/24"
# wg_net = "10.8.0.0/24"
# vpn_nets = "{ 10.8.0.0/24, 192.168.100.0/24 }"
################
### Services ###
################
# ssh_ports = "{ 22 }"
# wg_port = "51820"
######################
### Basic settings ###
######################
# do not filter loopback traffic
set skip on lo
######################
### Default policy ###
######################
# block everything by default
block log all
#######################
### Inbound traffic ###
#######################
### SSH
# allow SSH from Office LAN (192.168.0.0/24) to FreeBSD host
pass in log on em0 proto tcp from 192.168.0.0/24 to (em0) port 22 keep state
# allow SSH from Home network (192.168.100.0/24) to FreeBSD host
pass in log on em0 proto tcp from 192.168.100.0/24 to (em0) port 22 keep state
# allow SSH from VPN clients to FreeBSD host
pass in on wg0 proto tcp from 10.8.0.0/24 to (wg0) port 22 keep state
### NEW
# allow SSH from Office netwrok to Home network
pass in on em0 proto tcp from 192.168.0.0/24 to 192.168.100.0/24 port 22 keep state
### TEST
# allow Office LAN to reach Home LAN via WireGuard
#pass in on em0 from 192.168.0.0/24 to 192.168.100.0/24 keep state
#pass out on wg0 from 192.168.0.0/24 to 192.168.100.0/24 keep state
# allow Home LAN to reach Office LAN via WireGuard
#pass in on wg0 from 192.168.100.0/24 to 192.168.0.0/24 keep state
#pass out on em0 from 192.168.100.0/24 to 192.168.0.0/24 keep state
### VPN
# allow WireGuard handshake (UDP/51820) on LAN interface
pass in on em0 proto udp to (em0) port 51820 keep state
# allow VPN clients (10.8.0.0/24) to access FreeBSD host itself
# this allows ping, ssh, etc. to the wg0 address
pass in on wg0 from 10.8.0.0/24 to (wg0) keep state
# allow VPN clients to access Office LAN (192.168.0.0/24)
pass in on wg0 from 10.8.0.0/24 to 192.168.0.0/24 keep state
# allow VPN clients to access Home network (192.168.100.0/24)
pass in on wg0 from 10.8.0.0/24 to 192.168.100.0/24 keep state
#
#pass in on em0 from 192.168.0.0/24 to 192.168.100.0/24 keep state
#pass in on wg0 from 192.168.100.0/24 to 192.168.0.0/24 keep state
### ICMP
# allow ICMP from VPN clients to FreeBSD host
pass in on wg0 proto icmp from 10.8.0.0/24 to (wg0) keep state
# allow ICMP from Home network to FreeBSD host
#pass in on em0 proto icmp from 192.168.100.0/24 to (em0) keep state
# allow ICMP from Home network to Office network
pass in on em0 proto icmp from 192.168.0.0/24 to 192.168.100.0/24 keep state
############################
### outbound traffic ###
############################
# allow all outbound traffic from FreeBSD
pass out keep state
Активні підключення в pftop:
Тут:
In 192.168.0.165:50286 => 192.168.0.2:22: SSH робочий ноут на FreeBSD
In 178.***.***.236:56432 => 192.168.0.2:51820: підключення з дому через NAT Port-forwarding на офісному роутері до VPN на FreeBSD
In 10.8.0.3:39442 => 192.168.0.165:22: SSH з дому на робочий ноут
Out 10.8.0.1:50589 => 10.8.0.3:22: SSH з FreeBSD на домашній
P.S. Який це дикий кайф – оцей во “traditional networking” а не ці всі AWS VPC і його subnets…
Зараз в системі є три “штатних” фаєрволи – 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.
Всі частини серії по налаштуванню домашнього NAS на FreeBSD:
quick: корисна штука – чи перевіряти правила далі, чи, якщо пакет підпадає під поточне правило, зупинити обробку і не перевіряти решту правил (далі буде приклад)
interface: імʼя інтерфейсу або групи, до яких застосовується правило (якщо не вказано – правило діє на всі інтерфейси)
Важливо: 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:
Аби перевірити синтаксис /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 підключення буде розірване, а якщо помилились в правилах – то не зможемо підключитись.
Запускаємо 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):
Тому читаємо його не 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, але подія зберігається в лог:
Якщо хочемо логувати ще і всі нові дозволені підключення – просто додаємо 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-адрес, для яких дозволений доступ:
І далі використовуємо їх в правилах як $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_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
...
Тепер у нас все ще є доступ з робочого ноутбуку, але не буде з хоста 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 з тими самими адресами:
А у /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 – виконуємо:
Якщо хочемо відновити правила, які є у файлі /etc/pf_clients.conf – робимо або service pf reload, або pfctl replace і файл, з якого треба взяти правила:
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 ще багато – див. посилання нижче.
Установку будемо робити по SSH з bsdinstall – завантажимо систему в режимі LiveCD, включимо SSH, і далі вже з робочого ноута виконаємо установку системи.
На віртуальній машині є три диски – аналогічно тому, як це буде на ThinkCentre:
Вибираємо Live System:
Логінимось з root:
Піднімаємо мережу:
# ifconfig em0 up
# dhclient em0
Налаштування SSH на FreeBSD LiveCD
Для SSH нам треба буде задати пароль root і робити зміни в /etc/ssh/sshd_config, але зараз це все не працює, бо система змонтована в read-only:
Дивимось, які розділи є зараз:
І робимо “dirty hack:
монтуємо нову файлову систему tmpfs в RAM на /mnt
копіюємо туди вміст /etc з LiveCD
монтуємо tmpfs поверх /etc (перекриваючи read-only каталог з ISO)
копіюємо підготовлені файли з /mnt назад у новий /etc
Виконуємо:
# mount -t tmpfs tmpfs /mnt
# cp -a /etc/* /mnt/
# mount -t tmpfs tmpfs /etc
# cp -a /mnt/* /etc/
Синтаксис mount для tmptfs – mount -t <fstype> <source> <mountpoint>, в source значення обов’язкове, тому ще раз вказуємо tmpfs.
Тепер задаємо пароль з passwd і стартуємо sshd з onestart:
# passwd
# service sshd onestart
Але SSH все ще не пустить, бо по дефолту логін root заборонений:
Вибираємо що будемо додавати в систему – ports треба, src опціонально, на реальному NAS точно варте:
Disk partitioning
Будемо робити мінімальну розбивку диска, тому вибираємо Manual:
Встановлювати систему будемо на ada0, вибираємо його і Create:
Далі треба вибрати схему, тут, в принципів, стандартно для 2025 року – GPT:
Підтверджуємо зміни, і маємо нову partition table на системному ada0:
Розділ freebsd-boot
Далі треба створити самі розділи.
Ще раз вибираємо ada0, Create, і створюємо розділ під freebsd-boot.
Це тільки зараз на віртуальній машині, на самому ThinkCentre тут будемо робити тип efi з розміром мегабайт у 200-500.
Зараз задаємо:
Type: freebsd-boot
Size: 512K
Mountpoint: empty
Label: empty
Підтверджуємо, йдемо до наступного розділу.
Розділ freebsd-swap
Ще раз Create, додаємо Swap.
Враховуючи що у нас на ThninkCentre будуть:
8 – 16 GB RAM
без sleep/hibernate
буде UFS і ZFS
То 2 гігабайти вистачить.
Задаємо:
Type: freebsd-swap
Size: 2GB
Mountpoint: empty
Label: empty
Розділ root з UFS
Основна система буде на UFS, бо вона дуже стабільна, не потребує для роботи RAM, швидко монтується, проста у відновленні, без складних механізмів кешування.
Задаємо:
Type: freebsd-ufs
Size: 14GB
Mountpoint: /
Label: rootfs – просто ім’я для нас
Решту дисків налаштуємо пізніше, зараз вибираємо Finish і Commit:
Finihsing installation
Чекаємо завершення копіювання:
Налаштовуємо мережу:
Таймзону:
В System Configuration – sshd, без мишки, включаємо ntpd і powerd:
System Hardening – враховуючи що це буде домашній NAS, але буду, мабуть, відкривати доступ ззовні, хоч і за фаєрволом – то є сенс трохи затюнити security:
read_msgbuf: дозволяємо dmesg тільки root
proc_debug: дозволяємо ptrace тільки root
random_pid: номера PID в більш рандомному порядку
clear_tmp: чистимо /tmp при ребутах
secure_console: вимагаємо пароль root при логіні з фізичної консолі
Вся “магія” ZFS – що в ньому все йде “з коробки” – не треба окремий LVM і його групи, не треба mdadm для RAID.
Для роботи з дисками в ZFS основна утиліта – zpool, для роботи з даними (datasets, файловими системами, снапшотами) – zfs.
Для об’єднання одного або кількох дисків у єдиний логічний storage ZFS використовує pool – в Linux LVM аналог volume group.
Створюємо пул:
root@test-nas-1:/home/setevoy # zpool create tank mirror ada1p1 ada2p1
Тут tank – ім’я пула, в mirror вказуємо, що це буде RAID1, і передаємо список розділів, які в цей пул включаються.
Перевіряємо:
root@test-nas-1:/home/setevoy # zpool status
pool: tank
state: ONLINE
config:
NAME STATE READ WRITE CKSUM
tank ONLINE 0 0 0
mirror-0 ONLINE 0 0 0
ada1p1 ONLINE 0 0 0
ada2p1 ONLINE 0 0 0
errors: No known data errors
ZFS відразу монтує цей пул в /tank:
root@test-nas-1:/home/setevoy # mount
/dev/ada0p3 on / (ufs, local, soft-updates, journaled soft-updates)
devfs on /dev (devfs)
tank on /tank (zfs, local, nfsv4acls)
Якщо хочемо змінити маунтпоінт – виконуємо zfs set mountpoint:
root@test-nas-1:/home/setevoy # zfs set mountpoint=/data tank
І він відразу монтується в новий каталог:
root@test-nas-1:/home/setevoy # mount
/dev/ada0p3 on / (ufs, local, soft-updates, journaled soft-updates)
devfs on /dev (devfs)
tank on /data (zfs, local, nfsv4acls)
В продовження теми логів AWS Load Balancer: в попередньому пості Golang: запис логів AWS Loab Balancer до VictoriaLogs зробили збір логів з власним logs collector на Golang, тепер треба з цих логів отримати щось корисне.
Раніше, коли у нас на проекті була Loki, ми з її RecordingRules створювали метрики, з яких потім малювали дашборди в Grafana і мали алерти з Alertmanager.
Тепер треба це перенести до VictoriaMetrics, у якої є власні RecordingRules, з якими VMAlert може виконувати запити до VictoriaLogs.
час target response time на конкретних ендпоінтах нашого Backend API
VictoriaLogs LogsQL ALB logs parsing
ALB access logs має чітко визначений формат полів (який, правда, іноді змінюється), див. документацію Access log entries.
Знаючи ці поля – з LogsQL pipes можемо їх розпарсити і створити потрібні fields, які потім будемо використовувати для обчислень.
extract pipe
Перший pipe, який використаємо – extract, з яким читаємо дані з логу і створюємо поля.
Всі поля нам не потрібні, деякі поля створюються ще на етапі збору логів в Go (описаний у Створення Log parser), тому задаємо <_> на ті поля, які хочемо скіпнути, і тепер маємо таку конструкцію:
Нам потрібно буде робити час відповіді і помилки по конкретним ендпоінтам нашого Backend API, і нам вистачить першої частини URI.
Тобто зі строки “https://staging.api.challenge.example.co:443/roadmap/media/123 HTTP/2.0” треба отримати тільки “/roadmap”.
Сам запит йде в полі request_line:
Тут можемо використати extract_regexp pipe з яким отримуємо першу частину після “/” і зберігаємо результат в regex named capture group з ім’ям uri_path:
Але зараз avg() рахує значення зі всіх numeric log fields:
avg returns the average value over the given numeric log fields .
Тому користі з цього нуль.
Натомість можемо вказати яке саме поле використовувати, вказуємо його аргументом до avg() – avg(target_processing_time):
{stream="alb"}
...
|stats by (domain_name) avg(target_processing_time) as avg_target_processing_time
Результат:
math pipe
Ще хочеться бачити загальний час виконання запитів, але в полях ALB у нас три окремі значення – request_processing_time, target_processing_time та response_processing_time.
Робити три окремі метрики, а потім рахувати в Grafana не хочеться, тому можемо зробити простіше – використати LogsQL math(): спочатку рахуємо суму по всім трьом полям, а потім отриманий результат передаємо до avg():
{stream="alb"}
...
| math (request_processing_time + target_processing_time + response_processing_time) as total_processing_time
|stats by (domain_name) avg(total_processing_time) as avg_total_processing_time
Результат:
Хоча насправді сенсу особо нема, бо в 99.9% випадків 99.9% часу все одно займає target_processing_time.
AWS Athena та AWS ALB logs
“Довіряй – але перевіряй”.
Тому зробимо табличку в AWS Athena, і потім з неї будемо робити запити для перевірки.
Тепер маємо таблицю, яка дозволяє виконувати SQL-запити до логів, що зберігаються в S3:
Перевіряємо поля:
SELECT elb_status_code, count(*)
FROM alb_access_logs
WHERE date(from_iso8601_timestamp(time)) = date('2025-12-04')
GROUP BY elb_status_code
ORDER BY elb_status_code;
Результат:
Ну і для додатково користуємось метриками з CloudWatch.
Recording Rules з VictoriaLogs
Розібрались з тим, як нам отримати поля, сформували поля – можна переходити до Recording Rules та метрик.
Але перед тим, як почати писати rules – є момент, який треба уточнити:
у нас логи з S3 збираються раз на 5 хвилин (“ELB publishes a log file for each load balancer node every 5 minutes” – документація Access log files)
дефолтний Recording Rules evaluation time – 1 хвилина (див. Configuration)
і якщо момент виконання rule не співпаде з моментом запуску log parser і у VictoriaLogs логів на цей момент не буде – то і наш rule не спрацює, і нічого не створить
Тоді задача, яка мала запуститись у, наприклад, 12:00:00 – буде запущена в 12:15:00, але вона буде шукати логи в проміжок часу 12:00:00 – 12:01:00.
Ну а в логах, в нашому конкретному випадку, timestamp парситься з самих логів, тому в VictoriaLogs час буде такий, як було збережено самим лоад-балансером.
Планування Recording Rules
Повернемось до початку – що хочеться бачити:
помилки 4хх – ALB та target
помилки 5хх – ALB та target
загальна кількість реквестів на ALB та по конкретних ендпоінтах нашого Backend API
час відповіді загальна
час target response time на конкретних ендпоінтах
Тобто задача, фактично, зводиться к трьом показникам – кількість помилок, кількість реквестів, час відповіді.
Як ми це можемо зробити з Recording Rules і метриками:
4хх і 5хх помилки по полям elb_status_code:
з фільтром filter elb_status_code :~ "40[1-4]|50[0-4]" отримуємо всі логи, в яких є elb_status_code з 4хх або 5хх
потім зі stats і rate() рахуємо кількість знайдених log records, в яких були 4хх або 5хх помилки
потім в дашбордах і алертах робимо фільтри по 4хх або 5хх (хоча можна і окремі метрики робити)
target_status_code – аналогічно
загальна кількість реквестів на секунду по домену:
кожен запис в логах == окремий HTTP query
тому можемо просто робити stats by (elb_id, domain_name) rate() – групуємо по elb_id, domain_name, аби потім мати фільтри в Grafana і алертах
загальна кількість реквестів на ендпоінт
аналогічно, тільки додаємо uri_path – stats by (elb_id, domain_name, uri_path) rate()
в uri_path у нас тільки перша частина запиту, їх не так багато різних, тому проблеми з high cardinality у VictoriaLogs не буде
ALB total response time і на конкретних ендпоінтах – тут є варіанти 🙂 див далі
Пам’ятаємо про high cardinality і в VictoriaMetrics: все, що ми використаємо у stats by () потім буде збережено в labels метрик, а тому робити групування по всяким всякі trace_id не варто – при потребі це можна буде перевіряти в самих логах, а в алертах і Grafana воно нам не треба.
Почнемо з самої простої – просто рахуємо скільки записів було отримано за останній evalution interval, тобто 1 хвилину, потім з rate() отримуємо кількість змін на секунду:
Деплоїмо, чекаємо 5-10 хвилин, перевіряємо в VictoriaMetrics:
О 10:13 маємо значення 4.83.
Перевіряємо в CloudWatch, де з math просто робимо “sum requests in 1 minute /60”:
Ті ж 4.83, правда о 10:12.
Можемо ще перевірити з Athena, яку підготували раніше, і з таким запитом:
SELECT
date_trunc('minute', from_iso8601_timestamp(time)) AS minute,
count(*) / 60.0 AS rps_estimated
FROM alb_access_logs
WHERE from_iso8601_timestamp(time) >= now() - interval '1' hour
GROUP BY 1
ORDER BY 1 DESC;
Ті ж цифри, тільки теж о 10:12:
ALB 4xx/5xx Errors rule
Далі робимо per second rate помилок – та ж сама логіка, тільки додаємо filter elb_status_code :~ "40[1-4]":
В CloudWtach – те саме, тільки знов на 1 хвилину раніше:
ALB Target Processing Time rule
Наступним хочеться отримати те, що в CloudWatch у нас приходить в метриці TargetResponseTime, але мати можливість отримати і загальний час по всьому LoadBalancer – і по окремим URI.
Проблема зі stats by () і avg()
Перше рішення, яке прийшло в голову – це просто взяти середнє по кожному ендпоінту, потім взяти середнє по всім отриманням значенням – і має бути +/- те самий результат, що в CloudWatch Target Response Time з Average.
Але якщо зробити метрику так:
- record: vmlogs:alb:stats:elb_target_processing_time:avg
debug: false
expr: |
{stream="alb"}
...
| stats by (elb_id, domain_name, uri_path) avg(target_processing_time) as avg_target_processing_time
І потім для отримання загального середнього значення у VictoriaMetrics виконувати avg():
Бо у нас в stats() результати рахуються для кожної групи (elb_id, domain_name, uri_path).
Тобто, якщо взяти такі приклади і використати тільки uri_path:
/uri-1: 200 requests по 0.1 секунді:
sum: 200*0.1 == 20 sec
avg: 200*0.1/200 == 0.1 sec
/uri-2: 10 requests по 2 секунди:
sum: 10*2 == 20 sec
avg: 10*2/10 == 2 sec
Якщо ми будемо рахувати результат як avg(vmlogs:alb:stats:elb_target_processing_time:avg) – то для отримання avg() ми візьмемо значення з кожної метрики:
А як CloudWatch обчислює Average взагалі, і по TargetResponseTime?
Він бере кількість отриманих метрик, Sample count – о 12:52 це було 143:
Потім бере суму по всім отриманим метрикам, Sum – в 12:52 це було 22:
І ділить суму на кількість – 22/143 == 0.1538:
Або якщо повернутись до нашого прикладу вище – то це сума часу по обом ендпоінтам поділена на загальну кількість запитів: (/uri-1 sum + /uri-2 sum) / (/uri-1 requests + /uri-1 requests):
(20+20)/210 == 0.19
Тобто реальний середній час відповіді нашого умовного ALB – 0.19 секунди, а не 1.05 у випадку з avg() або тим більш не 2.1 sec у випадку з sum().
Отже, якщо ми хочемо мати і загальний середній час по всьому ALB, і середній час по кожному ендпоінту – то у нас два варіанти:
або в stats() треба брати суму, а не avg(), а потім отриманий результат ділити на загальну кількість запитів
або мати окрему метрику на загальний середній час – і окрему на середній час по кожному ендпоінту
Замість vmlogs:alb:stats:elb_target_processing_time:count можна було б взяти vmlogs:alb:stats:elb_requests_total:count, яку ми робили вище – але в метриках target_processing_time ми прибираємо значення “-1”, яке буває в target_processing_time якщо підключення розірване чи завершилось помилкою, а в vmlogs:alb:stats:elb_requests_total:count у нас всі реквести без виключення.
І з цими двома метриками ми можемо отримати середнє значення часу відповіді по кожному ендпоінту так:
sum(
sum(vmlogs:alb:stats:elb_target_processing_time:sum) by (uri_path)
/
sum(vmlogs:alb:stats:elb_target_processing_time:count) by (uri_path)
)
by (uri_path)
Тут у нас в 12:47 ендпоінт /chats відповідав 0.54 секунди.
І те саме значення буде в самому першому варіанті:
А аби отримати середній загальний час відповіді по всьому лоад-балансеру – можемо або використати метрики vmlogs:alb:stats:elb_target_processing_time:sum і vmlogs:alb:stats:elb_target_processing_time:count так:
Я давно фанат ThinkPad, дуже люблю всю їхню лінійку.
Нещодавно десь зустрів модель X200, які випускались з 2008 року – просто десь побачив картинку, і дуже захотів собі в “колекцію”. Неочікувано – але він навіть знайшовся в продажу на OLX, тому купив собі цей чудо-девайс.
ThinkPad X200 overview
Він… Ну – це просто бімба 🙂
Перше, що відрізняє цей ноутбук від інших моделей – це поворотний екран на 360 градусів, і це “фірмова фішка” моделі саме X200 Tablet, бо є X200 без цього.
Друге – це тачскрін.
Зовнішній вигляд
Виглядає ThinkPad X200 Tablet так:
На борту навіть є dial-up модем!
Ну і, звісно, ніяких HDMI – для зовнішнього монітору тільки VGA.
Хоча HDMI 1.0 з’явився ще у 2003, але тоді він використовувався для DVD-плеєрів та телевізорах, а ноутбуки в ті роки ще йшли переважно з VGA.
Замовив перехідник VGA на HDMI, бо в моєму моніторі DELL U3421WE ніяких VGA, само собою, вже нема – подивимось, чи спрацює.
Справа є фізичний перемикач для відключення WiFi/Bluetooth, хоча в моїй моделі блютузу нема:
В комплекті йде стилус:
ThinkPad X200 Hardware
Звісно, залізо вже стареньке:
дисплей: 12.1 дюймів, 1440×900 точок
процесор: Intel Core 2 Duo SU9400, 1.4 GHz
відео: Integrated card Intel 4500MHD
оперативна пам’ять: DDR3-1066 MHz
в офіційній документації Lenovo завжди писали, що максимум пам’яті 4 гігабайти, але гугол каже, що 8 гіг працює без проблем
поки що в мене 4 GB, замовив нові 2 планки по 4 ГБ, подивимось чи запрацює
зараз (2025 рік) DDR3-1066 MHz на 4 ГБ коштує 440 гривень 🙂
диск: SATA, але сам диск HDD, 250 ГБ, 5400 RPM
начебто є можливість замінити диск на SSD, але корпус ще не розбирав, потім подивлюся, і якщо можна – то поставлю якийсь SSD
мережа:
Ethernet 1000 bmps
WiFi: 802.11a, 802.11g, 802.11n
Встановлення FreeBSD
Чому FreeBSD? Бо це моя перша UNIX-система, на якій я вперше збирав ядро, ще у 2006 чи 2007 році, і потім до 2012 чи 2013 це була моя основна система на моїх перших сервера.
Моя перша версія FreeBSD була… не пам’ятаю точно, чи то 5, чи то 6. Але згодом, після виходу FreeBSD 9 перейшов на Linux (CentOS). Правда чому саме – теж не пам’ятаю. Щось пов’язане зі змінами в роботі з пакетами і FreeBSD ports.
Ну і раз вже налаштовуємо “раритетний” ноутбук – то чому б не спробувати і “раритетну” систему? 🙂
Трохи ностальгії по тим часам, коли вчився працювати з портами FreeBSD, коли вперше розбирався з runlevels в Linux часів SysV init і їхніми аналогами у FreeBSD – режимами завантаження системи.
Хоча, звісно, встановлювати будемо актуальну версію FreeBSD, 14.3 на сьогодняшній день.
Єдине, що тачскрін завести не вдалося, і, судячи з результатів гуглінгу, не вдасться – бо FreeBSD просто не має драйвера.
Тому я все ж встановлю FreeBSD, пограюсь і опишу якісь деталі, але потім на цьому ноутбуці буде Arch Linux – там хоч і довелось трохи попрацювати напильником, але тачскрін працює відмінно.
Вибір образу FreeBSD
Для FreeBSD існує три гілки системи:
RELEASE: основна стабільна версія, апдейти тільки security + critical fixes – максимум стабільності, мінімум сюрпризів
STABLE: гілка розробки, з якої формується майбутній RELEASE – вже достатньо стабільна, але все ще можуть бути баги
CURRENT: гілка активної розробки з усіма новими фічами і плюшками – мінімум стабільності, максимум нових експерементальних штук
FreeBSD має окремі версії для майже всіх платформ:
Для нашого ThinkPad X200 вибираємо amd64, завантажуємо образ зі сторінки Get FreeBSD, і починаємо установку.
Встановлення FreeBSD на віртуальну машину
Аби не робити 100500 фотографій екрану – процес установки FreeBSD покажу на віртуалці з QEMU/KVM, бо принципової різниці нема.
По віртуалізації QEMU/KVM в Linux є чорнетка, допишу якось, давно лежить.
У FreeBSD дуже приємний інсталятор bsdinstall, з яким можна зробити все і відразу. І, як на мене – він зручніший за archintsall, з яким я взагалі не подружився і Arch Linux завжди встановлюю руками:
Вибираємо 1, далі маємо вибір – або запустити установку, або використати ISO як live-cd:
Задаємо ім’я хоста:
Далі вибір компонентів.
Нам тут потрібні тільки порти, “ports – Ports tree“, і можна додати “src – System source tree” – тут весь код FreeBSD, корисно, якщо хочеться зібрати власне ядро і для установку апдейдів з утилітою freebsd-update, про неї трохи далі:
Наступний крок – розбивка диску:
ZFS: файлова система з підтримкою snapshots, RAID і можливістю зміни розміру розділів
з’явилась у 2005 році для операційної системи Solaris від Sun Microsystems, і на той час була революційною системою
UFS: класична для FreeBSD файлова система – дуже стабільна, мінімум використання RAM, але і не має всіх фіч ZFS
Залишаємо дефолтну опцію “Auto (ZFS)”:
Забігаючи наперед – ось так потім виглядають розділи вже на самому ноутбуку:
І навіть пропонується відразу налаштувати RAID – але це явно не наш кейс:
Вибираємо диск для установки:
Чекаємо завершення установки:
Задаємо пароль root:
Вибираємо мережевий інтерфейс:
Це установка на віртуалку, WiFi тут нема.
Але на самому ноуті можна відразу налаштувати і його, хоча в моєму випадку з ThinkPad X200 потім довелось трохи робити руками, далі покажу.
Залишаємо DHCP:
Вибираємо таймзону:
Після чого налаштовуємо базові параметри системи:
local_unbound: локальний DNS-кеш + DNSSEC валідація, для домашнього ноута можна не включати
sshd: включаємо
moused: підтримка миші в консолі – користі мало, але виглядає прикольно 🙂
ntpd: синхронізація часу
powerd: динамічне керування частотою CPU, дуже корисно, особливо для ноутбука – зберігати час роботи батареї
dumpdev: допомагає дебажити kernel panic
Далі налаштування для security – цікаво, але для домашнього ноута можна пропустити, має сенс для серверів:
Детально можна почитати в документації Section 2.8.4, “Enabling Hardening Security Options”, але коротко пройдемось по опціях, бо тут проявляються перші (якщо не говорити про файлу систему) відмінності від Linux:
hide_uids і hide_gids: ховає процеси інших користувачів системи (root, звісно, бачить все)
hide_jail: ховає процеси, які запущені в jail
FreeBSD Jails – аналог “класичних” контейнерів в Linux, але з більше жорсткою ізоляцією
і з’явились вони ще до того, як в Linux ядрі взагалі з’явилась підтримка чогось схожого:
jails були додані у FreeBSD ще у 2000 році, з FreeBSD 4.0
в Linux на той час був тільки chroot, де ізоляція була дуже слабка, і можна було легко вийти за межі “контейнера” і тримати доступ до всієї системи
і тільки у 2006-2008 в Linux з’явились cgroups, аж в 2008-2013 були додані namespaces, і тільки після цього, у 2013 році, з’явився Docker і контейнери в Linux, якими ми їх знаємо зараз
proc_debug: вимикає для звичайних користувачів можливість дебажити чужі процеси і обмежує частину інформації з /proc
random_pid: якщо включено, то PID задається з рандомним зміщенням номеру, а не по черзі
clear_tmp: у FreeBSD каталог /tmp по дефолту не очищається при ребуті, з clear_tmp це можна включити
в Linux залежить від дистрибутива і того, як саме монтується /tmp, бо зазвичай це tmpfs в RAM, який, звісно, очищається
FreeBSD свідомо не очищає його, бо політика FreeBSD – “адміністратор сам вирішує, що треба робити системі“
disable_syslogd: заборона відкриття мережевого сокету для демона syslogd – на ноуті можна включити
але сам syslogd продовжує використання локального Unix socket /var/run/log для роботи
secure_console: блокує root-вхід з консолі без пароля при завантаженні системи в single-user mode (див. Chapter 15. The FreeBSD Booting Process – у FreeBSD дуже класна документація)
disable_dtrace: DTrace у FreeBSD – аналог strace/eBPF в Linux, для трасування процесів, системних викликів і роботи ядра, і має можливість вносити “на льоту” зміни в пам’ять та ядро; disable_dtrace блокує цю можливість навіть для root
Окей – залишаємо тут все по дефолту, і переходимо до user management:
Додаємо юзера:
У FreeBSD є можливість вказати Login class, і це фішка чисто FreeBSD, див. Configuring Login Classes.
Ну і власне на цьому установка завершена.
В останньому вікні є можливість щось дотюнити:
Ребутаємось, і маємо нову систему:
І екран загрузки вже на самому ноуті:
FreeBSD load options
Коротко по доступних опціях:
Boot Multi user (Enter): стандартний запуск, монтуються файлові системи, запускаються сервіси із /etc/rc.conf
аналог в Linux – звичайний boot у runlevel/systemd multi-user.target
Boot Single user: коли система зламалась, і треба щось пофіксити
запускається тільки ядро і мінімальний shell
“/” монтується в read-only (можна перемонтувати в r/w)
аналог в Linux – init=/bin/bash або systemd.unit=rescue.target
аналог в Linux – GRUB console, але FreeBSD loader глибше інтегрований з системою
Reboot: reboot 🙂
Cons: Video: фішка FreeBSD, задає тип для system console – video console (звичайний екран) або serial console
Kernel: default/kernel: вибір ядра для завантаження
при апгрейдах попередня версія ядра зберігається у /boot/kernel.old/, і при проблемах можна завантажити інші ядра
Boot Options: додаткові параметри для завантаження – відключити ACPI, DMA, ZFS cache, etc
Повертаємось до ноутбука.
Налаштування WiFi
При встановленні на ноутбук побачило карту:
І мережі:
Але після налаштування і завершення установки інтерфейсу нема:
Робимо вручну.
З pciconf -lv перевіряємо пристрої на PCI/PCI-Express шині:
Нас цікавить device iwn – “Intel WiFi Link 5300“:
The Intel® WiFi Link 5300 Series is a family of IEEE 802.11a/b/g/Draft-N1 wireless network adapters that operate in both the 2.4 GHz and 5.0 GHz spectra
Тут FreeBSD – це user-facing пакети, а FreeBSD-kmods – репозиторій для додаткових модулів ядра (nvidia-driver, virtualbox-kmod тощо).
FreeBSD Ports Collection – каталог із source code пакетів та Makefiles, розбиті по категоріям, наприклад порт для xfce4-conf буде в директорії /usr/ports/x11/:
root@setevoy-x200:/home/setevoy # ls -1 /usr/ports/x11/xfce4-conf/
Makefile
distinfo
files
pkg-descr
pkg-plist
Використовувати порти варто, якщо потрібні якісь особливі параметри для зборки та установки, але здебільшого все можна встановлювати з pkg.
Апгрейд системи і пакетів
Тут у нас є дві окремі частини – сама система, і пакети, які ми встановлюємо з pkg.
Для апгрейду системи у FreeBSD є класна утиліта freebsd-update – дозволяє і отримувати останні апдейти, і виконувати апгрейд самої системи.
Аби отримати і встановити вручну – робимо:
# freebsd-update fetch
# freebsd-update install
fetch – завантажити останні апдейти, install – встановити їх.
Або можна відразу додати в cron:
@daily root freebsd-update cron
freebsd-update fetch та freebsd-update install виконають апгрейд ядра, системних утиліт в /bin, /sbin, /usr/bin, бібліотеки в /lib, /usr/lib і драйверів.
Для апгрейду пакетів, які ми встановлюємо з pkg – використовуємо аналогічні команди:
# pkg update
# pkg upgrade
Аналогічно до freebsd-update – з update отримуємо оновлення, з upgrade встановлюємо їх.
# pkg install xorg drm-kmod
...
Number of packages to be installed: 318
The process will require 3 GiB more space.
431 MiB to be downloaded.
...
Встановлюємо сам XFCE – теж немаленький:
# pkg install xfce xfce4-goodies
...
Number of packages to be installed: 317
The process will require 1 GiB more space.
248 MiB to be downloaded.
...
Але році у 2010 встановлював KDE з ports – то процес зборки зайняв багато годин, бо pkg тоді ще не було, і в ті часи майже все встановлювалось із ports collection.
Була і тоді система pkg_tools, але в ній не було системи залежностей, пакети качались по FTP, а апгрейд пакетів виконувався фактично через видалення і встановлення заново.
До /etc/rc.conf додаємо запуск DBus (потрібен для XFCE) та Slim:
sysrc dbus_enable="YES"
sysrc slim_enable="YES"
Створюємо файл ~/.xinitrcв домашній директорії юзера, аби Slim знав що запускати:
# echo "exec startxfce4" > /home/setevoy/.xinitrc
Ребутаємо машинку:
# reboot
І вуаля – все готово:
Можна перевірити з яким драйвером працює відео:
# cat /var/log/Xorg.0.log | grep -E "Driver|scfb|vesa"
[ 145.813] X.Org Video Driver: 25.2
[ 145.862] (==) Matched scfb as autoconfigured driver 2
[ 145.862] (==) Matched vesa as autoconfigured driver 3
[ 145.864] Module class: X.Org Video Driver
[ 145.864] ABI class: X.Org Video Driver, version 25.2
[ 145.864] (II) LoadModule: "scfb"
[ 145.864] (II) Loading /usr/local/lib/xorg/modules/drivers/scfb_drv.so
[ 145.864] (II) Module scfb: vendor="X.Org Foundation"
[ 145.864] ABI class: X.Org Video Driver, version 25.2
[ 145.864] (II) LoadModule: "vesa"
[ 145.864] (II) Loading /usr/local/lib/xorg/modules/drivers/vesa_drv.so
[ 145.864] (II) Module vesa: vendor="X.Org Foundation"
[ 145.864] Module class: X.Org Video Driver
[ 145.865] ABI class: X.Org Video Driver, version 25.2
[ 145.865] (II) modesetting: Driver for Modesetting Kernel Drivers: kms
[ 145.865] (II) scfb: driver for wsdisplay framebuffer: scfb
[ 145.865] (II) VESA: driver for VESA chipsets: vesa
[ 145.873] (WW) Falling back to old probe method for scfb
[ 145.873] scfb trace: probe start
[ 145.873] scfb trace: probe done
[ 145.874] ABI class: X.Org Video Driver, version 25.2
[ 145.875] ABI class: X.Org Video Driver, version 25.2
[ 146.006] (II) UnloadModule: "scfb"
[ 146.006] (II) Unloading scfb
scfb чимось не сподобався, буде з vesa, але на цьому залізі взагалі не принципово.
Додаємо інші корисні пакети:
# pkg install vim sudo bash
Я вже звик до bash, тому встановлюю і його.
Аби змінити юзеру його shell – використовуємо chsh:
# chsh -s /usr/local/bin/bash setevoy
chsh: user information updated
Але, як казав на початку – на такому старому залізі під FreeBSD не працює тачскрін, тому потім все ж встановлю Arch Linux – з ним вже тестив, там тачскрін пальцями і стилусом працює без проблем.
Наступна задача, яку хочеться вирішити з Go – це написати власний logs collector для збору логів AWS Load Balancer з AWS S3 і запису їх до VictoriaLogs.
наш колектор опитує SQS, отримує інформацію про нові об’єкти в S3
робить запит до S3, отримує gz-архів
розпаковує, парсить дані, і відправляє до VictoriaLogs
Поїхали.
Налаштування S3 та SQS notifications
Коли продумував ідею, то головне питання було – як знати, які об’єкти в S3 ми вже обробили, а які ні?
Мати якусь базу, в яку писати інформацію про вже оброблені об’єкти – перший варіант. Але з часом записів про такі обєкти буде все більше, плюс не дуже хочеться тягнути якийсь stateful-сервіс в Kubernetes, де потім буде запускатись наш колектор.
Тому робимо простіше і надійніше, так само як це зроблено з Vector.dev та VPC Flow Logs – створимо SQS чергу, в яку будуть приходити повідомлення про нові S3 objects.
Читаємо повідомлення з SQS, отримуємо з них інформацію про нові файли, оброблюємо файли, видаляємо меседж із SQS queue.
Переходимо до S3 з логами, Properties > Event notifications:
Створюємо новий Event notification – відправляти повідомлення про всі операції s3:ObjectCreated:
В Destination вибираємо SQS і нашу чергу:
Чекаємо кілька хвилин, перевіряємо Monitoring в SQS:
Окей – є повідомлення, тепер їх треба зібрати і прочитати.
Переходимо до Go.
AWS SDK for Go
Для роботи з AWS нам буде потрібен AWS SDK for Go, з яким можемо виконати всі потрібні операції.
Для отримання даних доступу використовуємо aws-sdk-go-v2/config і функцію LoadDefaultConfig(), яка виконує стандартний пошук credentials – в змінних оточення, файлах ~/.aws/credentials та ~/.aws/config, або використовує EC2 IAM Roles.
...
// receive a single message from SQS
msgResp, err := sqsClient.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{
QueueUrl: queueURL, // URL returned by the GetQueueUrl()
MaxNumberOfMessages: 1, // receive only one message
WaitTimeSeconds: 10, // enable long polling
})
if err != nil {
log.Fatal(err)
}
if len(msgResp.Messages) == 0 {
fmt.Println("no messages received")
return
}
// print the received message body
fmt.Println("Received message:", aws.ToString(msgResp.Messages[0].Body))
...
...
// define a struct to unmarshal S3 event message
type S3Event struct {
Records []struct {
S3 struct {
Bucket struct {
Name string `json:"name"`
} `json:"bucket"`
Object struct {
Key string `json:"key"`
} `json:"object"`
} `json:"s3"`
} `json:"Records"`
}
// take SQS message body
msgBody := aws.ToString(msgResp.Messages[0].Body)
// decode JSON into the struct
var event S3Event
if err := json.Unmarshal([]byte(msgBody), &event); err != nil {
log.Fatal("failed to parse S3 event:", err)
}
// extract bucket and key
// we always have 1 message, so use [0]
bucket := event.Records[0].S3.Bucket.Name
key := event.Records[0].S3.Object.Key
fmt.Println("bucket:", bucket)
fmt.Println("key:", key)
...
Перевіряємо ще раз:
$ go run main.go
bucket: ops-1-33-devops-ingress-ops-alb-loki-logs
key: AWSLogs/492***148/elasticloadbalancing/us-east-1/2025/11/25/492***148_elasticloadbalancing_us-east-1_app.k8s-ops133externalalb-***.336cddd33c043f33_20251125T0935Z_52.***.***.213_60mjhvf6.log.gz
...
// fetch the S3 object and return its streaming body
objResp, err := s3Client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
})
if err != nil {
log.Fatal("failed to download object:", err)
}
defer objResp.Body.Close()
fmt.Println("S3 object stream opened:", bucket, key)
...
GetObject() повертає *GetObjectOutput, в якому є поле Body з типом io.ReadCloser, а io.ReadCloser – це інтерфейс, який визначає два методи – Reader та Closer.
Читання файлу з gzip
Логи в S3 зберігаються в gz, тому додаємо пакет gzip, і з NewReader() читаємо дані:
У нас вже доволі великий main(), і основні операції зробили, все працює.
Давайте наведемо трохи красоти.
Весь код зараз:
package main
import (
"bufio"
"compress/gzip"
"context"
"encoding/json"
"fmt"
"log"
"os"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/sqs"
)
func main() {
// TODO: add exit handler
ctx := context.Background()
// Load the Shared AWS Configuration (~/.aws/config)
cfg, err := config.LoadDefaultConfig(ctx)
if err != nil {
log.Fatal(err)
}
// create SQS client using the shared AWS config
sqsClient := sqs.NewFromConfig(cfg)
// read queue name from environment
queueName := os.Getenv("ALB_LOGS_QUEUE")
if queueName == "" {
log.Fatal("environment variable ALB_LOGS_QUEUE is not set")
}
// request queue URL by name
getURLResp, err := sqsClient.GetQueueUrl(ctx, &sqs.GetQueueUrlInput{
QueueName: aws.String(queueName),
})
if err != nil {
log.Fatal(err)
}
queueURL := getURLResp.QueueUrl
//fmt.Println("Queue URL:", *queueURL)
// receive a single message from SQS
msgResp, err := sqsClient.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{
QueueUrl: queueURL, // URL який ми отримали раніше
MaxNumberOfMessages: 1, // receive only one message
WaitTimeSeconds: 10, // enable long polling (recommended)
})
if err != nil {
log.Fatal(err)
}
if len(msgResp.Messages) == 0 {
fmt.Println("no messages received")
return
}
// print the received message body
//fmt.Println(aws.ToString(msgResp.Messages[0].Body))
// define a struct to unmarshal S3 event message
type S3Event struct {
Records []struct {
S3 struct {
Bucket struct {
Name string `json:"name"`
} `json:"bucket"`
Object struct {
Key string `json:"key"`
} `json:"object"`
} `json:"s3"`
} `json:"Records"`
}
// take SQS message body
msgBody := aws.ToString(msgResp.Messages[0].Body)
// decode JSON into the struct
var event S3Event
if err := json.Unmarshal([]byte(msgBody), &event); err != nil {
log.Fatal("failed to parse S3 event:", err)
}
// extract bucket and key
// we always have 1 message, so use [0]
bucket := event.Records[0].S3.Bucket.Name
key := event.Records[0].S3.Object.Key
//fmt.Println("bucket:", bucket)
//fmt.Println("key:", key)
// create S3 client using the shared AWS config
s3Client := s3.NewFromConfig(cfg)
// fetch the S3 object and return its streaming body
objResp, err := s3Client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
})
if err != nil {
log.Fatal("failed to download object:", err)
}
defer objResp.Body.Close()
fmt.Println("S3 object stream opened:", bucket, key)
// create gzip reader from S3 object stream
gzReader, err := gzip.NewReader(objResp.Body)
if err != nil {
log.Fatal("failed to create gzip reader:", err)
}
defer gzReader.Close()
//fmt.Println("gzip stream opened")
// create scanner to read decompressed log lines
scanner := bufio.NewScanner(gzReader)
// increase buffer size if ALB logs have long lines
// default scanner buffer = 64 KB
buf := make([]byte, 0, 1024*1024) // 1 MB
scanner.Buffer(buf, 1024*1024)
// iterate over every line in the decompressed file
for scanner.Scan() {
line := scanner.Text()
fmt.Println("log line:", line)
}
if err := scanner.Err(); err != nil {
log.Fatal("scanner error:", err)
}
}
Як можемо організувати процес?
В main() виконуємо всякі ініціалізації, а потім в циклі будемо опитувати SQS:
створюємо context
створюємо AWS config
створюємо клієнти sqsClient та s3Client
зчитуємо змінні середовища (ALB_LOGS_QUEUE, потім додамо ще)
і потім в циклі:
викликаємо функцію receiveFromSQS() – перевіряємо, чи з’явились нові меседжи
викликаємо функцію getS3Object() – якщо меседжи є, то йдемо до S3 і читаємо звідти новий архів
викликаємо функцію processLogFile() – зчитуємо строки з кожного отриманого логу
Які функції для цього знадобляться?
функція ReceiveFromSQS()
сюди будемо передавати context, SQS client, SQS queue URL, і записувати в структуру S3Event ім’я бакету та key – ім’я файлу
для подальшого видалення меседжів після успішної обробки – потрібно буде повертати receiptHandle
функція GetS3Object()
отримує context, AWS Config, bucket, key
виконує GetObject() і повертає GetObjectOutput
функція GzipReader()
читає дані від GetS3Object(), розпаковує, повертає строки
функція ScanLines()
отримує дані від GzipReader(), зчитує з Text(), і поки що просто виводить на консоль
Package collector
Для зручності і аби все було структуровано – розділимо все по окремим файлам:
collector/
sqs.go
s3.go
gzip.go
scan.go
main.go
Файл collector/sqs.go
Коли тестив, то зустрів таку помилку:
$ go run main.go | head
panic: runtime error: index out of range [0] with length 0
goroutine 1 [running]:
alb-logs-collector/collector.ReceiveFromSQS({0xa83530, 0xdda060}, 0xc00021d888, {0xc000160050, 0x4d})
/home/setevoy/Projects/Go/alb-logs-collector/collector/sqs.go:52 +0x25d
main.main()
/home/setevoy/Projects/Go/alb-logs-collector/main.go:46 +0x305
exit status 2
Виникає через, то AWS пише тестові повідомлення до черги:
JSONLogRecord struct: тут будемо формувати JSON для передачі на VictoriaLogs HTTP API
заповнюємо його даними, в поле Timestamp можна передавати “Unix timestamp in seconds, milliseconds, microseconds or nanoseconds” – ми робимо в time.UnixMilli()
package collector
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"time"
)
// JSONLogRecord is the minimal structure for VictoriaLogs JSONLine API
type JSONLogRecord struct {
Timestamp int64 `json:"date"`
Stream string `json:"stream"`
Message string `json:"message"`
}
// SendTestRecord sends simple test log record to VictoriaLogs
func SendTestRecord(url string) error {
rec := JSONLogRecord{
Timestamp: time.Now().UTC().UnixMilli(),
Stream: "test",
Message: "TEST VICTORIA",
}
body, err := json.Marshal(rec)
if err != nil {
return fmt.Errorf("marshal error: %w", err)
}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(body))
if err != nil {
return fmt.Errorf("request error: %w", err)
}
req.Header.Set("Content-Type", "application/stream+json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("http error: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("victoria returned %d", resp.StatusCode)
}
fmt.Println("TEST RECORD SENT TO VICTORIA")
return nil
}
До main.go додаємо отримання VictoriaLogs ендпоінта зі змінних оточення і з ним будуємо повний URL, в якому вказуємо які поля треба вважати як _msg, де буде _time, в якому форматі час, і за яким полем створювати Log stream:
...
// "http://localhost:9428/insert/jsonline"
vmLogsEp := os.Getenv("VICTORIA_LOGS_URL")
if vmLogsEp == "" {
log.Fatal("environment variable VICTORIA_LOGS_URL is not set")
}
// VictoriaLogs endpoint
vmLogsURL := vmLogsEp +
"?_msg_field=message" +
"&_time_field=date" +
"&_time_format=unix_ms" +
"&_stream_fields=stream"
// 5. send test record to VictoriaLogs
err = collector.SendVlTestRecord(vmLogsURL)
if err != nil {
fmt.Println("ERROR sending test record:", err)
return
}
...
collector/scan.go буде читати дані від gzip і писати в канал у вигляді готових strings
collector/parser.go – формує SimpleLog з полями Timestamp і Message
Редагуємо scan.go – замість створення буферу створюємо channel, і scanner.Text()тепер буде писати в канал замість буферу:
package collector
import (
"bufio"
"io"
)
// ScanLines reads lines from an io.Reader and sends them into a channel
// caller must range over the returned channel
func ScanLines(r io.Reader) <-chan string {
ch := make(chan string)
go func() {
defer close(ch)
scanner := bufio.NewScanner(r)
// - bufio.Scanner reads decompressed bytes from GzipReader()
// - it splits input by '\n' and returns each line as a complete string
// - each line is sent into the channel for further processing
for scanner.Scan() {
ch <- scanner.Text()
}
}()
return ch
}
Створюємо parser.go зі структурою SimpleLog.
В структурі будемо тримати час, отриманий із запису в log record, а в Message будемо заносити весь текст:
Поки collector.ScanLines() повертає дані – передаємо їх до ParseRawLine(), який заповнює SimpleLog з Timestamp і Message.
Потім заповнюємо JSONLogRecord і передаємо до VictoriaLogs.
Зараз можна очистити SQS-чергу: поки я писав код, там назбирались старі повідомлення, і експортер почав тягнути старі логи. Я довго шукав, чому у VictoriaLogs час не збігається з очікуваним, але проблема виявилась банальною – я дивився дані за останні 15 хвилин, а імпортувались ранкові записи.
Але тоді доведеться почекати до 5 хвилин, поки з’явиться новий меседж.
Що зараз можемо додати до логів у VictoriaLogs – client_ip з client:port, target_ip (Kubernetes Pod) із target:port, elb_status_code – код відповіді ALB, аби потім простіше робити алерти, і target_status_code – аналогічно.
Оновлюємо структуру SimpleLog – додаємо нові поля:
type SimpleLog struct {
Timestamp time.Time
Message string
ClientIP string
TargetIP string
ELBStatus int
TargetStatus int
}
До ParseRawLine() додаємо запис в ці поля, використовуючи array index зі строки fields:
Коменти попросив написати AI, щоб детально по всім функціям було пояснення.
Файл main.go
package main
import (
"alb-logs-collector/collector"
"context"
"fmt"
"log"
"os"
"time"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/sqs"
"github.com/go-co-op/gocron"
)
func main() {
// create a base context for all AWS operations
// this context is passed into SQS/S3 functions
ctx := context.Background()
// load shared AWS config from ~/.aws/config and ~/.aws/credentials
// this provides region, credentials, retry settings, etc.
cfg, err := config.LoadDefaultConfig(ctx)
if err != nil {
log.Fatal(err)
}
// create SQS client using the shared AWS config
// used later for reading and deleting queue messages
sqsClient := sqs.NewFromConfig(cfg)
// create S3 client for reading S3 objects
s3Client := s3.NewFromConfig(cfg)
// read queue name from environment
// this avoids hardcoding queue names in code
queueName := os.Getenv("ALB_LOGS_QUEUE")
if queueName == "" {
log.Fatal("environment variable ALB_LOGS_QUEUE is not set")
}
// create the scheduler that runs periodic jobs
// gocron automatically creates goroutines for scheduled tasks
s := gocron.NewScheduler(time.UTC)
// schedule: run the function every minute
s.Every(1).Minute().Do(func() {
fmt.Println("CHECKING SQS...")
// 1. read one message from SQS
// ReceiveFromSQS is implemented in collector/sqs.go
bucket, key, receiptHandle, queueURL, err := collector.ReceiveFromSQS(ctx, sqsClient, queueName)
if err != nil {
// "no messages" is not an error — just no new logs
if err.Error() == "no messages" {
fmt.Println("NO MESSAGES")
return
}
fmt.Println("ERROR receiving message:", err)
return
}
fmt.Println("BUCKET:", bucket)
fmt.Println("KEY:", key)
// 2. download the S3 object stream
// GetS3Object located in collector/s3.go
s3Obj, err := collector.GetS3Object(ctx, s3Client, bucket, key)
if err != nil {
fmt.Println("S3 error:", err)
return
}
defer s3Obj.Body.Close()
fmt.Println("S3 object stream opened:", bucket, key)
// 3. wrap the S3 stream into gzip reader
// GzipReader implemented in collector/gzip.go
gzReader, err := collector.GzipReader(s3Obj.Body)
if err != nil {
fmt.Println("gzip error:", err)
return
}
defer gzReader.Close()
// read VictoriaLogs endpoint
vmLogsEp := os.Getenv("VICTORIA_LOGS_URL")
if vmLogsEp == "" {
log.Fatal("environment variable VICTORIA_LOGS_URL is not set")
}
// final URL with parameters for jsonline ingestion
vmLogsURL := vmLogsEp +
"?_msg_field=message" + // which JSON field contains the log message
"&_time_field=date" + // which JSON field contains timestamp
"&_time_format=unix_ms" + // tell VictoriaLogs that the timestamp is unix milliseconds
"&_stream_fields=stream" // which field defines the stream name
// 4. process the log file line-by-line
// ScanLines implemented in collector/scan.go
// It returns a channel of RAW log lines (already ungzipped)
for line := range collector.ScanLines(gzReader) {
// parse timestamp + extract fields
// ParseRawLine implemented in collector/parser.go
rec, err := collector.ParseRawLine(line)
if err != nil {
continue // skip invalid ALB lines
}
// prepare the JSON record for VictoriaLogs
// JSONLogRecord defined in collector/victoria.go
out := &collector.JSONLogRecord{
Timestamp: rec.Timestamp.UnixMilli(), // ALB timestamp in unix ms
Message: rec.Message, // full raw ALB log line
Stream: "alb", // log stream name
ClientIP: rec.ClientIP, // extracted from ALB log
TargetIP: rec.TargetIP, // extracted from ALB log
ELBStatus: rec.ELBStatus, // HTTP status from ALB
TargetStatus: rec.TargetStatus, // backend response status
}
// send it to VictoriaLogs
// SendToVictoria implemented in collector/victoria.go
if err := collector.SendToVictoria(vmLogsURL, out); err != nil {
fmt.Println("send error:", err)
}
}
// 5. delete message from SQS after successful processing
// DeleteFromSQS implemented in collector/sqs.go
if err := collector.DeleteFromSQS(ctx, sqsClient, queueURL, receiptHandle); err != nil {
fmt.Println("FAILED TO DELETE SQS MESSAGE:", err)
} else {
fmt.Println("SQS MESSAGE DELETED")
}
})
// start the scheduler and block main goroutine forever
s.StartBlocking()
}
Файл sqs.go
package collector
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/sqs"
)
// S3Event describes the JSON format sent by S3 to SQS.
// It matches the structure of the S3 event notification:
// "Records" → "s3" → "bucket.name" and "object.key".
// This struct is used in ReceiveFromSQS() to extract bucket/key.
type S3Event struct {
Records []struct {
S3 struct {
Bucket struct {
Name string `json:"name"` // S3 bucketName
} `json:"bucket"`
Object struct {
Key string `json:"key"` // S3 object key (log filename)
} `json:"object"`
} `json:"s3"`
} `json:"Records"`
}
// ReceiveFromSQS reads a single SQS message.
// It returns:
// - bucket: the S3 bucket from event
// - key: the object key (filename in S3)
// - receiptHandle: required later to delete the message
// - queueURL: actual AWS queue URL (needed for deletion)
// - err: error, or "no messages" if queue is empty
//
// This function is called from main.go inside the scheduler loop.
// After processing the file from S3, the caller must call DeleteFromSQS().
func ReceiveFromSQS(ctx context.Context, client *sqs.Client, queueName string) (bucket, key, receiptHandle, queueURL string, err error) {
// resolve real SQS queue URL from queue name
// SQS APIs always operate on queueURL, not the name
getURL, err := client.GetQueueUrl(ctx, &sqs.GetQueueUrlInput{
QueueName: aws.String(queueName),
})
if err != nil {
return "", "", "", "", err
}
queueURL = *getURL.QueueUrl
// receive exactly one message
// WaitTimeSeconds enables long polling for up to 10s
resp, err := client.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{
QueueUrl: aws.String(queueURL),
MaxNumberOfMessages: 1,
WaitTimeSeconds: 10,
})
if err != nil {
return "", "", "", "", err
}
// queue empty → no new logs
if len(resp.Messages) == 0 {
return "", "", "", "", fmt.Errorf("no messages")
}
// take the first (and only) message
msg := resp.Messages[0]
// ReceiptHandle is mandatory for deletion
receiptHandle = *msg.ReceiptHandle
// raw JSON body received from S3 → SQS notification
raw := aws.ToString(msg.Body)
fmt.Println("SQS RAW:", raw)
// filter out AWS TestEvent generated when enabling notifications
// Test events do NOT contain real logs and must be ignored
if strings.Contains(raw, `"Event":"s3:TestEvent"`) {
fmt.Println("Skipping AWS S3 test event")
return "", "", "", queueURL, fmt.Errorf("test event skipped")
}
// unmarshal JSON into S3Event struct
var event S3Event
if err := json.Unmarshal([]byte(raw), &event); err != nil {
return "", "", "", "", err
}
// extract bucket and key from the event
bucket = event.Records[0].S3.Bucket.Name
key = event.Records[0].S3.Object.Key
return bucket, key, receiptHandle, queueURL, nil
}
// DeleteFromSQS permanently removes the processed message.
// Must be called only after successful S3 → gzip → parsing → VictoriaLogs ingestion.
// If not deleted, SQS will retry delivery (visibility timeout expires).
// Implemented in collector/sqs.go, called from main.go.
func DeleteFromSQS(ctx context.Context, client *sqs.Client, queueURL, receiptHandle string) error {
_, err := client.DeleteMessage(ctx, &sqs.DeleteMessageInput{
QueueUrl: aws.String(queueURL),
ReceiptHandle: aws.String(receiptHandle),
})
return err
}
Файл s3.go
package collector
import (
"context"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
)
// GetS3Object retrieves an S3 object and returns a streaming reader.
//
// This function does NOT download the file to disk.
// It returns a network stream (`GetObjectOutput.Body`), which allows the caller
// to read the object lazily, byte-by-byte, directly from S3.
//
// In our pipeline, the call chain looks like:
//
// main.go
// → ReceiveFromSQS() to get bucket/key (collector/sqs.go)
// → GetS3Object() to open S3 object stream (this file)
// → GzipReader() to decompress gzip data (collector/gzip.go)
// → ScanLines() to iterate log lines (collector/scan.go)
//
// Notes:
// - The returned object must be closed by the caller: `defer obj.Body.Close()`
// - S3 GetObject supports range requests, but here we read the whole file.
// - The body is read sequentially; this is efficient for log ingestion patterns.
func GetS3Object(ctx context.Context, client *s3.Client, bucket, key string) (*s3.GetObjectOutput, error) {
return client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(bucket), // name of S3 bucket with ALB logs
Key: aws.String(key), // key (path/filename) of the gzip log file
})
}
Файл gzip.go
package collector
import (
"compress/gzip"
"io"
)
// GzipReader wraps an existing io.Reader with a gzip decompressor.
//
// This function takes the S3 object body (returned by GetS3Object in
// collector/s3.go) and turns it into a gzip.Reader capable of producing
// the decompressed content of the ALB log file.
//
// Typical pipeline:
//
// S3Object.Body (io.ReadCloser)
// ↓ passed into
// GzipReader() → *gzip.Reader (still a stream)
// ↓ passed into
// ScanLines() → yields plain-text log lines
//
// Notes:
// - gzip.NewReader expects the input stream to be in .gz format.
// - Caller must close the returned reader: `defer gz.Close()`.
// - No buffering or scanning is done here; this function only wraps the stream.
func GzipReader(r io.Reader) (*gzip.Reader, error) {
return gzip.NewReader(r)
}
Файл scan.go
package collector
import (
"bufio"
"io"
)
// ScanLines reads an arbitrary io.Reader line-by-line and returns a channel
// that produces each extracted line as a string.
//
// This function is used in main.go to iterate through the contents of an S3
// Gzip file. The call chain is:
//
// main.go → GetS3Object() (collector/s3.go)
// → GzipReader() (collector/gzip.go)
// → ScanLines() (this file)
//
// Because ScanLines() uses a goroutine, the caller can process lines
// asynchronously using: `for line := range ScanLines(r) { ... }`.
//
// Notes:
// - The returned channel is *unbuffered*, so each send blocks until the caller
// receives the value. This naturally rate-controls the goroutine.
// - bufio.Scanner splits input by '\n', automatically handling different line
// lengths (up to scanner’s max buffer).
// - When the reader is fully consumed, the goroutine closes the channel.
func ScanLines(r io.Reader) <-chan string {
ch := make(chan string)
// launch a goroutine that streams lines out of the reader
go func() {
// ensure channel is closed when scanning finishes
defer close(ch)
// bufio.Scanner provides efficient, line-oriented reading of text streams
scanner := bufio.NewScanner(r)
// loop until EOF or error. Each iteration reads the next line
for scanner.Scan() {
// push extracted line into the channel
// (blocks until caller receives it)
ch <- scanner.Text()
}
// scanner.Err() is intentionally ignored here, because error handling
// is performed by the caller when needed, and the channel-based design
// treats EOF as natural termination.
}()
// return read-only channel of strings
return ch
}
Файл parser.go
package collector
import (
"fmt"
"strconv"
"strings"
"time"
)
// SimpleLog represents a minimal, lightweight structure containing:
//
// - Timestamp: parsed ALB timestamp in UTC
// - Message: the full raw log line (used as message in VictoriaLogs)
// - ClientIP: extracted client IP (port removed)
// - TargetIP: extracted backend target IP (port removed)
// - ELBStatus: HTTP status returned by ALB to the client
// - TargetStatus: HTTP status returned by the backend to ALB
//
// This struct is designed for the simplified ingestion phase,
// before implementing full ALB field parsing.
type SimpleLog struct {
Timestamp time.Time
Message string
ClientIP string
TargetIP string
ELBStatus int
TargetStatus int
}
// stripPort removes the ":port" suffix from IP strings like "1.2.3.4:5678".
// This keeps VictoriaLogs cardinality low, avoiding creation of thousands
// of separate series due to ephemeral client ports.
func stripPort(s string) string {
parts := strings.SplitN(s, ":", 2)
return parts[0]
}
// ParseRawLine parses the essential ALB log fields:
//
// - Timestamp from the 2nd field
// - Client IP (without port)
// - Target IP (without port)
// - ALB HTTP status
// - Target HTTP status
//
// Everything else is kept untouched in the Message field.
//
// ALB logs are space-delimited, except for quoted sections
// (like the request line and user agent). At this simplified stage,
// we do *not* parse quoted fields — we only extract the mandatory parts.
func ParseRawLine(line string) (*SimpleLog, error) {
fields := strings.Fields(line)
// we expect at least: protocol, timestamp, elb, client, target, ... status codes
// ALB format is consistent — <10 fields means corrupted input
if len(fields) < 10 {
return nil, fmt.Errorf("invalid ALB log line")
}
fmt.Println("--- PARSER SEND ---")
fmt.Println(line)
fmt.Println("--- PARSER DEBUG ---")
// parse timestamp in RFC3339Nano format
// ALB always emits timestamps in UTC
ts, err := time.Parse(time.RFC3339Nano, fields[1])
if err != nil {
return nil, fmt.Errorf("timestamp parse error: %w", err)
}
// convert status codes from string → int
elbStatus, _ := strconv.Atoi(fields[8])
targetStatus, _ := strconv.Atoi(fields[9])
return &SimpleLog{
Timestamp: ts.UTC(), // ensure strict UTC normalization
Message: line, // pass full raw line to VictoriaLogs
ClientIP: stripPort(fields[3]),
TargetIP: stripPort(fields[4]),
ELBStatus: elbStatus,
TargetStatus: targetStatus,
}, nil
}
Файл victoria.go
package collector
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
)
// JSONLogRecord represents a single log entry formatted for
// VictoriaLogs JSONLine ingestion API.
//
// Field mapping:
//
// - Timestamp → "date"
// UNIX milliseconds timestamp of the log event.
// This must match ?_time_field=date&_time_format=unix_ms in ingestion URL.
//
// - Message → "message"
// The complete raw ALB log line. Used as the primary log message.
//
// - Stream → "stream"
// Logical log stream identifier. Used in ingestion via ?_stream_fields=stream.
//
// - ClientIP / TargetIP / ELBStatus / TargetStatus
// Additional parsed metadata fields. These become searchable log fields.
//
// The structure intentionally avoids nested JSON — VictoriaLogs processes
// flat objects more efficiently and without ambiguity in field extraction.
type JSONLogRecord struct {
Timestamp int64 `json:"date"`
Message string `json:"message"`
Stream string `json:"stream"`
ClientIP string `json:"client_ip"`
TargetIP string `json:"target_ip"`
ELBStatus int `json:"elb_status"`
TargetStatus int `json:"target_status"`
}
// SendToVictoria sends exactly ONE JSON record to the VictoriaLogs JSONLine API.
//
// This function is called from main.go inside the ingestion loop.
// It performs the following steps:
//
// 1. Marshal the JSONLogRecord into a compact JSON object
// 2. Build an HTTP POST request with Content-Type: application/stream+json
// 3. Send the request to VictoriaLogs
// 4. Check for non-2xx HTTP status codes
//
// Notes:
// - VictoriaLogs expects a "streaming JSON" format, where each POST body
// contains a single JSON object (or multiple lines if needed).
// - We send logs one-by-one for simplicity, but batching can be added later.
// - The caller controls the ingestion endpoint URL, including query params:
// ?_msg_field=message&_time_field=date&_time_format=unix_ms&_stream_fields=stream
//
// Errors returned from this function are logged in main.go, and do not
// interrupt the ingestion pipeline.
func SendToVictoria(url string, rec *JSONLogRecord) error {
// serialize record into JSON line
body, err := json.Marshal(rec)
if err != nil {
return err
}
// debug output for transparency
fmt.Println("--- VMLOGS SEND ---")
fmt.Println(string(body))
fmt.Println("--- VMLOGS DEBUG ---")
// create POST request with streaming JSON payload
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
if err != nil {
return err
}
// JSONLine ingestion requires this content type
req.Header.Set("Content-Type", "application/stream+json")
// send HTTP request using default HTTP client
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
// check server-side errors
if resp.StatusCode >= 300 {
return fmt.Errorf("victoria response status: %s", resp.Status)
}
return nil
}
Вже запустив в Kubernetes, все працює.
Єдиний момент, що SQS та S3 операції не падають при access denied, треба додати перевірку помилок.
Почав писати log collector з S3 до VictoriaLogs з використанням AWS GO SDK, і в коді достатньо багато використовуються різні Input/Ouput операції, бо треба отримати лог, розпарсити, записати дані.
Тож цього разу подивимось на дуже класний приклад використання інтерфейсів при роботі з функцією io.Copy() і ще раз трохи зазирнемо під капот внутрішньої реалізації інтерфейсів в Go.
Basic I/O example – os.Open(), os.Create() та io.Copy()
Напишемо простий код, який буде з одного файлу копіювати в інший:
package main
import (
"fmt"
"io"
"os"
)
func main() {
// open the source file for reading
// returns pointer to os.File:
// 'func os.Open(name string) (*os.File, error)':
//
// os.File represents an open file descriptor:
// type File struct {
// // contains filtered or unexported fields
// }
sourceFile, err := os.Open("source.txt")
if err != nil {
panic(err)
}
// always close files when done
defer sourceFile.Close()
// create destination file for writing
// 'func os.Create(name string) (*os.File, error)'
destFile, err := os.Create("dest.txt")
if err != nil {
panic(err)
}
defer destFile.Close()
// copy data from source to destination
// io.Copy() pulls bytes from any Reader and pushes them into any Writer
// 'func io.Copy(dst io.Writer, src io.Reader) (written int64, err error)'
bytesWritten, err := io.Copy(destFile, sourceFile)
if err != nil {
panic(err)
}
fmt.Println("Copied bytes:", bytesWritten)
}
Тут ми:
з os.Open("source.txt") відкриваємо файл на читання
з os.Create("dest.txt") створюємо файл, в який будемо копіювати дані
і з io.Copy() копіюємо дані з “source.txt” до “dest.txt“
І для обох функцій – і Copy(), і copyBuffer() – аргументи є interface type:
А type Writer interface описує вимоги до типу, що може бути використаний через цей інтерфейс: такий тип повинен мати метод Write(), приймати аргумент з типом slice of bytes, і повертати значення int та err:
// Writer is the interface that wraps the basic Write method.
type Writer interface {
Write(p []byte) (n int, err error)
}
Об’єкт destFile повинен мати метод Write() – який в нього є, бо destFile – це *os.File struct, в якої є набір методів, в тому числі як раз Read() та Write():
$ go doc os.File
package os // import "os"
type File struct {
// Has unexported fields.
}
File represents an open file descriptor.
...
func (f *File) Read(b []byte) (n int, err error)
...
func (f *File) Write(b []byte) (n int, err error)
А отже, маючи об’єкт з типом *os.File – ми через відповідні інтерфейси можемо викликати *os.File.Write():
func Copy(dst Writer, ...) каже – “dst повинен мати метод Write([]byte) (int, error)“
тип *os.File має метод Write() – а значить він задовольняє Writer interface
Тобто: інтерфейси в Go описують не типи даних, а вимоги до методів, які мають бути реалізовані, щоб ці методи можна було викликати через інтерфейс.
І коли ми пишемо і запускаємо io.Copy(destFile, ...) – під капотом Go під час компіляції програми:
перевіряє, який тип приймає io.Copy() – це interface type
аби задовільнити конкретно цей interface type – об’єкт (тип), який передається аргументом до io.Copy(), повинен мати метод Write()
Go перевіряє, чи є у переданого типу такий метод – чи є для об’єкту *os.File метод Write()
Далі – “магія”, описана в попередньому пості: ще раз глянемо на те, як працюють інтерфейси, як через них викликаються методи, і що саме знаходиться в аргументах io.Copy() та copyBuffer() при роботі програми.
Структури iface та itab
Коли ми передаємо обʼєкт (pointer на *os.File) у параметр із типом інтерфейсу (dst Writer) – то Go формує дві внутрішні структури, які передаються до функцій як interface value.
type iface struct {
// Pointer to the 'itab' (interface table)
tab unsafe.Pointer
// Pointer to the actual data (our *os.File struct)
data unsafe.Pointer
}
Де:
tab unsafe.Pointer: pointer на другий тип type itab, який описаний в type ITab struct
раніше було type itab struct, зараз перенесли в Go ABI, про ABI в наступному пості
data unsafe.Pointer: pointer на наш об’єкт з типом os.File struct, який має метод Write()
Друга структура, ITab struct, має свої три поля:
type ITab struct {
// pointer to the 'type Writer interface'
Inter *InterfaceType
// pointer to the 'type File struct'
Type *Type
// in our case if we have 1 method, thus '[N]uintptr' == [1]uintptr
// and in the 'fun[0]' will be the address of the method 'Write()' of the 'os.File' struct
Fun [1]uintptr // will have '[1]uintptr', and
}
Тут:
Inter *InterfaceType: pointer на опис type Writer interface
“який інтерфейс треба задовольнити“
Type: pointer на опис конкретного типу значення (у нашому випадку тип *os.File)
“який тип ми передаємо“
'Fun[0]': буде посиланням на метод Write() структури os.File
“ось адреси методів, які цей тип використовує для реалізації цього інтерфейсу“
І коли ми в коді передаємо значення типу *os.File в параметр інтерфейсного типу (dst Writer) – то Go створює ці структури, і передає структура iface з полями tab і data до виклику io.Copy(), а потім далі – до copyBuffer():
io.Copy(iface):
- iface.tab => вказівник на структуру itab
- iface.data => вказівник на *os.File
В itab struct маємо таблицю методів, пов’язаних з цим інтерфейсом (або – які імплементують цей інтерфейс), а в полі fun структури itab знаходиться масив з pointers, де кожен елемент містить адресу функції, яка реалізує відповідний метод інтерфейсу для конкретного типу.
І у випадку з інтерфейсом Writer – це буде масив fun[0] зі значенням, наприклад, 0xc000014070, де за адресою 0xc000014070 буде розташований метод Write() типу *os.File.
І коли в copyBuffer(dst Writer) виконується виклик Write(), який описаний як:
Повертаючись до твердження “при виклику io.Copy(*os.File) – викликається copyBuffer(), якому першим аргументом передається структура iface” – давайте подивимось на аргументи, з якими ми працюємо.
Перевірка типів інтерфейсних значень в аргументах
Аби побачити все своїми очима – повторимо “хак” з попереднього поста – створимо власну структуру, яка аналогічна до iface, бо напряму до iface ми звернутись не можемо – але можемо прочитати її памʼять через unsafe.Pointer.
І на додачу створимо власну функцію myCopy(), яка буде мати в параметрах наші власні інтерфейси – аналогічно тому, як це зроблено для io.Copy().
Тобто – ми повністю повторюємо поведінку оригінального io.Copy(), але замість справжніх io.Reader та io.Writer використовуємо свої інтерфейси і власну структуру myIfaceStruct, аби подивитись, як Go зберігає інтерфейс у памʼяті:
створюємо два об’єкти sourceFile та destFile, які є pointers на *os.File
описуємо власну функцію myCopy(), яка в параметрах описує отримання інтерфейсних типів
наші інтерфейси myReaderInterface та myWriterInterface вимагають методів Read() та Write(), які є у sourceFile та destFile
Код виходить такий:
package main
import (
"fmt"
"io"
"os"
"unsafe"
)
type myIfaceStruct struct {
tab unsafe.Pointer
data unsafe.Pointer
}
// Writer is the interface that wraps the basic Write method.
type myWriterInterface interface {
// define Write method to satisfy the myWriterInterface interface
Write(p []byte) (n int, err error)
}
// Reader is the interface that wraps the basic Read method.
type myReaderInterface interface {
// define Read method to satisfy the myReaderInterface interface
Read(p []byte) (n int, err error)
}
// accept any type which has Read and Write methods
func myCopy(src myReaderInterface, dst myWriterInterface) (int64, error) {
// '&src' gives us the address of the interface variable 'src'
// 'unsafe.Pointer(&src)' allows us to reinterpret that memory as a different type
// the interface value occupies 16 bytes:
// - first 8 bytes: pointer to the method/type table ('tab')
// - next 8 bytes: pointer to the actual value ('data')
// '(*myIfaceStruct)(...)' tells Go to treat those bytes as a 'myIfaceStruct'
// '*(*myIfaceStruct)(...)' finally copies those bytes into the 'rawIface' variable
rawIface := *(*myIfaceStruct)(unsafe.Pointer(&src))
fmt.Println()
// Print diagnostic messages
//
// we intentionally use '%p' modifier with a non-pointer value argument
// this causes a formatting error, and 'fmt' prints a diagnostic message
// that includes the full content of 'rawIface' (its type and both fields)
fmt.Printf("'rawIface' data: %p\n", rawIface)
// same idea for %s: &src is a *myReaderInterface, not a string
// so fmt prints a diagnostic message showing the type and value
fmt.Printf("'src' data: %s\n", &src)
fmt.Println()
// Print addresses from the 'iface' struct
//
// 'tab' field is a pointer to the interface's method table (the 'itab' struct)
// this value is copied from the real interface value stored in 'src'
fmt.Printf("Copy of the 'iface.tab': address stored inside 'rawIface.tab': %p\n", rawIface.tab)
// 'data' field is a pointer to the underlying object (the *os.File struct)
// also copied directly from the actual interface storage
fmt.Printf("Copy of the 'iface.data': address stored inside 'rawIface.data': %p\n", rawIface.data)
// print the address of the real underlying object (*os.File)
// this should match the value stored in rawIface.data
fmt.Printf("The 'src' (*os.File) actual object address: %p\n", src)
fmt.Println()
// Test sizes
//
// 'src' will have 16 bytes
// because 'iface' has two fields: 'tab' and 'data'
// they are pointers, each of 8 bytes
fmt.Println("sizeof the 'src' (size of 'iface' with two pointers):", unsafe.Sizeof(src))
// but pointer to the '*os.File' object size will be 8 bytes
testSource, _ := os.Open("source.txt")
fmt.Println("sizeof the 'testSource' (size of '*os.File' with one pointer):", unsafe.Sizeof(testSource))
fmt.Println()
// demonstrate "dynamic types"
//
// - Printf '%T' modifier will print the type of the variable
// - Printf '%p' modifier will print the address pointed to by '&'
fmt.Printf("'src' type: %T\n", src)
// address of the the 'src'
fmt.Printf("'src' address: %p\n", &src)
fmt.Println()
return io.Copy(dst, src)
}
func main() {
// sourceFile is *os.File
sourceFile, _ := os.Open("source.txt")
defer sourceFile.Close()
// destFile is *os.File
destFile, _ := os.Create("dest.txt")
defer destFile.Close()
myCopy(sourceFile, destFile)
}
Запускаємо:
$ go run test-int.go
'rawIface' data: %!p(main.myIfaceStruct={0x4eee38 0xc000062030})
'src' data: %!s(*main.myReaderInterface=0xc000014070)
Copy of the 'iface.tab': address stored inside 'rawIface.tab': 0x4eee38
Copy of the 'iface.data': address stored inside 'rawIface.data': 0xc000062030
The 'src' (*os.File) actual object address: 0xc000062030
sizeof the 'src' (size of 'iface' with two pointers): 16
sizeof the 'testSource' (size of '*os.File' with one pointer): 8
'src' type: *os.File
'src' address: 0xc000014070
І розбираємо результат.
Перші два – зовсім “грязний хак”, випадково на нього натрапив: якщо до модифікатора в fmt.Printf() передати не той тип даних, який він очікує – він виводить повідомлення з деталями по помилці, де можемо побачити, що саме повністю передавалось (хоча як виявилось, під капотом просто викликається (reflect.TypeOf(p.arg).String())).
Перший блок:
rawIface є типом main.myIfaceStruct, яка містить два вказівники на адреси 0x4eee38 та 0xc000062030 – див. далі про зміст rawIface
src є поінтером на *main.myReaderInterface – структуру, яка знаходиться за адресою 0xc000014070
Далі – виводимо адреси, які зберігаються в полях iface (і які ми отримали через нашу власну структуру):
'rawIface.tab': 0x4eee38 – тут адреса розміщення itab struct
'rawIface.data': 0xc000062030 – тут адреса переданого через src об’єкту os.File
і ту саму адресу ми бачимо в наступному рядку – src є pointer на *os.File, з Printf(%p) отримуємо адресу, на яку src вказує
Найбільш явний доказ того, що насправді myCopy() у (src myReaderInterface) працює з інтерфейсом, а не *os.File – це розмір:
з unsafe.Sizeof(src) отримуємо розмір самого інтерфейсного значення (iface), яке складається з двох pointers – tab і data, по 8 байт кожен
а testSource := os.Open("source.txt") має розмір 8 байт, бо це один поінтер
Інтерфейси Go та “dynamic type”
А далі ми бачимо те, що називають “динамічними типами”: в результатах unsafe.Sizeof(src)) ми побачили, що там 2 поінтери, тобто це 100% тип interface value з двома pointers.
Але в fmt.Printf("'src' type: %T\n", src) ми отримуємо тип *os.File – бо це pointer на структуру os.File:
$ go run test-int.go
...
'src' data: %!s(*main.myReaderInterface=0xc000014070)
...
'src' type: *os.File
'src' address: 0xc000014070
The static type (or just type) of a variable is the type given in its declaration, the type provided in the new call or composite literal, or the type of an element of a structured variable. Variables of interface type also have a distinct dynamic type, which is the (non-interface) type of the value assigned to the variable at run time (unless the value is the predeclared identifier nil, which has no type). The dynamic type may vary during execution but values stored in interface variables are always assignable to the static type of the variable.
Отже, змінна має static type, коли:
змінна оголошується (var i int)
тип заданий під час присвоювання даних при виклику функцій (x := new(int))
Проте змінні інтерфейсного типу завжди мають фіксований статичний тип (сам інтерфейс) – але реальний об’єкт всередині неї має окремий dynamic type – це конкретний тип значення, присвоєного під час виконання.
І в нашому прикладі вище – iface.data як раз і є тою змінною, яка визначає dynamic type, і тому ми в результаті fmt.Printf("'src' type: %T\n", src) бачимо саме *os.File.
Додаємо до нашого коду ще трохи дебагу:
...
// show the static type of the interface itself
// - (*myReaderInterface)(nil) creates a nil pointer to the interface type
// - reflect.TypeOf(...) gives the type of that pointer
// - Elem() gives the type the pointer points to (the interface type)
// this demonstrates that the static type is 'myReaderInterface'
fmt.Println("static type of the 'myReaderInterface':", reflect.TypeOf((*myReaderInterface)(nil)).Elem())
// show the dynamic type stored inside the interface variable 'src'
// - 'src' is an interface value (16-byte iface: tab + data)
// - reflect.TypeOf(src) reads the real type stored in iface.data
// this prints the actual type, '*os.File' in our case
//
// and this is exactly the same information that 'fmt.Printf("%T", src)' prints:
// both reflect.TypeOf(src) and %T reveal the dynamic type stored in the interface
fmt.Printf("'src' dynamic type: %v\n", reflect.TypeOf(src))
// show the type of the variable 'src' itself, not the value stored inside it
// this is exactly what the myCopy() function "sees" when receiving its argument
// - '&src' is a pointer to the interface variable
// - reflect.TypeOf(&src) therefore reports: "*myReaderInterface"
// this confirms that 'src' is an interface-typed variable, not a concrete value
fmt.Println("'src' variable type: ", reflect.TypeOf(&src))
...
Результат:
$ go run test-int.go
...
static type of the 'myReaderInterface': main.myReaderInterface
'src' dynamic type: *os.File
'src' variable type: *main.myReaderInterface
Тут ми:
в першій перевірці просто створюємо вказівник на інтерфейсний тип (але без створення самого об’єкту): результат є main.myReaderInterface
другий результат – “прочитай значення інтерфейсної змінної src, і скажи, який там тип” – саме тут ми бачимо, що в iface.data зберігається pointer на об’єкт типу – *os.File
третя перевірка – “сходи за адресою, де зберігається змінна src, і скажи який за цією адресою тип даних” – отримуємо pointer на *main.myReaderInterface
Використання інтерфейсів на прикладі io.Copy()
То що це все значить для нас?
А значить, що використовуючи інтерфейси, ми можемо передати будь-які значення (типи), які реалізують інтерфейс.
Якщо повернутись до нашого першого коду, то в io.Copy() першим параметром ми можемо передати будь-який тип, який має метод Write([]byte) (int, error), а в другий – аналогічно, тільки Read(), бо під капотом Copy() викликає copyBuffer(), а той просто створює буфер розміром в 32 кілобайти, чей який “переливає” з одного “каналу” в інший:
func copyBuffer(dst Writer, src Reader, buf []byte) (written int64, err error) {
...
if buf == nil {
size := 32 * 1024
...
buf = make([]byte, size)
}
for {
nr, er := src.Read(buf)
if nr > 0 {
nw, ew := dst.Write(buf[0:nr])
...
А значить – ми можемо у Writer передати os.Stdout, тобто просто вивести на консоль:
...
func main() {
// open the source file for reading
// returs pointer to os.File:
// 'func os.Open(name string) (*os.File, error)':
//
// os.File represents an open file descriptor:
// type File struct {
// // contains filtered or unexported fields
// }
sourceFile, err := os.Open("source.txt")
if err != nil {
panic(err)
}
// always close files when done
defer sourceFile.Close()
// printto console instead
// actualy, os.Stdout is also *os.File
// thus it also has Write() method
bytesWritten, err := io.Copy(os.Stdout, sourceFile)
if err != nil {
panic(err)
}
fmt.Println("Copied bytes:", bytesWritten)
}
Результат:
$ go run main.go
source
Copied bytes: 7
Або можемо створити власний буфер в пам’яті, і писати в нього, бо bytes.Buffer теж має метод Write():