SSL/TLS: self-signed Certificate Authority для NGINX на FreeBSD
0 (0)

18 Квітня 2026

На домашньому NAS крутиться багато web-сервісів – Grafana, VictoriaMetrics, мій власний щоденник на WordPress і ще пів-десятка дрібниць.

Вся серія постів по FreeBSD та NAS починається тут – FreeBSD: Home NAS, part 1 – налаштування ZFS mirror, там станом на зараз 15 частин.

Окремо описаний NGINX+PHP, див. FreeBSD: налаштування FEMP – NGINX, PHP-FPM, MariaDB.

В цілому, хоча це все і доступне тільки в рамках VPN або домашньої мережі – але внутрішня параноя кричить, коли бачить HTTP замість HTTPS, а тому хочеться мати SSL/TLS і налаштувати NGINX з ним.

Купувати сертифікат для такого use case сенсу нема, Let’s Encrypt теж не підійде – бо доступу до NGINX з інтернету нема, а DNS challenge для домашньої зони .setevoy піднімати – то трохи геморою, бо TXT має бути для публічно доступної зони.

Тому просто зробимо свій Certificate Authority з блекджеком і дівчатами, а потім ним підпишемо власний wildcard self-signed сертифікат для NGINX.

Ну а заодно згадаємо як взагалі працюють CA та приватні та публічні сертифікати.

Домени для Home NAS

В мене є “домашня” top level domain зона .setevoy в якій живуть всі мої сервіси і яка включає в себе два внутрішні домени:

  • .aws.setevoy: ресурси в AWS – EC2 для самого блогу RTFM, окремий EC2 для NAT Gateway, і інстанс RDS
    • зроблено окремою зоною, бо це виключно AWS-related ресурси
  • .net.setevoy: це вже ресурси в моїх локальних мережах – одна квартира під “офіс”, в якій більшість хостів (сам NAS, MikroTik, робочий ноутбук тощо), та домашня мережа – там тільки домашній ноутбук

Відповідно в домені .net.setevoy будуть адреси:

  • work.net.setevoy: робочий ноут
  • nas.net.setevoy: ThinkCentre з FreeBSD/NAS
  • gw.net.setevoy: MikroTik RB4011

А для веб-сервісів будуть адреси типу grafana.net.setevoy для Grafana, victoria.net.setevoy – для VictoriaMetircs, logs.net.setevoy для VictoriaLogs тощо.

Власне, що для цього всього треба зробити – це wildcard SSL-сертифікат, який потім буде використовуватись в NGINX.

Аби браузери не сварились на нього – створимо власний CA-сертифікат, який потім я додам на свої робочий та домашній ноутбуки, і з власним Certificate Authority підпишемо wildcard-сертифікат для веб-сервісів.

Чому wildcard не на сам .setevoy

Перша думка була “зроблю собі *.setevoy, і буде один сертифікат на все” – але так не вийде, бо wildcard на TLD заборонений і, наприклад, Chrome відкидає такий сертифікат з помилкою ERR_CERT_COMMON_NAME_INVALID.

Формально RFC 6125 – 6.4.3 каже тільки те, що wildcard має бути в найлівішому лейблі (*.example.com – ОК, bar.*.example.net – ні) – і цьому *.setevoy відповідає.

Крім того, RFC прямо нічого не каже про мінімум лейблів (рівнів домену) – це навіть описана проблема цього RFC.

Але на практиці TLS-клієнти додають своє правило, наприклад, GnuTLS документує це явно – див. gnutls_x509_crt_check_hostname2:

wildcards […] are only considered if the domain name consists of three components or more

Тобто *.setevoy (2 компоненти) – не валідно, *.net.setevoy (3 компоненти) – валідно.

Chrome (через BoringSSL) і Firefox (через NSS), судячи з помилки яку я отримав, поводяться так само – хоча я не копав, де саме це у них задокументовано.

Окремо є CA/Browser Forum Baseline Requirements, які забороняють публічним CA видавати такі сертифікати в принципі. Мій CA не публічний – але правила браузерів від цього не перестають діяти.

Тому веб-сервіси будуть в зоні .net.setevoy, а wildcard буде для *.net.setevoy.

SSL vs TLS

Їх часто плутають, та я і сам в блозі пишу то “SSL”, то “TLS”, то просто “SSL/TLS”.

Власне, в чому різниця:

Тобто коли хтось каже “SSL сертифікат” або “налаштувати SSL” – мається на увазі TLS. Це як “ксерокс” замість “копіювальний апарат” – всі розуміють, але технічно неточно. В тексті далі буду казати “SSL/TLS” або просто “SSL” чи “TLS” – це все про одне й те саме.

Що таке Certificate Authority

Certificate Authority – це центр, який має право підписувати сертифікати, і якому довіряють клієнти (браузери, операційні системи).

Коли ми створюємо сертифікат через Let’s Encrypt – він підписується сертифікатом компанії Let’s Encrypt.

Коли через AWS Certificate Manager – то сертифікатом Amazon.

У випадку Cloudflare – issuer буде Google Trust Services:

$ openssl s_client -connect rtfm.co.ua:443 </dev/null 2>/dev/null | openssl x509 -noout -issuer -subject
issuer=C=US, O=Google Trust Services, CN=WE1
subject=CN=rtfm.co.ua

Тобто, сертифікат виданий issuer=Google Trust Services, підписаний Google Trust Services CA, а виданий сертифікат для subject=rtfm.co.ua.

Всі публічні Certificate Authority сертифікати йдуть “в комплекті” браузера або операційної системи.

Наприклад, в Google Chrome список доступний в chrome://settings/certificates > Chrome Root Store:

SSL/TLS: self-signed Certificate Authority для NGINX на FreeBSD

Linux та ca-certificates

В Arch Linux за CA-сертифікати відповідають кілька пакетів – ca-certificates-utils та ca-certificates-mozilla.

Хоча насправді тут доволі цікавий ланцюжок.

Наприклад, пакет curl має в залежностях мета-пакет

$ pacman -Qi curl
Name            : curl
...
Depends On      : ca-certificates
...

Пакет ca-certificates має в залежностях пакет ca-certificates-mozilla:

$ pacman -Qi ca-certificates
Name            : ca-certificates
...
Depends On      : ca-certificates-mozilla
...

А ca-certificates-mozilla тягне за собою пакет ca-certificates-utils:

$ pacman -Qi ca-certificates-mozilla
Name            : ca-certificates-mozilla
...
Depends On      : ca-certificates-utils>=20181109-3
...

Пакет ca-certificates-utils створює каталоги (/etc/ca-certificates/, /etc/ssl/certs/), додає man pages та встановлює утиліту /usr/bin/update-ca-trust.

Пакет ca-certificates-mozilla додає в систему файл /usr/share/ca-certificates/trust-source/mozilla.trust.p11-kit, який містить всі публічні CA-сертифікати.

Наприклад, вже згаданий вище “Organization=Google Trust Services” з “CommonName=GTS Root R1“:

$ cat /usr/share/ca-certificates/trust-source/mozilla.trust.p11-kit | grep -A 10 "Google Trust Services"
#        Issuer: C=US, O=Google Trust Services LLC, CN=GTS Root R1
#        Validity
#            Not Before: Jun 22 00:00:00 2016 GMT
#            Not After : Jun 22 00:00:00 2036 GMT
#        Subject: C=US, O=Google Trust Services LLC, CN=GTS Root R1
#        Subject Public Key Info:
#            Public Key Algorithm: rsaEncryption
#                Public-Key: (4096 bit)
#                Modulus:
#                    00:b6:11:02:8b:1e:e3:a1:77:9b:3b:dc:bf:94:3e:
#                    b7:95:a7:40:3c:a1:fd:82:f9:7d:32:06:82:71:f6:
#                    f6:8c:7f:fb:e8:db:bc:6a:2e:97:97:a3:8c:4b:f9:
#                    2b:f6:b1:f9:ce:84:1d:b1:f9:c5:97:de:ef:b9:f2:
#                    a3:e9:bc:12:89:5e:a7:aa:52:ab:f8:23:27:cb:a4:
#                    b1:9c:63:db:d7:99:7e:f0:0a:5e:eb:68:a6:f4:c6:
...

А update-ca-trust – це bash-скрипт, який викликає утиліту /usr/bin/trust і витягує сертифікати в каталог DEST=/etc/ca-certificates/extracted/cadir:

$ ll /etc/ca-certificates/extracted/cadir/ | grep GTS_Root_R1
-r--r--r-- 1 root root 1.9K Apr  3 14:57 GTS_Root_R1.pem

Та потім створює сімлінки в /etc/ssl/certs/.

$ ll /etc/ssl/certs/ | grep GTS_Root_R1
lrwxrwxrwx 1 root root   53 Apr  3 14:57 GTS_Root_R1.pem -> ../../ca-certificates/extracted/cadir/GTS_Root_R1.pem

Глянути наявні сертифікати можемо з trust list:

$ trust list | grep -B2 -A 2 "GTS Root R1"
pkcs11:id=%E4%AF%2B%26%71%1A%2B%48%27%85%2F%52%66%2C%EF%F0%89%13%71%3E;type=cert
    type: certificate
    label: GTS Root R1
    trust: anchor
    category: authority

Власне, що ми будемо робити: створимо власний root-key нашого Certificate Authority, ним підпишемо TLS-сертифікат для NGINX, а потім сертифікат нашого Certificate Authority додамо в trusted store на робочих машинах.

Файли CA, CSR, CRT, KEY

Тут хочу окремо зупинитись, бо насправді не так часто щось роблю руками з сертифікатами, і від кількості пов’язаних файлів можна потірятись.

Отже, у нас будуть дві пари ключ+сертифікат.

Пара 1 – наш Certificate Authority:

  • ca-private.key: приватний ключ CA
    • використовується виключно для підпису інших сертифікатів, зберігається окремо від сертифікатів NGINX
  • ca-public.crt: публічний сертифікат CA
    • підписується ca-private.key – власне – тому ця схема і є “self-signed” – ми самі собі підписуємо публічний сертифікат, який потім додаємо в trust store хостів

Пара 2 – для NGINX:

  • wildcard.net.setevoy.key: приватний ключ NGINX
    • лежить на сервері і нікому не передається
    • під час TLS handshake NGINX ним підписує challenge від клієнта, чим доводить що володіє ключем (сам ключ по мережі не йде)
  • wildcard.net.setevoy.crt: фінальний публічний сертифікат веб-сервера, підписаний нашим CA
    • це CSR + підпис від ca-private.key
    • саме цей файл NGINX і віддає браузеру

Окремо будемо створювати файл Certificate Signing Request (CSR) – wildcard.net.setevoy.csr і який буде використовуватися для створення підпису публічного сертифікату wildcard.net.setevoy.crt.

Процес валідації сертифікатів

Тепер розберемо як саме CA використовується для перевірки сертифіката від NGINX.

Тут приклади на вже готових файлах.

У нас буде файл wildcard.net.setevoy.crt, який NGINX передає клієнту під час підключення і який підписаний ca-private.key – приватним ключем CA.

Клієнт має у своєму trust store публічний сертифікат CA – ca-public.crt, використовуючи який він має впевнитись, що wildcard.net.setevoy.crt був підписаний саме ca-private.key.

Файл wildcard.net.setevoy.crt містить в собі набір полів:

# openssl x509 -in wildcard.net.setevoy.crt -noout -text
Certificate:
    Data:
        ...
        Signature Algorithm: sha256WithRSAEncryption
        Issuer: C = UA, ST = Kyiv, O = Setevoy Home NAS, CN = Setevoy CA
        Validity
            Not Before: Apr 18 11:35:59 2026 GMT
            Not After : Jul 21 11:35:59 2028 GMT
        Subject: C = UA, ST = Kyiv, O = Setevoy Home NAS, CN = *.net.setevoy
        Subject Public Key Info:
            Public Key Algorithm: rsaEncryption
                Public-Key: (2048 bit)
                Modulus:
                    00:dd:c6:f7:e1:13:1c:dd:91:44:37:d5:75:09:ca:
                    fb:16:a5:80:22:23:42:6e:6b:7c:1f:08:dd:25:f3:
                    7f:bd:05:13:74:79:76:de:d7:2b:f8:4c:bd:4c:a5:
                    ...
    Signature Algorithm: sha256WithRSAEncryption
    Signature Value:
        3d:24:95:55:cd:fb:c6:af:35:59:bc:dd:f6:05:fb:da:c9:51:
        f1:37:38:79:f0:e8:62:4a:5c:bc:f3:da:4b:45:8c:39:75:f4:
        3c:e5:3f:73:89:e6:8a:93:79:52:d7:8e:08:b0:50:02:ce:e9:
        18:63:4d:cd:ef:be:fa:78:f2:ed:01:db:77:e8:30:d7:b6:27:
        ...

Для підписання цього сертифіката CA бере хеш (SHA-256) від усієї секції Data (subject, issuer, public key, validity, SAN, …), шифрує цей хеш своїм приватним ключем ca-private.key і прикріплює результат до сертифіката як поле “Signature Value“.

Коли клієнт отримує wildcard.net.setevoy.crt від NGINX – він перевіряє значення issuer, бачить там “Setevoy CA“, і шукає у своєму trust store сертифікат CA з таким subject – це буде наш ca-public.crt.

Тепер у клієнта є wildcard.net.setevoy.crt з Signature Value, і є ca-public.crt, після чого:

  1. клієнт бере секцію Data з сертифіката і сам обчислює її SHA-256 хеш – назвемо цей хеш “H1
  2. бере значення Signature Value і розшифровує його публічним ключем CA (який лежить всередині ca-public.crt) – це буде хеш “H2

Якщо хеши співпадають – то підпис дійсно був зроблений парним приватним ключем до публічного ключа CA, ca-private.key.

H1” та “H2” тут – чисто умовні позначення, аби простіше було розібратись з тим, що будемо робити нижче.

Demo: перевірка підпису сертифікату

Виглядає класно в теорії – але давайте глянемо на практиці, як цей механізм працює.

Створимо файл data.txt – це буде наш умовний блок Data із сертифікату wildcard.net.setevoy.crt:

$ echo "Hello, this is our Data block" > data.txt

Створимо приватний ключ – це наш умовний ca-private.key, приватний ключ CA:

$ openssl genrsa -out demo.key 2048

З ca-private.key ми будемо підписувати хеш від data.txt.

Отримуємо з demo.key публічну частину – це буде наш умовний ca-public.crt, публічний сертифікат CA:

$ openssl rsa -in demo.key -pubout -out demo.pub

З ca-public.crt ми повинні мати змогу перевірити підпис від ca-private.key та отримати оригінальні дані.

А тепер сама цікава частина.

Отримуємо хеш даних в data.txt – це буде наш умовний “H1“:

$ openssl dgst -sha256 data.txt
SHA2-256(data.txt)= 959af28af72380bb03c44bf734d886a4ee3302d83a6edb0283a428e9850b9b68

Це той самий хеш, який клієнт буде обчислювати самостійно з блоку Data сертифіката від NGINX.

Підписуємо дані використовуючи приватний ключ CA:

$ openssl dgst -sha256 -sign demo.key -out signature.bin data.txt

Тепер у файлі signature.bin маємо 256 байт – це, власне – той самий “Signature Value” із сертифіката від NGINX, тільки у нас цей Value лежить окремим файлом, а не полем в сертифікаті:

$ od -An -tx1 signature.bin | tr -d ' \n' | head -c 200
203f65033571f3c7...d51b

Далі нам треба розшифрувати цей хеш, використовуючи публічний сертифікат CA:

$ openssl pkeyutl -verifyrecover -pubin -inkey demo.pub -in signature.bin -out decrypted.bin

Перевіряємо його зміст:

$ openssl asn1parse -inform DER -in decrypted.bin
   ...
    4:d=2  hl=2 l=   9 prim: OBJECT            :sha256
   ...
   17:d=1  hl=2 l=  32 prim: OCTET STRING      [HEX DUMP]:959AF28AF72380BB03C44BF734D886A4EE3302D83A6EDB0283A428E9850B9B68

Бачимо той самий хеш “959AF28AF…850B9B68” – це наш умовний H2, і він точно дорівнює H1, який ми отримали кілька кроків тому.

Підпис валідний – значить його зробив той, хто має приватний ключ, парний до demo.pub.

Те ж саме клієнт робить кожного разу при підключенні до NGINX – тільки замість demo.pub використовує публічний ключ з ca-public.crt у своєму trust store.

Все – досить теорії.

Давайте тепер створювати ключі і сертифікати.

План дій – Certificate Authority та NGINX

Нам треба буде створити файли нашого CA, а потім – файли для NGINX:

  • створимо приватний ключ, ним підпишемо сертифікат для CA – отримаємо self-signed Public CA certificate
  • створимо приватний ключ для NGINX – він буде використовуватись під час TLS Handshake для встановлення безпечного з’єднання
  • створимо CSR з потрібними CN та SAN – доменами, для яких буде валідним публічний сертифікат NGINX
  • з цим CSR та нашим приватним ключем CA отримаємо сертифікат для NGINX
  • налаштуємо virtualhost в NGINX з приватним ключем та сертифікатом
  • додамо публічний сертифікат CA в trusted store на FreeBSD і Linux

Створення власного Certificate Authority

На FreeBSD (в моєму випадку, але процес ідентичний на будь-якому Linux) створюємо каталог:

# mkdir -p /usr/local/etc/ssl/setevoy/NasCA/
# cd /usr/local/etc/ssl/setevoy/NasCA/

Генеруємо приватний ключ CA на 4096 біт:

# openssl genrsa -out ca-private.key 4096

З цим ключем генеруємо публічний self-signed сертифікат нашого CA:

# openssl req -new -x509 -days 3650 -key ca-private.key -out ca-public.crt -subj "/C=UA/ST=Kyiv/O=Setevoy Home NAS/CN=Setevoy CA"

Тут:

  • -new -x509: генеруємо новий self-signed сертифікат (а не CSR)
  • -days 3650: сертифікат валідний 10 років (для root CA норм)
  • -key ca-private.key: підписуємо приватним ключем CA, який створили вище
  • -out ca-public.crt: куди зберегти публічний сертифікат
  • -subj: метадані сертифіката – поле CN потім будемо бачити в браузері

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

# openssl x509 -in ca-public.crt -noout -issuer -subject
issuer=C = UA, ST = Kyiv, O = Setevoy Home NAS, CN = Setevoy CA
subject=C = UA, ST = Kyiv, O = Setevoy Home NAS, CN = Setevoy CA

Власне, issuer і subject однакові: це і є self-signed сертифікат – бо виданий від “Setevoy CA” для “Setevoy CA“.

Сертифікат для *.net.setevoy

В NGINX можна було б використати ключ ca-private.key напряму – але це наш “рутовий” ключ, і якщо NGINX зламають – атакуючий зможе підписувати ним будь-що, тому для NGINX робимо окремий ключ.

Створюємо приватний ключ для NGINX – тут вже можна зробити 2048 біт, а не 4096, як для рутового ключа CA:

# openssl genrsa -out wildcard.net.setevoy.key 2048

Тепер генеруємо CSR – Certificate Signing Request:

# openssl req -new -key wildcard.net.setevoy.key -out wildcard.net.setevoy.csr -subj "/C=UA/ST=Kyiv/O=Setevoy Home NAS/CN=*.net.setevoy"

Тут:

  • req -new: генеруємо новий CSR (без -x509, бо це не сертифікат)
  • -key wildcard.net.setevoy.key: використовуємо приватний ключ, який створили вище – його публічна частина піде в CSR
  • -out wildcard.net.setevoy.csr: куди зберегти сам Certificate Signing Request
  • CN=*.net.setevoy: wildcard, покриває всі сабдомени *.net.setevoy (хоча CN ролі не грає, див. далі)

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

# openssl req -in wildcard.net.setevoy.csr -noout -subject
subject=C = UA, ST = Kyiv, O = Setevoy Home NAS, CN = *.net.setevoy

Common Name та Subject Alternative Name

Тут є нюанс, на якому я сам спіткнувся: сучасні браузери і клієнти ігнорують Common Name (CN) і дивляться тільки на поле Subject Alternative Name (SAN), тому значення в полі CN недостатньо – треба додати SAN з усіма іменами, які покриває сертифікат.

Історія цього питання довга. RFC 2818 задепрікейтив CN на користь SAN ще у 2000, але залишив fallback:

If a subjectAltName extension of type dNSName is present, that MUST be used as the identity. Otherwise, the (most specific) Common Name field in the Subject field of the certificate MUST be used. Although the use of the Common Name is existing practice, it is deprecated and Certification Authorities are encouraged to use the dNSName instead.

Google Chrome повністю прибрав підтримку CN-matching у 2017, див. Remove support for commonName matching in certificates, Firefox і всі сучасні TLS-клієнти зробили те саме.

А вже RFC 9525 у 2023 офіційно прибрав CN-перевірку з самого стандарту – див. Identifying Application Services:

The Common Name RDN MUST NOT be used to identify a service because it is not strongly typed (it is essentially free-form text) and therefore suffers from ambiguities in interpretation.

Тобто сертифікат без SAN сьогодні – це гарантована помилка валідації, незалежно від того що в CN.

Окремий момент: wildcard *.net.setevoy покриває рівно один рівень піддомену, тобто test-ssl.net.setevoy – так, але саме net.setevoy (без префіксу) – ні. Тому в SAN додаємо обидва записи.

Створюємо файл san.cnf – можна мінімальний:

[v3_req]
subjectAltName = DNS:*.net.setevoy, DNS:net.setevoy

Або, якщо робити більше кошерно і по шаблону OpenSSL (див. x509v3_config) – то файл буде таким:

[req]
req_extensions = v3_req
distinguished_name = req_distinguished_name

[req_distinguished_name]

[v3_req]
subjectAltName = @alt_names

[alt_names]
DNS.1 = *.net.setevoy
DNS.2 = net.setevoy

Тут:

  • [req]: секція для команди openssl req (генерація CSR)
  • req_extensions = v3_req: підтягувати розширення з секції [v3_req]
  • distinguished_name = req_distinguished_name: поля subject (CN, O, C) брати з секції [req_distinguished_name]
  • [req_distinguished_name]: порожня, бо subject ми передаємо через -subj прямо в команді
  • [v3_req]: секція з розширеннями, які підуть у сертифікат
  • subjectAltName = @alt_names: значення SAN брати зі секції [alt_names], @ означає “посилання на секцію”
  • [alt_names]: власне список DNS-імен
  • DNS.1, DNS.2: workaround обмеження OpenSSL – ключ DNS в одній секції може зустрічатись тільки раз, тому додають .1, .2

Створюємо сам публічний сертифікат для NGINX, який підписується нашим приватним ключем CA:

# openssl x509 -req -days 825 -in wildcard.net.setevoy.csr -CA ca-public.crt -CAkey ca-private.key -CAcreateserial -out wildcard.net.setevoy.crt -extensions v3_req -extfile san.cnf
Certificate request self-signature ok
subject=C = UA, ST = Kyiv, O = Setevoy Home NAS, CN = *.net.setevoy

Опції тут:

  • x509 -req: підписуємо CSR і робимо з нього сертифікат
  • -CA ca-public.crt -CAkey ca-private.key: підписуємо нашим CA
  • -CAcreateserial: генерує серійний номер сертифіката (буде створений файл ca.srl)
  • -days 825: максимум, який приймає Chrome/Safari без скарг (це обмеження Apple з 2020 року, див. Apple Cuts SSL Validity Period to 13 Months Effective September 1)
  • -extfile san.cnf -extensions v3_req: додаємо список SAN з конфіга (без цього SAN не запишеться в сертифікат, навіть якщо він був у CSR)

Сама послідовність створення сертифікату із CSR така:

  • на вході у нас CSR (wildcard.net.setevoy.csr) – заявка з полями subject, public key, SAN
  • OpenSSL бере дані з CSR (subject, public key), додає від себе кілька полів (issuer = Setevoy CA, validity, serial number, extensions з san.cnf), збирає це все в нову структуру Data
  • хешує цю структуру Data, шифрує хеш приватним ключем CA – отримує Signature Value
  • на виході збирає Data + Signature Value в один файл – це і є wildcard.net.setevoy.crt

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

# openssl x509 -in wildcard.net.setevoy.crt -noout -issuer -subject -dates
issuer=C = UA, ST = Kyiv, O = Setevoy Home NAS, CN = Setevoy CA
subject=C = UA, ST = Kyiv, O = Setevoy Home NAS, CN = *.net.setevoy
notBefore=Apr 18 11:35:59 2026 GMT
notAfter=Jul 21 11:35:59 2028 GMT

Тепер issuer – це Setevoy CA, а subject – наш wildcard. Це означає, що сертифікат підписаний саме нашим CA, а не сам собою.

Перевіряємо що SAN на місці:

# openssl x509 -in wildcard.net.setevoy.crt -noout -ext subjectAltName
X509v3 Subject Alternative Name: 
    DNS:*.net.setevoy, DNS:net.setevoy

В результаті маємо три файли:

  • wildcard.net.setevoy.key: приватний ключ для NGINX
  • wildcard.net.setevoy.csr: Certificate Signing Request, який використовували для створення сертифікату
  • wildcard.net.setevoy.crt: власне – сам сертифікат, який буде віддаватись клієнтам

Налаштування SSL в NGINX

Створюємо каталог для сертифікатів:

# mkdir -p /usr/local/etc/nginx/ssl

Копіюємо сертифікат і ключ:

# cp /usr/local/etc/ssl/setevoy/NasCA/wildcard.net.setevoy.crt /usr/local/etc/nginx/ssl
# cp /usr/local/etc/ssl/setevoy/NasCA/wildcard.net.setevoy.key /usr/local/etc/nginx/ssl

Доступ до приватного ключа залишаємо тільки root:

# chmod 600 /usr/local/etc/nginx/ssl/wildcard.net.setevoy.key

Загальні параметри SSL виносимо в окремий файл /usr/local/etc/nginx/conf.d/ssl.conf, щоб не дублювати в кожному віртуалхості:

ssl_certificate     /usr/local/etc/nginx/ssl/wildcard.net.setevoy.crt;
ssl_certificate_key /usr/local/etc/nginx/ssl/wildcard.net.setevoy.key;
ssl_protocols       TLSv1.2 TLSv1.3;
ssl_ciphers         HIGH:!aNULL:!MD5;

І сам віртуалхост, наприклад /usr/local/etc/nginx/conf.d/test-ssl.net.setevoy.conf:

server {
    listen 80;
    server_name test-ssl.net.setevoy;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    server_name test-ssl.net.setevoy;
    
    include /usr/local/etc/nginx/conf.d/ssl.conf;
    
    location / {
        root /usr/local/www/nginx;
        index index.html;  
    }   
}

Хоча include /usr/local/etc/nginx/conf.d/ssl.conf можна взагалі винести в nginx.conf в секцію http{}.

Перевіряємо конфіг і ребутаємо:

# nginx -t && service nginx reload
nginx: the configuration file /usr/local/etc/nginx/nginx.conf syntax is ok
nginx: configuration file /usr/local/etc/nginx/nginx.conf test is successful

Спробуємо curl:

# curl https://test-ssl.net.setevoy
curl: (60) SSL certificate OpenSSL verify result: unable to get local issuer certificate (20)
More details here: https://curl.se/docs/sslcerts.html

curl failed to verify the legitimacy of the server and therefore could not
establish a secure connection to it. To learn more about this situation and
how to fix it, please visit the webpage mentioned above.

Це очікувано: curl не знає нашого CA, бо ми його ще нікуди не додали.

Додавання CA в локальні trusted store

Аби перевірка проходила без помилок – треба публічний сертифікат CA додати на всі хости в їхні trust store.

FreeBSD та certctl

Копіюємо CA-сертифікат у системний каталог:

# cp /usr/local/etc/ssl/setevoy/NasCA/ca-public.crt /usr/local/share/certs/setevoy-nas-ca.crt

Оновлюємо trusted store (займе пару хвилин):

# certctl rehash

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

# certctl list | grep -i setevoy
certctl: Listing Trusted Certificates:
f6c33121.0      Setevoy CA

І тепер curl з хоста FreeBSD працює без помилок:

# curl https://test-ssl.net.setevoy
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
...

Arch Linux та trust

Копіюємо ca-public.crt з FreeBSD на ноутбук з Arch Linux:

[setevoy@setevoy-work ~] $ scp [email protected]:/usr/local/etc/ssl/setevoy/NasCA/ca-public.crt setevoy-nas-ca.crt
ca-public.crt

Кладемо в системний trust source:

[setevoy@setevoy-work ~] $ sudo cp setevoy-nas-ca.crt /etc/ca-certificates/trust-source/anchors/

Оновлюємо:

[setevoy@setevoy-work ~] $ sudo update-ca-trust

Тепер маємо сімлінк в /etc/ssl/certs/:

[setevoy@setevoy-work ~] $ ll /etc/ssl/certs/ | grep Sete
lrwxrwxrwx 1 root root   52 Apr 18 15:02 Setevoy_CA.pem -> ../../ca-certificates/extracted/cadir/Setevoy_CA.pem

І бачимо сертифікат в trust list:

[setevoy@setevoy-work ~] $ trust list | grep -i "setevoy"
    label: Setevoy CA

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

[setevoy@setevoy-work /tmp]  $ curl https://test-ssl.net.setevoy
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
...

Bonus: швидкий debug сертифікатів

Якщо браузер чи curl все ще скаржиться на сертифікат – корисні команди для перевірки.

Подивитись, що саме віддає NGINX:

$ openssl s_client -connect test-ssl.net.setevoy:443 </dev/null 2>/dev/null | openssl x509 -noout -issuer -subject -ext subjectAltName
issuer=C=UA, ST=Kyiv, O=Setevoy Home NAS, CN=Setevoy CA
subject=C=UA, ST=Kyiv, O=Setevoy Home NAS, CN=*.net.setevoy
X509v3 Subject Alternative Name: 
    DNS:*.net.setevoy, DNS:net.setevoy

Тут перевіряємо: правильний issuer (наш CA), правильний subject, і головне – Subject Alternative Name з потрібним хостом.

Перевірити підключення з конкретним CA, не додаючи його в систему:

[setevoy@setevoy-work /tmp]  $ curl --cacert ./setevoy-nas-ca.crt https://test-ssl.net.setevoy
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
...

Якщо з --cacert працює, а без нього – ні, значить CA не доїхав до системного trust store – перевіряємо update-ca-trust / certctl rehash.

Перевірити повний chain:

$ openssl s_client -connect test-ssl.net.setevoy:443 -showcerts
Connecting to 192.168.0.2
CONNECTED(00000003)
depth=0 C=UA, ST=Kyiv, O=Setevoy Home NAS, CN=*.net.setevoy
verify error:num=20:unable to get local issuer certificate
verify return:1
depth=0 C=UA, ST=Kyiv, O=Setevoy Home NAS, CN=*.net.setevoy
verify error:num=21:unable to verify the first certificate
verify return:1
depth=0 C=UA, ST=Kyiv, O=Setevoy Home NAS, CN=*.net.setevoy
verify return:1
---
Certificate chain
 0 s:C=UA, ST=Kyiv, O=Setevoy Home NAS, CN=*.net.setevoy
   i:C=UA, ST=Kyiv, O=Setevoy Home NAS, CN=Setevoy CA
   a:PKEY: RSA, 2048 (bit); sigalg: sha256WithRSAEncryption
   v:NotBefore: Apr 18 11:35:59 2026 GMT; NotAfter: Jul 21 11:35:59 2028 GMT
...

Браузери та сертифікати CA

Окремий момент щодо браузерів: Firefox має власний trust store і не дивиться в системний тому для Firefox власний сертифікат CA треба додавати окремо через about:preferences#privacy > “View Certificates”:

Далі Import:

І тепер працює без помилок:

Google Chrome, Brave, Vivaldi і решта на Linux зазвичай використовують системний trust store, але можна імпортувати вручну на сторінці chrome://certificate-manager/localcerts:

Готово.

Loading

AWS: налаштування Okta SSO з AWS IAM Identity Center
0 (0)

31 Березня 2026

В попередній частині серії по налаштуванню Okta зробили SSO для Grafana (див. Okta: налаштування Grafana SSO з OIDC та Role mapping) – тепер більш цікава задача: треба налаштувати SSO для AWS, і мати не тільки log in – а і users provisioning.

В Okta для цього є AWS IAM Identity Center App, яка дозволяє налаштувати логін з SAML (див. також What is: SAML – обзор, структура и трассировка запросов на примере Jenkins и Okta SAML SSO) та user provisioning із SCIM.

З боку AWS для цієї інтеграції налаштуємо власне сам IAM Identity Center, і заодно створимо AWS Organization.

З приводу Terraform: свідомо роблю без нього, бо зараз ми використовуємо Okta акаунт разом з іншим проектом і потім будемо відокремлюватись і перероблювати сетап. Ну і, крім того – я не займався налаштуваннями Okta з ~2020 року, тому перший час краще “поклікопсити”, аби краще розібратись з тими змінами, які за цей час сталися.

Аналогічно з Terraform для AWS – якщо всякі VPC/EKS у нас вже зроблені з Terraform, то налаштування, які відносяться до account management поки роблю руками, бо 100% ми будемо або переїжджати в новий акаунт, або будемо розділяти поточний, і поки невідомо як це все буде виглядати.

Але коли переїдемо – то 100% будуть пости по Terraform з Okta та AWS.

AWS та сервіси для User Management

Перш ніж почати налаштування Okta – давайте коротко про те, що взагалі в AWS є з сервісів, які мають відношення до управлінню юзерами і доступами:

  • AWS IAM: базовий сервіс – юзери, групи, ролі, політики
  • AWS IAM Identity Center (колишній AWS Single Sign-On): те, що ми будемо використовувати для Okta – централізоване управління доступом до різних AWS Accounts, інтеграція з Identity Providers (IdP – Okta, Azure Active Directory, etc)
  • AWS Organizations: централізоване управління різними AWS Accounts – Service Control Policies (SCP), спільні CloudTrail, Config, GuardDuty, централізований білінг
  • AWS Control Tower: автоматичне налаштування AWS Organizations, IAM Identity Center, загальний compliance, security

Варіанти AWS SSO та Okta

Є два підходи до інтеграції Okta з AWS:

  • AWS Account Federation (legacy):
    • прямий SAML між Okta і кожним AWS акаунтом окремо через IAM Identity Providers – для кожного акаунту треба окремо створювати IAM Roles з Trust Policy на Okta, окремо налаштовувати SAML
    • при наявності 10 акаунтів – 10 раз повторювати одне і те саме налаштування
    • SCIM (provisioning) з Okta не підтримується – тобто юзери і групи не синхронізуються автоматично
  • IAM Identity Center:
    • централізований підхід – Okta підключається один раз через SAML, юзери і групи синхронізуються автоматично за SCIM протоколом
    • Permission Sets (aka IAM Policies для юзерів і груп) – права визначаються один раз і призначаються на будь-яку кількість акаунтів
    • при додаванні нового акаунту в AWS Organization – просто вибираємо існуючі групи та Permission Sets, без додаткового налаштування SAML

Ми будемо робити модно-маладьожно, з IAM Identity Center:

  • Okta: буде нашим Idetity Provider – юзери створюються там, логін тільки через Okta
  • IAM Identity Center: буде отримувати аутентифікованих юзерів від Okta та виконувати авторизацію з Permission Sets

Документація: Configure SAML and SCIM with Okta and IAM Identity Center та Configure AWS accounts and roles for SAML SSO.

Про AWS Organization

AWS Organizations дає нам централізоване управління кількома AWS акаунтами – об’єднує акаунти в ієрархію (Organizational Unit, OU – повіяло ностальгією за OpenLDAP) з єдиним білінгом, є основою для multi-account management і обов’язковою умовою для повноцінного IAM Identity Center з multi-account SSO.

Що дає AWS Organizations

Billing: єдиний consolidated billing на всі акаунти. До того ж всякі Reserved Instances і Savings Plans можна використовувати між всіма акаунтами організації..

Security / Governance

Єдина точка менеджменту різними security services:

  • SCPs (Service Control Policies): політики обмежень на рівні акаунту або OU, які діють поверх будь-яких IAM прав і які не можна обійти навіть з AdministratorAccess, наприклад – “ніхто не може вимкнути CloudTrail” або “дозволити створення нових ресурсів тільки в заданих AWS Regions
  • AWS Config aggregator: збирає дані про конфігурацію ресурсів з усіх акаунтів в одне місце – можна бачити чи всі ресурси відповідають заданим правилам, наприклад – “всі S3 buckets мають бути зашифровані” або “всі EC2 інстанси мають мати певні теги
  • CloudTrail organization trail: єдиний CloudTrail для усіх акаунтів, не треба в кожному налаштовувати окремо
  • GuardDuty, Security Hub, Macie: централізоване управління всіма security services

Networking: RAM (Resource Access Manager): дозволяє використовувати спільні ресурси між акаунтами без необхідності налаштовувати це між кожною парою акаунтів.

Account isolation (головна причина multi-account):

  • можна (і треба) мати Production акаунт повністю ізольованим від Dev – випадковий terraform destroy в Dev не торкнеться Prod
    • ще рекомендується мати і окремий акаунт з обмеженим доступом для security services
  • обмежуємо blast radius одним акаунтом: якщо пушнули ACCESS/SECRET ключі в GitHub – то “під роздачу” попаде тільки один акаунт
    • хоча краще ключі не використовувати взагалі

Що відбувається при створенні Organizations

Нічого не ламається: всі існуючі IAM Users, IAM Roles, IAM Policies, всі сервіси (EKS, RDS, S3) продовжують працювати. Поточний акаунт стає management account, з’являється root OU.

Єдиний момент, який треба мати на увазі, це сам management account – його потім змінити не можна. Тому перевіряємо, що створюємо Organization з правильного акаунту – там де billing і root доступ.

Створення AWS Organization

Переходимо в AWS Organization, клікаємо Create an organization:

AWS: налаштування Okta SSO з AWS IAM Identity Center

AWS рекомендує створювати Organization з окремого акаунту – але нам, як маленькому стартапу, підійде і поточний, в якому маємо всі наші сервіси:

AWS: налаштування Okta SSO з AWS IAM Identity Center

Після створення Organization, AWS пропонує включити Centralize root access for member accounts – відключити root accounts, і всі адміністративні дії виконувати тільки з management account.

Нам це поки не актуально, бо взагалі маємо тільки один акаунт, але взагалі з точки зору безпеки штука корисна:

AWS: налаштування Okta SSO з AWS IAM Identity Center

Поїхали до самого цікавого.

Створення Okta App – AWS IAM Identity Center

Спершу додамо Okta App – IAM Identity Center, бо в самому AWS IAM Identity Center потрібні будуть параметри SAML від Okta:

AWS: налаштування Okta SSO з AWS IAM Identity Center

Отримуємо лінк на SAML metadata:

AWS: налаштування Okta SSO з AWS IAM Identity Center

У нас в Okta кастомний домен, в браузері свариться на сертифікат, а через HSTS нема можливості цю помилку ігнорувати:

AWS: налаштування Okta SSO з AWS IAM Identity Center

Тому просто завантажуємо з curl:

$ curl -k https://okta.example.co/app/***/sso/saml/metadata -o metadata.xml

Перевіряємо, що дані в файлі є:

$ head metadata.xml 
<?xml version="1.0" encoding="UTF-8"?><md:EntityDescriptor entityID="http://www.okta.com/***" xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"><md:IDPSSODescriptor WantAuthnRequestsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol"><md:KeyDescriptor use="signing"><ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><ds:X509Data><ds:X509Certificate>MII
...

Тепер, як AWS Organization та Okta App у нас є – можемо налаштувати IAM Identity Center.

Налаштування AWS IAM Identity Center

Документація – What is IAM Identity Center?

Що нам дасть IAM Identity Center, і що будемо налаштовувати:

  • AWS Access Portal: буде єдина сторінка входу в усі акаунти організації
  • Identity Source: налаштуємо source of truth для юзерів, в нашому випадку буде External Ientity Provider – Okta
  • Account Assignments: прив’язка User Groups в IAM Identity Center, які далі синхронізуємо з Okta – до Permission Set для конкретного AWS акаунту, тобто – “Okta Group з іменем org-DevOps має AdministratorAccess в акаунті <accountName>
  • Permission Sets: набір IAM policies, який IAM Identity Center автоматично створює як IAM Role (з іменем, яке починається з AWSReservedSSO_) в цільовому AWS акаунті при підключенні User Group до Permission Sets, і далі, при логіні в акаунт – юзер використовує цю роль

Перед початком читаємо IAM Identity Center prerequisites and considerations, звертаємо увагу на:

IAM Identity Center creates IAM roles to give users permissions to account resources. For more information, see IAM roles created by IAM Identity Center.

AWS Organizations is recommended, but not required, for use with IAM Identity Center. If you haven’t set up an organization, you do not have to. If you’ve already set up AWS Organizations and are going to add IAM Identity Center to your organization, make sure that all AWS Organizations features are enabled. For more information, see IAM Identity Center and AWS Organizations.

Поїхали – переходимо в IAM Identity Center, клікаємо Enable:

AWS: налаштування Okta SSO з AWS IAM Identity Center

Якщо AWS Organization ще нема – AWS пропонує її створити, якщо не хочемо мати Organization – можна включити IAM Identity Center в режимі account instance:

AWS: налаштування Okta SSO з AWS IAM Identity Center

Якщо Organization вже є – то відразу включаємо як organization instance:

AWS: налаштування Okta SSO з AWS IAM Identity Center

Клікаємо Enable, починаємо конфігурацію.

Налаштування Identity Source з Okta

Переходимо в Settings > Identity Source, в Actions вибираємо Change Identity Source:

AWS: налаштування Okta SSO з AWS IAM Identity Center

Вибираємо External type:

AWS: налаштування Okta SSO з AWS IAM Identity Center

Отримуємо URLs, зберігаємо собі:

  • IAM Identity Center Assertion Consumer Service (ACS) URL
  • IAM Identity Center issuer URL

В Identity Provider Metadata завантажуємо файл metadata.xml, який скачали з Okta App:

AWS: налаштування Okta SSO з AWS IAM Identity Center

При  зміні IAM Identity Center виводить попередження про зміни для юзерів – але це відноситься тільки для юзерів самого IAM Identity Center, яких в нашому випадку ще нема – логін для звичайних IAM Users буде працювати, як і раніше:

AWS: налаштування Okta SSO з AWS IAM Identity Center

Налаштування SAML в Okta AWS IAM Identity Center App

Пишемо ACCEPT, клікаємо Change – отримуємо налаштування для SAML в Okta App:

AWS: налаштування Okta SSO з AWS IAM Identity Center

Повертаємось до Okta App, переключаємось на Sign On, клікаємо Edit та задаємо адреси:

  • AWS SSO ACS URL: це IAM Identity Center Assertion Consumer Service (ACS) URL із AWS IAM Identity Center
  • AWS SSO issuer URL: це IAM Identity Center issuer URL із AWS IAM Identity Center

AWS: налаштування Okta SSO з AWS IAM Identity Center

Власне, на цьому з аутентифікацією все.

Але залогінитись юзери ще не можуть – трохи далі налаштуємо це.

Поки зробимо Users та Groups provisiong – синхронізацію груп та юзерів із Okta до AWS IAM Identity Center.

Налаштування Provisioning з Okta до IAM Identity Center

Повертаємось до IAM Identity Center > Settings, клікаємо Enable в Automatic Provisioning:

AWS: налаштування Okta SSO з AWS IAM Identity Center

Отримуємо URL та Access Token.

Токен відразу зберігаємо – бо більше його не побачимо:

AWS: налаштування Okta SSO з AWS IAM Identity Center

Повертаємось до Okta > Provisioning > Configure API Integration:

AWS: налаштування Okta SSO з AWS IAM Identity Center

Групи із IAM Identity Center в Okta нам не потрібні – ми будемо робити тільки з Okta до IAM Identity Center, тому знімаємо галочку, погоджуємось з попередженням:

AWS: налаштування Okta SSO з AWS IAM Identity Center

Задаємо URL, токен, клікаємо Test API Credentials:

AWS: налаштування Okta SSO з AWS IAM Identity Center

“Єсть контакт!”:

AWS: налаштування Okta SSO з AWS IAM Identity Center

Зберігаємо, клікаємо Edit, включаємо синхронізацію юзерів, їхнії атрибутів та деактивацію юзерів (виключили акаунт в Okta – виключили в AWS):

AWS: налаштування Okta SSO з AWS IAM Identity Center

Тепер у нас під назвою App все зелене – маємо всі інтеграції:

AWS: налаштування Okta SSO з AWS IAM Identity Center

Assigning Okta Users та Okta Groups до Okta IAM Identity Center App

Переходимо в Assign, додаємо цю App до Okta Group:

AWS: налаштування Okta SSO з AWS IAM Identity Center

Залишаємо всі дефолтні атрибути:

AWS: налаштування Okta SSO з AWS IAM Identity Center

І вже маємо юзерів в IAM Identity Center:

AWS: налаштування Okta SSO з AWS IAM Identity Center

Але не групи – тут поки пусто:

Створення Permission Set для IAM Identity Center User Groups

Документація – Create, manage, and delete permission sets.

Permission Sets визначає те, які права доступу будуть у юзера чи групи в AWS Account, тобто:

  • в Okta маємо Okta Group (org-DevOps)
  • Okta виконує group push в IAM Identity Center (про це далі)
  • в IAM Identity Center отримуємо нову групу org-DevOps
  • цю групу додаємо до AWS Account
  • в AWS Account створиться IAM Role з іменем AWSReservedSSO_<Permission_Set_name>
  • при логіні в акаунт – юзер виконує Assume Role цієї ролі

Створюємо новий Permission Set:

AWS: налаштування Okta SSO з AWS IAM Identity Center

В Custom Permission Set можна вибрати власні політики, описати inline policy, або використати вже готові набори.

Для девопсів робимо AdministartorAccess:

AWS: налаштування Okta SSO з AWS IAM Identity Center

Session duration можна поставити побільше:

AWS: налаштування Okta SSO з AWS IAM Identity Center

Зберігаємо новий Permission Set, але Provisioned status поки Not provisioned – бо цей Permission Set ще нікому підключений:

AWS: налаштування Okta SSO з AWS IAM Identity Center

 

Синхронізація Okta Groups з Okta Push Groups

Для синхронізації Okta Groups до AWS IAM Identity Center – переходимо в Push Groups, вибираємо групу – при чому необов’язково, щоб вона була Assigned до цієї App:

AWS: налаштування Okta SSO з AWS IAM Identity Center

Вибираємо Okta Group:AWS: налаштування Okta SSO з AWS IAM Identity Center

Група готова до push в IAM Identity Center, і маємо дві опції – Create Group, якщо такої групи в AWS ще нема, або Link Group – зв’язати групу в Okta з вже існуючою групою в AWS:

AWS: налаштування Okta SSO з AWS IAM Identity Center

Клікаємо Save, починається процес синхронізації:

AWS: налаштування Okta SSO з AWS IAM Identity Center

Готово:

AWS: налаштування Okta SSO з AWS IAM Identity Center

Перевіряємо групи  в IAM Identity Center – є нова група з двома юзерами:

AWS: налаштування Okta SSO з AWS IAM Identity CenterПотім в Okta можна відключити синхронізацію:

AWS: налаштування Okta SSO з AWS IAM Identity Center

Підключення IAM Identity Center User Groups до AWS Accounts

Аби юзери цієї групи могли логінитись в AWS Account – виконуємо Assign вже в самому IAM Identity Center:

AWS: налаштування Okta SSO з AWS IAM Identity Center

Вибираємо групу:

AWS: налаштування Okta SSO з AWS IAM Identity Center

Вибираємо створений раніше Permission Set:

AWS: налаштування Okta SSO з AWS IAM Identity Center

В списку AWS Accounts тепер маємо підключений Permission Set:

AWS: налаштування Okta SSO з AWS IAM Identity CenterІ в самому AWS Account в IAM Roles маємо нову роль:

AWS: налаштування Okta SSO з AWS IAM Identity Center

Final: логін з SSO через AWS Access Portal

Знаходимо URL нашого AWS Access Portal – це буде єдина точка входу всіх юзерів:

AWS: налаштування Okta SSO з AWS IAM Identity Center

Або клікаємо на App в Okta.

Попадаємо на сторінку вибору акаунтів, відразу бачимо Permission Set з яким можемо залогінитись:

Логінимось, і маємо доступ до всіх наших сервісів:

AWS: налаштування Okta SSO з AWS IAM Identity Center

Власне – на цьому і все.

SSO та user provisioning налаштований, логін працює.

Для AWS Access Portal можемо налаштувати власний URL – але тільки в зоні awsapps.com – клікаємо Edit:

AWS: налаштування Okta SSO з AWS IAM Identity Center

Задаємо власне ім’я:

AWS: налаштування Okta SSO з AWS IAM Identity CenterІ далі ходимо через https://example.awsapps.com/start.

Налаштування AWS CLI з SSO

Всі старі доступи з ACCESS/SECRET ключами ще працюють, але відразу налаштовуємо собі новий логін з SSO.

Документація – Configuring IAM Identity Center authentication with the AWS CLI.

Виконуємо aws configure sso, з --profile вказуємо для якого саме акаунту буде логін з SSO:

$ aws configure sso --profile work
SSO session name (Recommended): org-sso
SSO start URL [None]: https://example.awsapps.com/start
SSO region [None]: us-east-1
SSO registration scopes [sso:account:access]: 
Attempting to automatically open the SSO authorization page in your default browser.
...

Відкриється браузер, дозволяємо доступ:

AWS: налаштування Okta SSO з AWS IAM Identity Center

І  терміналі бачимо повідомлення, що SSO для профайлу work налаштований:

...
The only AWS account available to you is: 492***148
Using the account ID 492***148
The only role available to you is: DevOps-AdministratorAccess
Using the role name "DevOps-AdministratorAccess"
Default client Region [us-east-1]:
CLI default output format (json if not specified) [None]:
To use this profile, specify the profile name using --profile, as shown:

aws sts get-caller-identity --profile work

Перевіряємо як ми залогінені – маємо наш власний UserId, який має assumed-role/AWSReservedSSO_DevOps-AdministratorAccess:

$ aws sts get-caller-identity --profile work
{
    "UserId": "ARO***ORD:[email protected]",
    "Account": "492***148",
    "Arn": "arn:aws:sts::492***148:assumed-role/AWSReservedSSO_DevOps-AdministratorAccess_66a4ead4b037e25f/[email protected]"
}

А в ~/.aws/config тепер для юзера маємо sso_session та конфіг самого SSO:

$ cat .aws/config
...
[profile work]
region = us-east-1
output = json
sso_session = org-sso
sso_account_id = 492***148
sso_role_name = DevOps-AdministratorAccess

...
[sso-session org-sso]
sso_start_url = https://example.awsapps.com/start
sso_region = us-east-1
sso_registration_scopes = sso:account:access

Готово.

Loading

FreeBSD: налаштування FEMP – NGINX, PHP-FPM, MariaDB
0 (0)

30 Березня 2026

Чергова частина  налаштування Home NAS на FreeBSD, хоча тут же не про NAS, а чисто про запуск веб-сервісів.

Вся серія постів по FreeBSD та NAS починається тут – FreeBSD: Home NAS, part 1 – налаштування ZFS mirror, там станом на зараз 15 частин, але FEMP вже винесу окремо.

На моєму хості з FreeBSD (вже є) запущений мій особистий щоденник, який, як і RTFM, працює на WordPress.

Отже, для нього треба підняти стандартний стек FEMP – FreeBSD + NGINX + PHP-FPM + MariaDB, а заодно налаштувати virtualhosts для сервісів типу Grafana, VictoriMetrics VM UI, Syncthing WebUI, Jellyfin тощо.

Робити будемо базовий сетап, без FreeBSD Jails – бо це чисто домашні внутрішні сервіси, але колись, у 2011-2013 роках, блог RTFM працював саме на такому сетапі, хіба що тоді ще була MySQL, а не MariaDB.

На цьому хості зараз FreeBSD v14.3, але принципової різниці з 15 нема.

Налаштування SSL буде окремим постом, із self-signed sertificate – тут всі віртуалхости на стандартному HTTP і порту 80.

Моніторинг NGINX/PHP описаний в VictoriaMetrics: базовий моніторинг AWS, Linux, NGINX та PHP.

Установка NGINX

Є в репозиторіях, встановлюємо з pkg:

root@setevoy-nas:~ # pkg install nginx

Додаємо в автостарт:

root@setevoy-nas:~ # sysrc nginx_enable="YES"

Запускаємо сервіс:

root@setevoy-nas:~ # service nginx start

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

root@setevoy-nas:~ # sockstat -4 -l | grep nginx
www      nginx        455 6   tcp4   *:80                  *:*
root     nginx        454 6   tcp4   *:80                  *:*

Перевіряємо, що все працює:

root@setevoy-nas:~ # curl localhost:80
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
...

Налаштування NGINX virtualhosts

Створюємо каталог для конфігів власних віртуалхостів:

root@setevoy-nas:~ # mkdir -p /usr/local/etc/nginx/conf.d

Додаємо include з цим каталогом в основний конфіг /usr/local/etc/nginx/nginx.conf:

FreeBSD: налаштування FEMP - NGINX, PHP-FPM, MariaDB

...
http {  

    # virtualhosts
    include /usr/local/etc/nginx/conf.d/*.conf;
...

Створення NGINX virtualhost для Grafana

Створюємо новий файл /usr/local/etc/nginx/conf.d/grafana.setevoy.conf.

В ньому задаємо ім’я хоста grafana.setevoy (.setevoy – моя локальна DNS-зона на MikroTik), і вказуємо proxy_pass – адреса, на якій запущена Grafana (про установку Grafana на FreeBSD див. FreeBSD: Home NAS, part 10 – моніторинг з VictoriaMetrics та Grafana):

server {
    listen 80;
    server_name grafana.setevoy;

    location / {
        proxy_pass         http://127.0.0.1:3000;
        proxy_http_version 1.1;

        proxy_set_header   Host              $host;
        proxy_set_header   X-Real-IP         $remote_addr;
        proxy_set_header   X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto $scheme;

        proxy_set_header   Upgrade           $http_upgrade;
        proxy_set_header   Connection        "upgrade";
    }
}

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

root@setevoy-nas:~ # nginx -t && service nginx reload

І відкриваємо в браузері http://grafana.setevoy:

FreeBSD: налаштування FEMP - NGINX, PHP-FPM, MariaDB

Створення NGINX virtualhost для VictoriaMetrics та редіректи

На відміну від Grafana, для доступу до VM UI у VictoriaMetrics нам треба URI /vmui/ – тому відразу налаштуємо редірект: якщо на NGINX приходить запит на victoria.setevoy – то відправляємо на victoria.setevoy/vmui/:

server {
    listen 80;
    server_name victoria.setevoy;

    location = / {
        return 301 /vmui/; 
    }   

    location / {
        proxy_pass         http://127.0.0.1:8428;
        proxy_http_version 1.1;

        proxy_set_header   Host              $host;
        proxy_set_header   X-Real-IP         $remote_addr;
        proxy_set_header   X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto $scheme;
    }
}

Установка PHP та PHP-FPM

Встановлюємо з pkg з репозиторію:

root@setevoy-nas:~ # pkg install -y php84 php84-extensions

Створюємо власний php.ini:

root@setevoy-nas:~ # cp /usr/local/etc/php.ini-production /usr/local/etc/php.ini

Створюємо файл налаштувань для PHP-FPM – /usr/local/etc/php-fpm.d/blog.setevoy.conf.

Задаємо параметри FPM (див. PHP-FPM: Process Manager – dynamic vs ondemand vs static – 2018 рік, але механізм той самий).

З важливого в конфігу:

  • user && group: власник процесів PHP
  • listen: використовуємо Unix socket замість TCP
  • listen.owner та listen.group: власник файлу сокета – www, бо до файлу треба доступ NGINX
  • pm = dynamic: динамічний пул FPM workers
  • pm.max_children: максимальна кількість процесів PHP для цього пула
  • pm.start_servers: скільки процесів створювати при старті/рестарті FPM
  • pm.min_spare_servers та pm.max_spare_servers – мінімум та максимум процесів в idle

В результаті файл для WordPress виглядає так:

[blog.setevoy]
user = setevoy
group = setevoy

listen = /var/run/php-fpm/blog.setevoy.sock
listen.owner = www
listen.group = www
listen.mode = 0660

pm = dynamic
pm.max_children = 5
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3

slowlog = /var/log/nginx/blog.setevoy-slow.log
php_flag[display_errors] = off
php_admin_value[display_errors] = on
php_admin_value[error_log] = /var/log/nginx/blog.setevoy-php-error.log
php_admin_flag[log_errors] = on
php_admin_value[upload_max_filesize] = 128M
php_admin_value[post_max_size] = 128M

І для прикладу – поточний конфіг самого rtfm.co.ua – тільки він в AWS на EC2 з Amazon Linux:

[rtfm.co.ua]

; run workers as this user
user = rtfm
group = rtfm

; unix socket path for nginx upstream
listen = /var/run/rtfm.co.ua-php-fpm.sock

; socket owner - must match nginx user
listen.owner = nginx
listen.group = nginx

; process manage settings
pm = dynamic                   ; dynamic - spawn/kill workers based on load
pm.max_children = 8            ; max workers total
pm.start_servers = 2           ; workers on startup
pm.min_spare_servers = 2       ; min idle workers
pm.max_spare_servers = 4       ; max idle workers
pm.process_idle_timeout = 10s  ; kill idle workers after N seconds
pm.max_requests = 500          ; restart worker after N requests (prevents memory leaks)

; write worker stderr to main fpm log
catch_workers_output = yes
; worker startup directory
chdir = /
; endpoint for fpm status page (use in nginx location)
pm.status_path = /fpm-status

; fpm-level log for requests slower than request_slowlog_timeout
slowlog = /var/log/php/rtfm.co.ua/rtfm.co.ua-slow.log

; php ini overrides - php_admin_value cannot be overridden by app code
php_admin_value[display_errors] = off
php_admin_value[error_log] = /var/log/php/rtfm.co.ua/rtfm.co.ua-error.log
php_admin_flag[log_errors] = on

; sessions - make sure /var/lib/php/session/rtfm exists, owner rtfm:rtfm
php_admin_value[session.save_path] = /var/lib/php/session/rtfm
php_value[session.save_handler] = files

; max upload size
php_admin_value[upload_max_filesize] = 128M
php_admin_value[post_max_size] = 128M
php_admin_value[memory_limit] = 256M

Створюємо каталог для сокетів:

root@setevoy-nas:~ # mkdir -p /var/run/php-fpm

Додаємо PHP-FPM в автостарт:

root@setevoy-nas:~ # sysrc php_fpm_enable="YES"

Запускаємо:

root@setevoy-nas:~ # service php_fpm start
Performing sanity check on php-fpm configuration:
[18-Feb-2026 18:21:59] NOTICE: configuration file /usr/local/etc/php-fpm.conf test is successful
Starting php_fpm.

Перевіряємо файл сокету – що він є і має правильні права доступу:

root@setevoy-nas:~ # ls -la /var/run/php-fpm/php-fpm.sock
srw-rw----  1 www www 0 Feb 18 18:21 /var/run/php-fpm/php-fpm.sock

Створення NGINX virtualhost для перевірки PHP

Додаємо новий віртуалхост для NGINX – файл /usr/local/etc/nginx/conf.d/blog.setevoy.conf:

server {
    listen 80;
    server_name blog.setevoy;

    root /usr/local/www/blog.setevoy;
    index index.php index.html;

    access_log /var/log/nginx/blog.setevoy.access.log;
    error_log  /var/log/nginx/blog.setevoy.error.log;

    location / {
        try_files $uri $uri/ /index.php?$args;
    }

    location ~ \.php$ {
        fastcgi_pass  unix:/var/run/php-fpm/blog.setevoy.sock;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include       fastcgi_params;
    }

    location ~ /\.ht {
        deny all;
    }
}

Створюємо каталог для файлів майбутнього блогу:

root@setevoy-nas:~ # mkdir -p /usr/local/www/blog.setevoy

І там один файл з викликом phpinfo() для тесту:

root@setevoy-nas:~ # echo "<?php phpinfo();" > /usr/local/www/blog.setevoy/phpinfo.php

Задаємо власника:

root@setevoy-nas:~ # chown -R setevoy:setevoy /usr/local/www/blog.setevoy

Перевіряємо в браузері http://blog.setevoy/phpinfo.php:

FreeBSD: налаштування FEMP - NGINX, PHP-FPM, MariaDB

Установка MariaDB

Шукаємо останню доступну версію:

root@setevoy-nas:~ # pkg search mariadb | grep server
mariadb1011-server-10.11.15    Multithreaded SQL database (server)
mariadb106-server-10.6.24      Multithreaded SQL database (server)
mariadb114-server-11.4.9       Multithreaded SQL database (server)

Встановлюємо MariaDB 11.4:

root@setevoy-nas:~ # pkg install mariadb114-server

Додаємо в автостарт, запускаємо:

root@setevoy-nas:~ # sysrc mysql_enable="YES"
root@setevoy-nas:~ # service mysql-server start

Запускаємо скрипт mariadb-secure-installation для дефолтних налаштувань:

root@setevoy-nas:~ # mariadb-secure-installation

Проходимось по основним параметрам, тут можна всюди відповідати просто “yes” – хіба що задати пароль root:

root@setevoy-nas:~ # mariadb-secure-installation
/usr/local/bin/mysql_secure_installation: Deprecated program name. It will be removed in a future release, use 'mariadb-secure-installation' instead
...
Switch to unix_socket authentication [Y/n] 
Enabled successfully!
Reloading privilege tables..
 ... Success!

...
Change the root password? [Y/n] 
New password: 
Re-enter new password: 
Password updated successfully!
Reloading privilege tables..
 ... Success!

...
Remove anonymous users? [Y/n] 
 ... Success!

...
Disallow root login remotely? [Y/n] 
 ... Success!

...
Remove test database and access to it? [Y/n] 
 - Dropping test database...
 ... Success!
 - Removing privileges on test database...
 ... Success!

...
Reload privilege tables now? [Y/n] 
 ... Success!

Cleaning up...

All done!  If you've completed all of the above steps, your MariaDB
installation should now be secure.

Thanks for using MariaDB!

Створення MariaDB database та user

Підключаємось до сервера:

root@setevoy-nas:~ # mysql -u root -p
Enter password: 
Welcome to the MariaDB monitor.  Commands end with ; or \g.
Your MariaDB connection id is 13
Server version: 11.4.9-MariaDB FreeBSD Ports

Copyright (c) 2000, 2018, Oracle, MariaDB Corporation Ab and others.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

root@localhost [(none)]>

Створюємо базу, юзера з паролем, даємо юзеру доступ до цієї бази:

root@localhost [(none)]> CREATE DATABASE blog_test CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
Query OK, 1 row affected (0.003 sec)

root@localhost [(none)]> CREATE USER 'blog-test'@'localhost' IDENTIFIED BY 'localpass';
Query OK, 0 rows affected (0.001 sec)

root@localhost [(none)]> GRANT ALL PRIVILEGES ON blog_test.* TO 'blog-test'@'localhost';
Query OK, 0 rows affected (0.001 sec)

root@localhost [(none)]> FLUSH PRIVILEGES;
Query OK, 0 rows affected (0.001 sec)

Виходимо, пробуємо підключитись з цим юзером:

root@setevoy-nas:~ # mysql -u blog-test -p blog_test
Enter password: 
Welcome to the MariaDB monitor.  Commands end with ; or \g.
Your MariaDB connection id is 14
Server version: 11.4.9-MariaDB FreeBSD Ports

Copyright (c) 2000, 2018, Oracle, MariaDB Corporation Ab and others.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

blog-test@localhost [blog_test]>

Установка WordPress

Завантажуємо архів з останнім релізом, розпаковуємо, переносимо файли в каталог /usr/local/www/blog.setevoy/:

root@setevoy-nas:~ # fetch https://wordpress.org/latest.tar.gz -o /tmp/latest.tar.gz
root@setevoy-nas:~ # tar -xzf /tmp/latest.tar.gz -C /tmp/

root@setevoy-nas:~ # cp -r /tmp/wordpress/* /usr/local/www/blog.setevoy/

root@setevoy-nas:~ # chown -R setevoy:setevoy /usr/local/www/blog.setevoy/

Відкриваємо в браузері – WordPress свариться на missing PHP extentions:

FreeBSD: налаштування FEMP - NGINX, PHP-FPM, MariaDB

Встановлюємо:

root@setevoy-nas:~ # pkg install php84-mysqli php84-pdo_mysql
root@setevoy-nas:~ # service php-fpm restart

Починаємо установку:

FreeBSD: налаштування FEMP - NGINX, PHP-FPM, MariaDB

Задаємо ім’я бази, юзера, пароль, хост MariaDB:

FreeBSD: налаштування FEMP - NGINX, PHP-FPM, MariaDB

Взагалі WordPress наче має сам створити файл wp-config.php, але ок – копіюємо зміст, створюємо файл вручну:

FreeBSD: налаштування FEMP - NGINX, PHP-FPM, MariaDB

Але знов помилка – “Call to undefined function WpOrg\Requests\gzinflate()“:

FreeBSD: налаштування FEMP - NGINX, PHP-FPM, MariaDB

Додаємо ще один пакет з PHP:

root@setevoy-nas:~ # pkg install php84-zlib

root@setevoy-nas:~ # service php_fpm restart

І тепер все працює – завершуємо налаштування:

FreeBSD: налаштування FEMP - NGINX, PHP-FPM, MariaDB

FreeBSD: налаштування FEMP - NGINX, PHP-FPM, MariaDB

Готово:

FreeBSD: налаштування FEMP - NGINX, PHP-FPM, MariaDB

І навіть прийшов лист на пошту, бо на FreeBSD налаштований DragonFly Mail Agent – див. FreeBSD: налаштування DragonFly Mail Agent для пошти root:

FreeBSD: налаштування FEMP - NGINX, PHP-FPM, MariaDB

Логінимось в адмінку блога:

FreeBSD: налаштування FEMP - NGINX, PHP-FPM, MariaDB

Все працює.

Готово.

Loading

VictoriaMetrics: базовий моніторинг AWS, Linux, NGINX та PHP
5 (2)

28 Березня 2026

Міграція RTFM з DigitalOcean до AWS пройшла без проблем, і потроху “обживаюсь на новому місці”.

Інфраструктура нова, все нове – а тому перший час хочеться уважно постежити за станом серверів та блогу, а тому треба налаштувати базовий моніторинг для WordPress: NGINX, PHP-FPM, базу даних та інфраструктуру, на якій все це крутиться.

Сам стек моніторингу вже розгорнутий на домашньому NAS з FreeBSD – є VictoriaMetrics, VictoriaLogs, Grafana, vmalert та Alertmanager з відправкою алертів в Telegram та ntfy.sh.

По цьому стеку писав в серії постів по FreeBSD та домашньому NAS:

Інфраструктура в AWS

Створення інфраструктури описане в AWS: сетап базової інфраструктури для WordPress і AWS: власний EC2 в ролі NAT Gateway замість AWS Managed NAT Gateway.

Нагадаю, що в AWS є загалом:

  • VPC з 4 Subnets – 2 публічні, 2 приватні
  • Application Load Balancer з Target Group, в TG – один EC2
  • два EC2 instances з Amazon Linux 2023:
    • один з NGINX та PHP-FPM для самого WordPress
    • і окремий EC2 в ролі NAT Gateway
  • AWS RDS з MariaDB – сервер бази даних для WordPress

На обох EC2 піднятий WireGuard для підключення до домашньої мережі, де в ролі VPN Hub виступає MikriTik RB4011 і який роутить запити до VictoriaMetrics та VictoriaLogs – див. MikroTik: налаштування WireGuard та підключення Linux peers.

Планування моніторингу

Що є з сервісів, які треба помоніторити:

  • AWS RDS: стан серверу бази даних
  • AWS ALB: картина того, що відбувається на Load Balancer
  • AWS EC2: різні метрики по стану самих інстансів
  • NGINX: метрики веб-сервера
  • PHP-FPM: метрики воркерів FPM

Крім того, треба збирати системні логи операційної системи та логи NGINX і PHP.

Логи RDS теж можуть бути корисними – але це вже на випадок реальних проблем, а тоді вже можна просто подивитись в CloudWatch Logs.

Для збору метрик на EC2 використав:

  • node_exporter: базові метрики EC2 – CPU, RAM, диск, мережа
  • nginx_exporter: простенький, метрик мало, але нехай буде (окремо зробимо метрики з логів NGINX)
  • php_fpm_exporter: метрики PHP-FPM – процеси, використання воркерів, slow requests
  • yace_exporter: збирає з CloudWatch дефолтні метрики по стану ALB та RDS

Для логів поки взяв Fluent Bit, який писатиме до VictoriaLogs. Взагалі, пізніше для збору логів спробую vlagent, зараз робив “швиденько” – тому взяв те, що в мене вже працює на FreeBSD/NAS.

vlagent виглядає дуже цікаво, див. цікавий пост в блогах VictoriaMetrics – Benchmarking Kubernetes Log Collectors: vlagent, Vector, Fluent Bit, OpenTelemetry Collector, and more. Але релізнули місяці три тому (по стану на березень 2026), тому поки що може мати не всіх корисні плюшки.

Пізніше можна буде додати cloudflare-prometheus-exporter та process_exporter, або генерити власні метрики з Textfile для node_exporter.

Установка експортерів

Запускати будемо стандартно – з Docker та Docker Compose.

Встановлюємо Docker:

[root@ip-10-0-3-146 ~]# dnf install -y docker

[root@ip-10-0-3-146 ~]# systemctl enable --now docker

Docker Compose:

[root@ip-10-0-3-146 ~]# mkdir -p /usr/local/lib/docker/cli-plugins

[root@ip-10-0-3-146 ~]# curl -SL https://github.com/docker/compose/releases/latest/download/docker-compose-linux-x86_64 -o /usr/local/lib/docker/cli-plugins/docker-compose

[root@ip-10-0-3-146 ~]# chmod +x /usr/local/lib/docker/cli-plugins/docker-compose

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

[root@ip-10-0-3-146 ~]# docker compose version
Docker Compose version v5.1.0

Запуск node_exporter

Створюємо каталог /opt/monitoring і в ньому файл /opt/monitoring/docker-compose.yml:

services:
  node_exporter:
    image: prom/node-exporter:latest
    container_name: node_exporter
    restart: unless-stopped
    pid: host
    network_mode: host
    volumes:
      - /proc:/host/proc:ro
      - /sys:/host/sys:ro
      - /:/rootfs:ro
    command:
      - '--path.procfs=/host/proc'
      - '--path.sysfs=/host/sys'
      - '--path.rootfs=/rootfs'
      - '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'

Щоб node_exporter бачив всі мережеві інтерфейси – задаємо network_mode: host, щоб всі PID – задаємо pid: host.

З точки зору security це не ідеально, бо контейнер з network_mode: host дає повний доступ до мережі хоста, а pid: host дає йому видимість всіх процесів. Але для моніторингу особистого блогу – нормально.

Запускаємо:

[root@ip-10-0-3-146 ~]# cd /opt/monitoring && docker compose up -d

Перевіряємо метрики:

[root@ip-10-0-3-146 ~]# curl -s http://localhost:9100/metrics | grep node_exporter_build
# HELP node_exporter_build_info A metric with a constant '1' value labeled by version, revision, branch, goversion from which node_exporter was built, and the goos and goarch for the build.
# TYPE node_exporter_build_info gauge
node_exporter_build_info{branch="HEAD",goarch="amd64",goos="linux",goversion="go1.25.3",revision="654f19dee6a0c41de78a8d6d870e8c742cdb43b9",tags="unknown",version="1.10.2"} 1

Налаштування vmagent на FreeBSD

Додаємо збір метрик до VictoriaMetrics. На FreeBSD для vmagent використовується конфіг /usr/local/etc/prometheus/prometheus.yml – додаємо туди новий таргет.

В мене в job_name: "node_exporter" вже є один таргет – 127.0.0.1:9100 для метрик самої FreeBSD – туди ж вписуємо 10.100.0.20:9100, де 10.100.0.20 – це адреса EC2 в мережі WireGuard (хоча потім створю Static DNS record на MikroTik):

global:
  scrape_interval: 15s

scrape_configs:

  - job_name: victoriametrics
    scrape_interval: 60s
    scrape_timeout: 30s
    metrics_path: "/metrics"
    static_configs:
    - targets:
      - 127.0.0.1:8428
      labels:
        project: victoriametrics

  - job_name: vmagent
    scrape_interval: 60s
    scrape_timeout: 30s
    metrics_path: "/metrics"
    static_configs:
    - targets:
      - 127.0.0.1:8429
      labels:
        project: vmagent

  - job_name: "node_exporter"
    static_configs:
      - targets:
          - "127.0.0.1:9100"
          - "10.100.0.20:9100"
...

Перевіряємо в VictoriaMetrics через метрику node_uname_info – маємо бачити обидва хости:

Запуск nginx_exporter

Для отримання метрик nginx_exporter використовує модуль stub_status. Додаємо окремий файл /etc/nginx/conf.d/nginx-status.conf:

server {
    listen 127.0.0.1:8080;

    location = /nginx_status {
        stub_status on;
        access_log off;
    }
}

Перевіряємо конфіг та перезавантажуємо NGINX:

[root@ip-10-0-3-146 ~]# nginx -t && systemctl reload nginx

Перевіряємо ендпоінт та дані від NGINX:

[root@ip-10-0-3-146 ~]# curl http://127.0.0.1:8080/nginx_status
Active connections: 6 
server accepts handled requests
 33310 33310 162229 
Reading: 0 Writing: 1 Waiting: 5

Додаємо nginx_exporter до Docker Compose file:

  nginx_exporter:
    image: nginx/nginx-prometheus-exporter:latest
    container_name: nginx_exporter
    restart: unless-stopped
    network_mode: host
    command:
      - '--nginx.scrape-uri=http://127.0.0.1:8080/nginx_status'

Перезапускаємо стек та додаємо таргет до vmagent:

  - job_name: "nginx_exporter"
    static_configs:
      - targets:
          - "10.100.0.20:9113"

Перезапускаємо vmagent та перевіряємо в VictoriaMetrics:

Або через curl напряму:

root@setevoy-nas:~ # curl -s 'http://localhost:8428/api/v1/query?query=nginx_connections_active' | jq
{
  "status": "success",
  "data": {
    "resultType": "vector",
    "result": [
      {
        "metric": {
          "__name__": "nginx_connections_active",
          "instance": "10.100.0.20:9113",
          "job": "nginx_exporter"
        },
        "value": [
          1773670644,
          "8"
        ]
      }
    ]
  },
  "stats": {
    "seriesFetched": "1",
    "executionTimeMsec": 0
  }
}

Запуск php-fpm exporter

Є два популярних експортери для PHP-FPM, хоча обидва давно не оновлюються – але працюють:

Для базового моніторингу WordPress-блогу різниця несуттєва – беремо hipages/php-fpm_exporter, він перевірений та стабільний.

Перевіряємо, що в конфігу PHP-FPM включена опція статусу pm.status_path – в мене файл конфігу FPM це /etc/php-fpm.d/rtfm.co.ua.conf:

...
; endpoint for fpm status page (use in nginx location)
pm.status_path = /status
...

Якщо не включена – додаємо і перезапускаємо PHP-FPM:

[root@ip-10-0-3-146 ~]# systemctl restart php-fpm.service

Додаємо окремий віртуалхост в NGINX, файл /etc/nginx/conf.d/fpm-status.conf, в allow дозволяємо доступ тільки з localhost:

server {
    listen 127.0.0.1:8081;
    server_name localhost;

    # fpm status page - local only
    location = /fpm-status {
        allow 127.0.0.1;
        deny all;

        include fastcgi_params;
        fastcgi_pass unix:/var/run/rtfm.co.ua-php-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }
}

Перезавантажуємо NGINX з nginx -t && systemctl reload nginx та перевіряємо ендпоінт:

[root@ip-10-0-3-146 ~]# curl http://localhost:8081/fpm-status
pool:                 rtfm.co.ua
process manager:      dynamic
start time:           16/Mar/2026:16:22:30 +0200
start since:          653
accepted conn:        218
listen queue:         0
max listen queue:     0
listen queue len:     0
idle processes:       2
active processes:     1
total processes:      3
max active processes: 3
max children reached: 0
slow requests:        0
memory peak:          40792064

PHP-FPM використовує Unix socket – тому монтуємо його в контейнер і передаємо URI з unix://:

  php_fpm_exporter_rtfm:
    image: hipages/php-fpm_exporter:latest
    container_name: php_fpm_exporter
    restart: unless-stopped
    network_mode: host
    volumes:
      - /var/run/rtfm.co.ua-php-fpm.sock:/var/run/rtfm.co.ua-php-fpm.sock
    environment:
      - PHP_FPM_SCRAPE_URI=unix:///var/run/rtfm.co.ua-php-fpm.sock;/fpm-status
      - PHP_FPM_FIX_PROCESS_COUNT=true

Перезапускаємо стек та перевіряємо метрики вже від експортера:

[root@ip-10-0-3-146 ~]# curl -s http://localhost:9253/metrics | grep phpfpm_up
# HELP phpfpm_up Could PHP-FPM be reached?
# TYPE phpfpm_up gauge
phpfpm_up{pool="rtfm.co.ua",scrape_uri="unix:///var/run/rtfm.co.ua-php-fpm.sock;/fpm-status"} 1

Додаємо нову scrape job до vmagent:

  - job_name: "php_fpm_exporter_rtfm"
    static_configs:
      - targets:
          - "10.100.0.20:9253"

Перезапускаємо vmagent та перевіряємо метрики в VictoriaMetrics:

Моніторинг AWS з YACE Exporter

Для AWS-метрик будемо використовувати yet-another-cloudwatch-exporter (YACE) – він забирає метрики з CloudWatch і віддає їх у форматі Prometheus. Трохи детальніше про нього писав у Prometheus: yet-another-cloudwatch-exporter – сбор метрик AWS CloudWatch, досі використовую на робочих проектах.

Документація по метриках:

Створення IAM Policy для YACE

EC2 вже має IAM Role – створював Instance Profile, коли робив AWS: ALB та Cloudflare – налаштування mTLS та AWS Security Rules.

До цієї ролі треба додати IAM Policy для YACE, яка надасть доступу до CloudWatch та iam:ListAccountAliases – щоб відображати ім’я акаунта замість числового ID в метриках:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "cloudwatch:GetMetricData",
                "cloudwatch:GetMetricStatistics",
                "cloudwatch:ListMetrics",
                "tag:GetResources",
                "iam:ListAccountAliases"
            ],
            "Resource": "*"
        }
    ]
}

Зберігаємо Policy:

Підключаємо до IAM Role EC2 інстансу

Перевіряємо, що EC2 має доступ до CloudWatch – підключаємось по SSH, виконуємо з AWS CLI запит до CloudWatch:

[root@ip-10-0-3-146 ~]# aws cloudwatch list-metrics --namespace AWS/ApplicationELB --region eu-west-1
{
    "Metrics": [
        {
            "Namespace": "AWS/ApplicationELB",
            "MetricName": "HTTPCode_Target_3XX_Count",
            "Dimensions": [
                {
                    "Name": "TargetGroup",
                    "Value": "targetgroup/rtfm-tg/66df64e645b2b01d"
                },
                {
                    "Name": "LoadBalancer",
                    "Value": "app/rtfm-alb/cd76dd0d557838f8"
                }
            ]
        },
...

Конфігурація YACE

Створюємо конфіг /opt/monitoring/yace-config.yml. В exportedTagsOnMetrics вказуємо, які AWS-теги додавати до метрик – потім в Grafana і алертах можна буде виводити ім’я, а не ARN.

За збір метрик з CloudWatch платимо гроші, тому тут беремо тільки те, що дійсно корисне:

apiVersion: v1alpha1
discovery:

  exportedTagsOnMetrics:
    AWS/ApplicationELB:
      - Name
    AWS/RDS:
      - Name

  jobs:

    - type: AWS/ApplicationELB
      regions:
        - eu-west-1
      period: 300
      length: 300
      metrics:
        - name: HTTPCode_ELB_5XX_Count
          statistics:
            - Sum
          nilToZero: true
        - name: ActiveConnectionCount
          statistics:
            - Sum
          nilToZero: true

    - type: AWS/RDS
      regions:
        - eu-west-1
      period: 300
      length: 300
      metrics:
        - name: CPUUtilization
          statistics:
            - Average
          nilToZero: true

Додаємо YACE до Docker Compose file:

  yace_exporter:
    image: quay.io/prometheuscommunity/yet-another-cloudwatch-exporter:latest
    container_name: yace
    restart: unless-stopped
    network_mode: host
    volumes:
      - /opt/monitoring/yace-config.yml:/tmp/config.yml:ro
    command:
      - '--config.file=/tmp/config.yml'

Перезапускаємо стек та перевіряємо метрики:

[root@ip-10-0-3-146 ~]# curl -s http://127.0.0.1:5000/metrics | grep aws_
# HELP aws_applicationelb_active_connection_count_sum Help is not implemented yet.
# TYPE aws_applicationelb_active_connection_count_sum gauge
aws_applicationelb_active_connection_count_sum{account_id="264***286",dimension_AvailabilityZone="",dimension_LoadBalancer="app/rtfm-alb/cd76dd0d557838f8",name="arn:aws:elasticloadbalancing:eu-west-1:264***286:loadbalancer/app/rtfm-alb/cd76dd0d557838f8",region="eu-west-1",tag_Name="rtfm-alb-main"} 336
...

Додаємо таргет до vmagent:

  - job_name: "yace_exporter"
    static_configs:
      - targets:
          - "10.100.0.20:5000"

Перевіряємо метрики в VictoriaMetrics:

Автозапуск експортерів із Docker Compose через systemd

Щоб весь стек піднімався автоматично після перезавантаження EC2 – додаємо systemd-сервіс.

Створюємо файл /etc/systemd/system/monitoring.service:

[Unit]
Description=Monitoring exporters stack
Requires=docker.service
After=docker.service

[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/opt/monitoring
ExecStart=/usr/bin/docker compose up -d
ExecStop=/usr/bin/docker compose down
TimeoutStartSec=60

[Install]
WantedBy=multi-user.target

Додаємо в автостарт та запускаємо:

[root@ip-10-0-3-146 ~]# systemctl daemon-reload
[root@ip-10-0-3-146 ~]# systemctl enable --now monitoring

VictoriaLogs та логи з Fluent Bit

Тепер логи. Основні логи – це NGINX та PHP errors. Їх будемо відправляти до VictoriaLogs на FreeBSD хості через http output – див. документацію VictoriaLogs по Fluentbit Setup.

Real IP в NGINX

Трафік до EC2 іде через Cloudflare та ALB, тому якщо нічого не налаштовувати – в логах NGINX замість реального IP клієнта буде адреса ALB. Cloudflare передає реальний IP у заголовку CF-Connecting-IP, а для NGINX є модуль ngx_http_realip_module, якому можна вказати з якого заголовка брати IP клієнта.

Додаємо до nginx.conf (не конфіг віртуалхоста, а конфіг самого NGINX), в секцію http {}:

http {

    # trust ALB (all traffic comes from within VPC)
    set_real_ip_from 10.0.0.0/16;
    # get real client IP from Cloudflare header
    real_ip_header CF-Connecting-IP;

    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';

    access_log  /var/log/nginx/access.log  main;
...

Перезавантажуємо NGINX та перевіряємо, що в логах з’явились реальні IP:

[root@ip-10-0-3-146 ~]# tail /var/log/nginx/rtfm.co.ua-access.log
94.139.42.59 - - [16/Mar/2026:17:29:14 +0200] "GET /ru/2021/11/29/ HTTP/1.1" 200 109096 "-" "kagi-fetcher/1.0"
2a01:4f8:242:3ce9::2 - - [16/Mar/2026:17:29:14 +0200] "GET /api/v1/instance/peers HTTP/1.1" 200 1976 "-" "Go-http-client/2.0"

Logrotate – ротація логів

В Amazon Linux NGINX вже йде з дефолтним конфігом для logrotate в файлі /etc/logrotate.d/nginx:

/var/log/nginx/*.log {
    create 0640 nginx root
    daily
    rotate 10
    missingok
    notifempty
    compress
    delaycompress
    sharedscripts
    postrotate
        /bin/kill -USR1 `cat /run/nginx.pid 2>/dev/null` 2>/dev/null || true
    endscript
}

Дефолтний конфіг ротує всі файли *.log в /var/log/nginx/, але для логів RTFM та логів PHP можна написати свій конфіг з окремими налаштуваннями:

/var/log/nginx/rtfm.co.ua-*.log /var/log/php/rtfm.co.ua/*.log {
    daily
    rotate 14
    compress
    delaycompress
    missingok
    notifempty
    sharedscripts
    postrotate
        nginx -s reopen
    endscript
}

Установка Fluent Bit

Fluent Bit буде читати логи NGINX та PHP і відправляти їх до VictoriaLogs на домашньому NAS.

Додаємо репозиторій – створюємо файл /etc/yum.repos.d/fluent-bit.repo:

[fluent-bit]
name=Fluent Bit
baseurl=https://packages.fluentbit.io/amazonlinux/2023/
gpgcheck=1
gpgkey=https://packages.fluentbit.io/fluentbit.key
enabled=1

Встановлюємо fluent-bit:

[root@ip-10-0-3-146 ~]# dnf install -y fluent-bit

Створюємо каталог для збереження позицій у файлах (щоб після перезапуску Fluent Bit не читав логи з початку):

[root@ip-10-0-3-146 ~]# mkdir -p /var/lib/fluent-bit

Конфігурація Fluent Bit – парсери для NGINX та PHP

Основний конфіг /etc/fluent-bit/fluent-bit.conf в мене виглядає так:

[SERVICE]
    Flush        5
    Daemon       Off
    Log_Level    info
    Parsers_File /etc/fluent-bit/parsers-custom.conf

[INPUT]
    Name        tail
    Path        /var/log/nginx/rtfm.co.ua-access.log
    Tag         nginx.access
    DB          /var/lib/fluent-bit/nginx-access.db
    Parser      nginx_access

[INPUT]
    Name        tail
    Path        /var/log/nginx/rtfm.co.ua-error.log
    Tag         nginx.error
    DB          /var/lib/fluent-bit/nginx-error.db

[INPUT]
    Name        tail
    Path        /var/log/php/rtfm.co.ua/rtfm.co.ua-error.log
    Tag         php.error
    DB          /var/lib/fluent-bit/php-error.db

[FILTER]
    Name    record_modifier
    Match   nginx.access
    Record  host aws-rtfm-main
    Record  job nginx
    Record  log_type access
    Record  site rtfm.co.ua

[FILTER]
    Name    record_modifier
    Match   nginx.error
    Record  host aws-rtfm-main
    Record  job nginx
    Record  log_type error
    Record  site rtfm.co.ua

[FILTER]
    Name    record_modifier
    Match   php.error
    Record  host aws-rtfm-main
    Record  job php-fpm
    Record  log_type error
    Record  site rtfm.co.ua

[FILTER]
    Name    lua
    Match   nginx.access
    script  /etc/fluent-bit/make_msg.lua
    call    make_msg

[Output]
    Name         http
    Match        *
    host         nas.setevoy
    port         9428
    uri          /insert/jsonline?_stream_fields=stream,job,host,log_type,site&_msg_field=log&_time_field=date
    format       json_lines
    json_date_format iso8601
    compress     gzip

Тут:

  • [SERVICE]: глобальні параметри Fleunt Bit
  • [INPUT]: читаємо три файли, кожному задаємо власний tag, аби далі мати окремі фільтри
  • [FILTER]: тут з record_modifier по тегу з [INPUT] фільтруємо який саме лог модифікувати і додаємо нові поля, які потім можна використовувати в VictoriaLogs та алертах; у Fluent Bit на FreeBSD, де є власний NGINX і FPM має такі самі налаштування, тільки, звісно, інші значення полів
  • останній [FILTER] викликає Lua-скрипт для створення поля logs, див. нижче

В дефолтному конфігу Fluent Bit не було парсера для nginx_access – тому створив власний і підключив в [SERVICE] через файл /etc/fluent-bit/parsers-custom.conf:

[PARSER]
    Name        nginx_access
    Format      regex
    Regex       ^(?<remote_addr>[^ ]*) - (?<remote_user>[^ ]*) \[(?<time>[^\]]*)\] "(?<method>[^ ]*) (?<path>[^ ]*) (?<protocol>[^ ]*)" (?<status>[^ ]*) (?<bytes>[^ ]*) "(?<referer>[^"]*)" "(?<agent>[^"]*)"
    Time_Key    time
    Time_Format %d/%b/%Y:%H:%M:%S %z

Але тут випилюється поле _msg, яке VictoriaLogs очікує і без якого не дуже зручно дивитись в VMUI.

Пробував зробити з record_modifier, але врешті-решт просто навайбокодив скрипт на Lua, який створює поле log, яке потім передається до VictoriaLogs в &_msg_field=log:

function make_msg(tag, timestamp, record)
    record["log"] = record["remote_addr"] .. ' "' .. record["method"] .. ' ' .. record["path"] .. '" ' .. record["status"] .. ' "' .. (record["agent"] or "-") .. '"'
    return 1, timestamp, record
end

Запускаємо Fluent Bit:

[root@ip-10-0-3-146 ~]# systemctl enable --now fluent-bit

Перевіряємо логи в VictoriaLogs:

vmalert та алерти з логів у VictoriaLogs

Один з плюсів VictoriaLogs – це можливість писати алерти безпосередньо з логів. Колись писав детальніше в VictoriaLogs: створення Recording Rules з VMAlert, і є в частині FreeBSD: Home NAS, part 14 – логи з VictoriaLogs і алерти з VMAlert.

Приклад того, що написав собі – спочатку задані recording rules з exclude домашніх/робочих IP та адреси самого EC2, потім самі алерти:

groups:

  - name: aws-rtfm-nginx-access-metrics
    type: vlogs
    interval: 1m

    rules:

      - record: aws:rtfm:nginx:requests_total:rate
        expr: |
          {job="nginx", log_type="access"}
          | not (remote_addr:~"108.***.***.54|178.***.***.184")
          | stats rate() as requests_rate

      - record: aws:rtfm:nginx:requests_by_status:count
        expr: |
          {job="nginx", log_type="access"}
          | not (remote_addr:~"108.***.***.54|178.***.***.184")
          | stats by (status) count() as requests_count

      - record: aws:rtfm:nginx:requests_by_status:rate
        expr: |
          {job="nginx", log_type="access"}
          | not (remote_addr:~"108.***.***.54|178.***.***.184")
          | stats by (status) rate() as requests_rate

  - name: aws-rtfm-nginx-access-alerts

    rules:

      - alert: "NGINX: Too Many 5xx"
        expr: aws:rtfm:nginx:requests_by_status:count{status=~"5.."} > 1
        for: 1m
        labels:
          severity: warning
        annotations:
          summary: Server-side errors on rtfm.co.ua, users may be affected
          description: |-
            Domain: rtfm.co.ua
            HTTP status: {{ $labels.status }}
            Count: {{ $value }} req/min
            Grafana: https://grafana.setevoy/d/MsjffzSZz/nginx-exporter

      - alert: "NGINX: High Request Rate"
        expr: aws:rtfm:nginx:requests_total:rate > 10
        for: 2m
        labels:
          severity: warning
        annotations:
          summary: Unusual traffic spike on rtfm.co.ua
          description: |-
            Domain: rtfm.co.ua
            Rate: {{ $value }} req/sec
            Grafana: https://grafana.setevoy/d/MsjffzSZz/nginx-exporter

  - name: aws-rtfm-php-error-metrics
    type: vlogs
    interval: 1m

    rules:

      - record: aws:rtfm:php:errors_total:count
        expr: |
          {job="php-fpm", log_type="error"}
          | stats count() as errors_count

  - name: aws-rtfm-php-error-alerts

    rules:

      - alert: "PHP-FPM: Too Many Errors"
        expr: aws:rtfm:php:errors_total:count > 5
        for: 2m
        labels:
          severity: warning
        annotations:
          summary: Application errors on rtfm.co.ua
          description: |-
            Domain: rtfm.co.ua
            Count: {{ $value }} errors/min
            Grafana: https://grafana.setevoy/d/MsjffzSZz/nginx-exporter

  - name: aws-rtfm-php-fpm-alerts

    rules:

      - alert: "PHP-FPM: Slow Requests Detected"
        expr: increase(phpfpm_slow_requests{pool="rtfm.co.ua"}[5m]) > 0
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: PHP-FPM slow requests on rtfm.co.ua
          description: |-
            PHP-FPM slow requests detected during last {{ $for }}
            Domain: rtfm.co.ua
            Slow requests (last 5m): {{ $value }}
            Grafana: https://grafana.setevoy/d/MsjffzSZz/nginx-exporter

      - alert: "PHP-FPM: Pool Usage High"
        expr: phpfpm_active_processes{pool="rtfm.co.ua"} / phpfpm_total_processes{pool="rtfm.co.ua"} * 100 > 80
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: FPM Pool usage high on rtfm.co.ua
          description: |-
            FPM Pool usage over 95% during last {{ $for }}
            Domain: rtfm.co.ua
            Pool used: {{ printf "%.2f" $value }}%
            Grafana: https://grafana.setevoy/d/MsjffzSZz/nginx-exporter

Рестаримо vmalert, перевіряємо в UI:

Alertmanager та алерти в Telegram і ntfy.sh

Про те, як створити Telegram-бота і налаштувати групу для алертів писав в пості EcoFlow: моніторинг з Prometheus та Grafana, тому тут опишу тільки конфіг Alertmanager – на FreeBSD це файл /usr/local/etc/alertmanager/alertmanager.yml.

В мене три роути і три ресівери – critical алерти дублюються через ntfy.sh, алерти по самій FreeBSD та NGINX/PHP йдуть в Telegram, плюс окремий Telegram канал для алертів EcoFlow:

global:
  resolve_timeout: 5m

route:
  receiver: "ntfy"
  group_by: ["alertname, status"]
  group_wait: 10s
  group_interval: 5m
  repeat_interval: 4h

  routes:

    - matchers:
        - severity="critical"
      receiver: "ntfy"
      continue: true

    - matchers: 
        - job="ecoflow_exporter"
      receiver: "telegram_ecoflow"

    - matchers: 
        - alertname =~ ".*"
      receiver: "telegram_system"

receivers:

  - name: "ntfy"
    webhook_configs:
      - url: "https://ntfy.sh/setevoy-nas-alertmanager-alerts"
        http_config:
          authorization:
            type: Bearer
            credentials: "***"
        send_resolved: true

  - name: telegram_system
    telegram_configs:
    - bot_token: "***"
      chat_id: -100***962
      api_url: https://api.telegram.org
      parse_mode: HTML
      message: |
        {{ if eq .Status "firing" }}🔥{{ else }}✅{{ end }} <b>{{ .CommonLabels.alertname }}</b>
        {{ range .Alerts }}
        <b>Status:</b> {{ .Status | toUpper }}
        {{ if .Labels.severity }}<b>Severity:</b> {{ .Labels.severity }}{{ end }}
        {{ if .Annotations.summary }}<b>Summary:</b> {{ .Annotations.summary }}{{ end }}
        {{ if .Annotations.description }}<b>Description:</b> {{ .Annotations.description }}{{ end }}
        {{ end }}

  - name: telegram_ecoflow
    telegram_configs:
    - bot_token: "***"
      chat_id: -100***981
      api_url: https://api.telegram.org
      parse_mode: HTML
      message: |
        {{ if eq .Status "firing" }}🔥{{ else }}✅{{ end }} <b>{{ .CommonLabels.alertname }}</b>
        {{ range .Alerts }}
        <b>Status:</b> {{ .Status | toUpper }}
        {{ if .Labels.severity }}<b>Severity:</b> {{ .Labels.severity }}{{ end }}
        {{ if .Annotations.summary }}<b>Summary:</b> {{ .Annotations.summary }}{{ end }}
        {{ if .Annotations.description }}<b>Description:</b> {{ .Annotations.description }}{{ end }}
        {{ end }}

І тепер маємо алерти в Telegram:

Grafana dashboard

Вже не буду описувати весь процес створення, пізніше викладу дашборду десь в GitHub, але в мене виглядає так:

І додатково є “small version” для відображення на 14-дюймовому екрані ноутбука:

Власне, на цьому і все.

Вийшло класно, корисно, вже відловив кілька проблем і перебанив пачку ботів 🙂

Loading

Okta: налаштування Grafana SSO з OIDC та Role mapping
0 (0)

27 Березня 2026

Нарешті на поточному проекті “доросли” до використання Okta, тому зараз буде невелика серія постів по ній.

Колись про Okta вже писав, але то було 5-6 років тому, і за цей час в ній є цікаві зміни (див. тег #okta).

Що будемо робити сьогодні – це налаштовувати SSO логін через Okta для Grafana.

Колись таке робив для ArgoCD та GitHub, але з SAML (див. What is: SAML – обзор, структура и трассировка запросов на примере Jenkins и Okta SAML SSO – 2019 рік, OMG…) – тепер зробимо для Grafana, але з OIDC.

При логіні з Okta в Grafana треба автоматично визначати яку Grafana Role йому видати – звичайного Viewer, або Admin, в залежності від того, яка у юзера група в Okta. Є два варіанти того, як це можна зробити – подивимось на обидва.

В Okta є готова App Grafana Labs – але вона підтримує тільки SAML, а хочеться модно-маладьожно, з OIDC – тому створимо окрему інтеграцію.

Документація Grafana – Configure Okta OIDC authentication, і навіть цілком робоча.

Єдина проблема, яка в мене виникла – це мапінг вже існуючих в Grafana юзерів із Google SSO з юзерами із Okta – трохи довелось покопатись.

Налаштування Okta

Що треба буде зробити в Okta – це створити нову App з OIDC, взяти її ключі для налаштувань самої Grafana, а потім налаштувати мапінг Okta Groups в Grafana Roles.

Створення Okta OIDC App for Grafana

Переходимо в Applications, створюємо нову апку, вибираємо метод логіну з OIDC, в Application type вказуємо Web Application:

Далі задаємо grant types:

  • Authorization Code: Grafana зможе виконувати логін юзера
  • Refresh Token: Grafana зможе оновлювати токен юзера без необхідності його релогіну

Див. Application Grant Types та OAuth 2.0 and OpenID Connect overview.

В URLs задаємо ті ендпоінти, що вказані в документації – https://<grafana_url>/login/okta та https://<grafana_url>/logout:

Controlled access можна налаштувати пізніше через Assign – або відразу вказати групи, яким буде підключена ця App:

Зберігаємо, і отримуємо ключі для Grafana:

В документації Grafana сказано, що тут ще мають бути і URLs – але їх нема. Втім, вони дефолтні, тому ОК.

Єдиний момент тут, це якщо використовується Okta Custom Domain – але про це вже поговоримо далі, в налаштуваннях самої Grafana.

Тут закінчили, тепер цікаве – мапінг груп Okta в Grafana Roles.

Configure Okta to Grafana role mapping

Тут є два варіанти: або створити кастомні Attributes для нової App, а потім їх задавати для Okta Groups – або в самій Grafana парсити значення в role_attribute_path.

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

Потестив з обома варіантами – обидва працюють, почнемо з того, як це в документації Grafana – через кастомні атрибути.

Grafana Role на основі Okta App Profile та Custom Attributes

Ідея полягає в тому, що для Profile створеної App ми додаємо новий Attribute, в Grafana вказуємо поле, яке буде містити інформацію про Grafana Role, потім для Okta User або Okta Group задаємо власне значення цього атрибута – і воно передається до Grafana.

Далі, коли ми зробимо Assign цієї App до юзера – App візьме його дефолтні атрибути з Okta User Profile (firstName, lastName, email, login), додасть до них новий атрибут з Grafana Role – і це все разом передасть в Grafana, а далі вже справа самої Grafana – розпарсити поля і визначити Grafana Role цього юзера.

Див. The Okta User Profile and Application User Profile та Add custom attributes to apps, directories, and identity providers.

Переходимо до Directory > Profile Editor, знаходимо профайл для нової App:

Клікаємо Add Attribute:

Задаємо тип string, в Enum задаємо список ролей Grafana, в Attribute type можна вказати Group, аби менеджити ролі на рівні груп юзерів:

Далі в документації Grafana в частині Configure Groups claim говориться, що треба налаштувати передачу груп юзерів – але якщо ми передаємо роль через кастомний атрибут – то Grafana Role буде працювати і без цього.

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

Отже, додали новий Attribute – повертаємось до списку Applications:

Виконуємо Refresh:

Переходимо до Okta Group, знаходимо підключену Grafana App:

Клікаємо Edit:

Задаємо значення атрибута Grafana Role:

Трохи забігаючи наперед – ось, що ми потім побачимо при логіні в Grafana logs – груп нема, але атрибут grafana_role="Admin" переданий:

...
logger=oauth.okta t=2026-03-27T13:58:14.843471331Z level=debug msg="Received user info response" raw_json="{\"sub\":\"***\",\"name\":\"Arseny Zinchenko\",\"locale\":\"en_US\",\"email\":\"[email protected]\",\"preferred_username\":\"[email protected]\",\"given_name\":\"Arseny\",\"family_name\":\"Zinchenko\",\"zoneinfo\":\"America/Los_Angeles\",\"updated_at\":1774610676,\"email_verified\":true,\"grafana_role\":\"Admin\"}" data="unsupported value type"

Grafana Role на основі Okta Group Name

Інший варіант – не морочитись з кастомними атрибутами, а передавати групи юзера із Okta до Grafana під час логіну, і потім в самій Grafana по імені групи визначати яку ролі підключити.

Для цього в Token claims треба додати передачу групи, і це, начебто, можна зробити через Add Expression і Okta Expression Language з, наприклад, Groups.startsWith():

Groups.startsWith("OKTA", "org-DevOps", 100)

Де “OKTA” – це source групи, а “org-DevOps” – фільтр, аби передавати групу тільки тоді, коли її ім’я починається з “org-DevOps“:

Але тоді Okta свариться, що “‘groups’ is reserved and cannot be used“:

Не став морочитись, і зробив через “Show legacy configuration”:

 

Тепер при логіні в Grafana ми отримаємо поле groups з усіма групами, до який належить юзер – це приклад з логу Grafana, де це вже налаштовано:

...
"groups\":[\""orgName-All-Users\",\""orgName-DevOps\",\""orgName-All-RnD\",\""orgName-Okta-Admins\"]}
...

Все – тепер можна налаштовувати саму Grafana.

Налаштування Okta Authentication в Grafana

Переходимо в Authentication, вибираємо Okta:

Задаємо ключі та URLs, про які говорили вище.

В моєму випадку використовується Okta Custom Domain, тому адреси будуть:

  • Auth URL: https://okta.example.com/oauth2/v1/authorize
  • Token URL: https://okta.example.com/oauth2/v1/token
  • API URL: https://okta.example.com/oauth2/v1/userinfo

Або використовуємо дефолтні з https://<TENANT_ID>.okta.com:

Тепер момент по ролям: якщо роль передаємо через App Profile та Custom Attribute – то в User mapping задаємо значення поля “Role attibute path” просто як grafana_role:

Тоді Grafana прочитає значення поля і замапить “Admin” від Okta в свою локальну роль “Admin”.

Якщо ж робимо через Okta Group Name, тобто передачу групи і без App Profile та кастомних атрибутів – то в Role attibute path пишемо JMESPath expression, в якому вказуємо:

contains(groups[*], 'orgName-DevOps') && 'Admin' || 'Viewer'

Тоді, якщо поле groups юзера містить ‘orgName-DevOps‘ – то йому буде видана Grafana Role “Admin”, якщо ні – то дефолтна роль “Viewer”.

Зберігаємо, відкриваємо логін в Grafana в іншому браузері чи інкогніто mode – і внизу маємо опцію логіна з Okta:

Клікаємо, нас редиректить на Okta:

Але перша спроба сфейлилась 🙂

Втім, якщо в Grafana такого юзера ще нема – то тут вже все має працювати без проблем:

Grafana OIDC та помилка “unable to create user: user not found”

Тут трохи довелось подебажити.

Включаємо debug logs – хоча на дебаг самої помилки не вплинуло, але цікаво було глянути що Grafana взагалі отримує від Okta:

...
    grafana.ini:
      log:
        level: debug
        filters: "oauth.okta:debug authn:debug"
...

Тепер бачимо всі поля від Okta:

...
logger=oauth.okta t=2026-03-27T11:36:21.745687386Z level=debug msg="Received user info response" raw_json="{\"sub\":\"***\",\"name\":\"Arseny Zinchenko\",\"locale\":\"en_US\",\"email\":\"[email protected]\",\"preferred_username\":\"[email protected]\",\"given_name\":\"Arseny\",\"family_name\":\"Zinchenko\",\"zoneinfo\":\"America/Los_Angeles\",\"updated_at\":1774610676,\"email_verified\":true,\"groups\":[\"orgName Users [old]\",\"Everyone\",\"orgName-All-Users\",\"orgName-DevOps\",\"orgName-All-RnD\",\"orgName-Okta-Admins\"],\"grafana_role\":\"Admin\"}" data="unsupported value type"
logger=user.sync t=2026-03-27T11:36:21.751172007Z level=error msg="Failed to create user" error="user not found" auth_module=oauth_okta auth_id=***
logger=authn.service t=2026-03-27T11:36:21.751234085Z level=error msg="Failed to run post auth hook" client=auth.client.okta id= error="[user.sync.internal] unable to create user: user not found"
...

Що мене тут напрягло – що Grafana намагається “unable to create user” і каже, що “user not found“.

Проблема в тому, що в нашій Grafana вже налаштований Google SSO, і я з ним колись логінився – а тому в Grafana вже є юзер з email":"[email protected]".

Аби Grafana змогла використовувати один і той самий email для різних identity providers – додаємо в grafana.ini опцію oauth_allow_insecure_email_lookup, див. Extended authentication settings та Using the same email address to login with different identity providers:

...
    grafana.ini:
      log:
        level: debug
        filters: "oauth.okta:debug authn:debug"
      auth:
        oauth_allow_insecure_email_lookup: true
...

І тепер все працює:

Готово.

Loading

VictoriaMetrics: vmalert та query() в алерті для різних $value
0 (0)

26 Березня 2026

Просто коротка замітка, бо доволі часто треба було щось подібне зробити – і тільки сьогодні дізнався, як це круто робиться з vmalert.

Отже, іноді в алерті хочеться вивести кілька $value, наприклад:

- alert: OpenAI Budget Usage
  expr: |
    openai_budget_used_usd / openai_budget_total_usd * 100 > 80
  ...
  annotations:
    summary: OpenAI Budget Usage
    description: |-
      OpenAI budget used amount is greater than 80% of the total budget
      *Budget used percentage*: < $value from the openai_budget_used_usd >
      *Budget used amount*: < $value from the expr: openai_budget_used_usd / openai_budget_total_usd * 100 >
      *Budget total amount*: < $value from the openai_budget_total_usd >

До цього єдиний варіант, який мені придумався – це вивести $value не з expr – це використати valueFrom:

- alert: OpenAI Spending Too High Warning
  expr: (project_spending_current > project_spending_avg_3d * 1.3) > 3
  ...
  valueFrom:
    metric: project_spending_today
  annotations:
    summary: 'OpenAI Spending Too High'
    description: |-
      Current OpenAI project spending exceeds the 3-day average by 30%
      *Project*: `{{ $labels.project }}`
      *Spent today*: `{{ $value }}`

(правда, не знаю як це працювало, бо зараз ніде в документації valueFrom не можу знайти – але колись воно в мене працювало саме так)

В такому варіанті алерта – він спрацює на умову в expr: (project_spending_current > project_spending_avg_3d * 1.3) > 3, але в `*Spent today*: {{ $value }} буде значення метрики з valueFrommetric: project_spending_today.

Але vmalert має Template functions, де є функція query(), яку можемо викликати прямо з алерту, наприклад:

- alert: OpenAI Budget Usage
  expr: |
    openai_budget_used_usd / openai_budget_total_usd * 100 > 80
  ...
  annotations:
    summary: OpenAI Budget Usage
    description: |-
      OpenAI budget used amount is greater than 80% of the total budget
      *Budget used percentage*: `{{ printf "%.0f" $value }}%`
      *Budget used amount*: `{{ printf "%.0f" (query "openai_budget_used_usd" | first | value) }}` USD
      *Budget total amount*: `{{ printf "%.0f" (query "openai_budget_total_usd" | first | value) }}` USD

І тут:

  • *Budget used percentage*: `{{ printf “%.0f” $value }}%`: значення з expr: openai_budget_used_usd / openai_budget_total_usd * 100
  • *Budget used amount*: `{{ printf “%.0f” (query “openai_budget_used_usd” | first | value) }}` USD: значення з openai_budget_used_usd, де first – “взяти перше (останнє) значення метрики”
  • *Budget total amount*: `{{ printf “%.0f” (query “openai_budget_total_usd” | first | value) }}` USD: аналогічно, але з метрики openai_budget_total_usd

І в результаті маємо алерт, в якому відразу бачимо всі необхідні дані:

Готово.

“Ви ще не користуєтесь VictoriaMetrics? Тоді ми йдемо до вас!” (с)

Ну і головний висновок: RTFM! Треба частіше читати мануали, тим більш у VictoriaMetrics чудова документація.

Loading

AWS: Amazon Linux відправка пошти з Postfix через Gmail
5 (2)

16 Березня 2026

Продовження налаштування нового сервера для RTFM. Наступний крок – налаштувати можливість відправки пошти з EC2, бо тут можуть бути і важливі листи юзера root, і сам RTFM відправляє листи.

Думав робити з AWS Simple Email Service – чисто для того, аби згадати як з ним працювати, але – не такий вже він і Simple, бо верифікація домену затягнулась.

Тому забив, і зробив зі старим другом – Postfix, який відправляє пошту через налаштований relay – звичайний ящик в Gmail.

OMG… Останній раз про Postfix писав в червні 2013.

В принципі – все аналогічно тому, як описано в пості FreeBSD: налаштування DragonFly Mail Agent для пошти root:

  • Postfix у нас грає роль MTA (Mail Transfer Agent) – приймає листи від клієнтів і передає до SMTP Relay host
  • Relay: Gmail SMTP – аутентифікуємось з логіном-паролем, які задамо в Postfix, і через Gmail відправляємо листи

Приклад на Amazon Linux версії AL2023, але те саме рішення можна для будь-якої системи.

Встановлюємо Postfix:

[root@ip-10-0-1-79 ~]# dnf install postfix cyrus-sasl-plain mailx

cyrus-sasl-plain вже має бути в системі, але про всяк випадок вказуємо і його, а mailx – зручний MUA (Mail User Agent), для тестів або для використання в скриптах.

Запускаємо:

[root@ip-10-0-1-79 ~]# systemctl enable --now postfix
Created symlink /etc/systemd/system/multi-user.target.wants/postfix.service → /usr/lib/systemd/system/postfix.service.

Для аутентифікації в Gmail краще створити окремий пароль – процес описував в Створення Google Mail App Passwords.

Налаштовуємо аутентифікацію Postfix в Gmail – файл /etc/postfix/sasl_passwd, формат:

[smtp.gmail.com]:587 [email protected]:apppassword

З postmap генеруємо sasl_passwd.db, бо Postfix не використовує файл /etc/postfix/sasl_passwd напряму:

[root@ip-10-0-1-79 ~]# postmap /etc/postfix/sasl_passwd
[root@ip-10-0-1-79 ~]# chmod 600 /etc/postfix/sasl_passwd /etc/postfix/sasl_passwd.db

І в самому Postfix /etc/postfix/main.cf додаємо:

  • relayhost: куди передаємо пошту для відправки, в нашому випадку Gmail SMTP
  • smtp_sasl_auth_enable: вмикаємо SMTP аутентифікацію (логін/пароль) з Simple Authentication and Security Layer (SASL)
  • smtp_sasl_password_maps: шлях до файлу з credentials до Gmail
  • smtp_sasl_security_options = noanonymous: забороняємо анонімну аутентифікацію
  • smtp_tls_security_level = encrypt: обов’язковий TLS
  • smtp_tls_CAfile: CA сертифікати для перевірки Gmail SMTP
  • inet_protocols = ipv4: якщо не налаштовували IPv6 для VPC – то дозволяємо тільки IPv4

Робимо з postconf, бо деякі параметри вже є в конфігу – postconf замінить існуючі, а не додасть дублі:

[root@ip-10-0-1-79 ~]# postconf -e "relayhost = [smtp.gmail.com]:587"
[root@ip-10-0-1-79 ~]# postconf -e "smtp_sasl_auth_enable = yes"
[root@ip-10-0-1-79 ~]# postconf -e "smtp_sasl_password_maps = hash:/etc/postfix/sasl_passwd"
[root@ip-10-0-1-79 ~]# postconf -e "smtp_sasl_security_options = noanonymous"
[root@ip-10-0-1-79 ~]# postconf -e "smtp_tls_security_level = encrypt"
[root@ip-10-0-1-79 ~]# postconf -e "smtp_tls_CAfile = /etc/ssl/certs/ca-bundle.crt"

Рестартимо, перевіряємо логи:

[root@ip-10-0-1-79 ~]# systemctl restart postfix
[root@ip-10-0-1-79 ~]# journalctl -f -u postfix.service

Змінюємо адресу юзера root – редагуємо /etc/aliases:

...
# Basic system aliases -- these MUST be present.
mailer-daemon:  postmaster
postmaster:     root

# add mailbox for the root user
root: [email protected]

...

Оновлюємо базу:

[root@ip-10-0-1-79 ~]# newaliases

І перевіряємо відправку – в одній консолі запускаємо journalctl -f -u postfix.service, в іншій з mailx відправляємо листа до root:

[root@ip-10-0-1-79 ~]# echo "test body" | mailx -s "test postfix" root

Бачимо відправку в логах:

[...] postfix/qmgr[176329]: BB15174EFD: from=<[email protected]>, size=714, nrcpt=1 (queue active)
[...] postfix/smtp[176331]: BB15174EFD: to=<[email protected]>, orig_to=<root>, relay=smtp.gmail.com[172.253.116.109]:587, delay=333, delays=332/0.03/0.32/0.59, dsn=2.0.0, status=sent
[...] postfix/qmgr[176329]: BB15174EFD: removed

І маємо листа в своєму ящику:

Готово.

Коли вже дописував, то нагуглив документацію AWS Integrating Amazon SES with Postfix – аналогічно до того, що ми робили вище, тільки з використанням SMTP AWS SES.

Loading

AWS: збільшення розміру EBS на EC2 з Linux
0 (0)

16 Березня 2026

Кожного разу згадую як це робиться, хоча вже десь писав, але давно: треба руками збільшити розмір диску в AWS EC2.

Звикаєш до Kubernetes, де для цього достатньо просто змінити значення в PersistentVolumeClaim, а коли треба зробити руками – починаєш шукати документацію, тому накидаю таку замітку тут.

Інтересу ради пошукав старі записи, знайшов, як це робилось 10 років тому – пост AWS: увеличение размера диска EBS від  30/04/2015: треба було зупиняти EC2, створювати снапшот диску, потім з цього снапшоту створювати новий EBS з новим розміром, потім цей EBS підключати до EC2, потім запускати EC2… Жесть.

Зараз все набагато простіше і, головне – без необхідності зупиняти інстанс:

  • збільшуємо розмір диска з AWS CLI та modify-volume або методом clickops прямо в AWS Console
  • в операційній системі оновлюємо partition table – задаємо новий розмір розділу
  • збільшуємо файлову систему
  • profit!

AWS: Modify EBS volume

Є EC2 з одним root EBS, який треба збільшити:

Вибираємо Modify volume:

Заодно можна додати IOPS, бо дик активно використовується:

Задаємо новий розмір та Throughput:

AWS нагадує, що далі треба буде вносити зміни в саму файлову систему:

Чекаємо завершення змін:

Коли статус стає optimizing або completed – переходимо до файлової системи.

Linux: розширення partition та файлової системи

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

root@ip-10-0-6-162:~# lsblk /dev/nvme0n1
NAME         MAJ:MIN RM  SIZE RO TYPE MOUNTPOINTS
nvme0n1      259:0    0  100G  0 disk 
├─nvme0n1p1  259:1    0   49G  0 part /
├─nvme0n1p14 259:2    0    4M  0 part 
├─nvme0n1p15 259:3    0  106M  0 part /boot/efi
└─nvme0n1p16 259:4    0  913M  0 part /boot

Диск /dev/nvme0n1 – доступно 100 гігабайт, але розділ nvme0n1p1 має розмір ~50 гігабайт.

Можна подивитись детальніше з опцією --output, до якої передаємо список колонок, які хочемо відобразити:

root@ip-10-0-6-162:~# lsblk -o NAME,SIZE,FSSIZE,FSUSED,FSAVAIL,FSUSE%,MOUNTPOINT /dev/nvme0n1
NAME          SIZE FSSIZE FSUSED FSAVAIL FSUSE% MOUNTPOIN
nvme0n1       100G                              
├─nvme0n1p1    49G  47.4G  13.7G   33.7G    29% /
├─nvme0n1p14    4M                              
├─nvme0n1p15  106M 104.3M   6.1M   98.2M     6% /boot/efi
└─nvme0n1p16  913M 880.4M   159M  659.8M    18% /boot

Тут маємо:

  • SIZE: розмір самого диску, block device – nvme0n1, 100 G
  • FSSIZE: на ньому маємо partition з номером 1 – nvme0n1p1, у якого filesystem size – 50G (бачимо 47.4G, бо ext4 резервує частину блоків під метадані та системний резерв ~5%)

Далі нам треба виконати:

  • growpart: розширити запис розділу nvme0n1p1 в partition table до кінця диску
  • resize2fs: розширити саму файлову систему до розміру nvme0n1p1

Виконуємо:

root@ip-10-0-6-162:~# growpart /dev/nvme0n1 1
CHANGED: partition=1 start=2099200 old: size=102758367 end=104857566 new: size=207615967 end=209715166

Тепер розділ розділу 100G, але FS все ще 50G:

root@ip-10-0-6-162:~# lsblk -o NAME,SIZE,FSSIZE,FSUSED,FSAVAIL,FSUSE%,MOUNTPOINT /dev/nvme0n1p1
NAME      SIZE FSSIZE FSUSED FSAVAIL FSUSE% M
nvme0n1p1  99G  47.4G  13.7G   33.7G    29% /

Збільшуємо саму файлову систему:

root@ip-10-0-6-162:~# resize2fs /dev/nvme0n1p1
resize2fs 1.47.0 (5-Feb-2023)
Filesystem at /dev/nvme0n1p1 is mounted on /; on-line resizing required
old_desc_blocks = 7, new_desc_blocks = 13
The filesystem on /dev/nvme0n1p1 is now 25951995 (4k) blocks long.

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

root@ip-10-0-6-162:~# lsblk -o NAME,SIZE,FSSIZE,FSUSED,FSAVAIL,FSUSE%,MOUNTPOINT /dev/nvme0n1p1
NAME      SIZE FSSIZE FSUSED FSAVAIL FSUSE% M
nvme0n1p1  99G  95.8G  13.7G   82.1G    14% /

Тепер SIZE 99G та FSSIZE 95.8G.

Готово.

Loading

AWS: ALB та Cloudflare – налаштування mTLS та AWS Security Rules
5 (2)

15 Березня 2026

Під час підготовки інфраструктури для міграції RTFM з серверу в DigitalOcean до AWS (див. AWS: сетап базової інфраструктури для WordPress) вирішив заодно спробувати AWS ALB – ALB mutual authentication (мені чомусь здавалось, що цю фічу запустили на останньому re:Invent, в кінці 2024, але вона є з кінця 2023 року – див. Mutual authentication for Application Load Balancer reliably verifies certificate-based client identities).

Ідея полягає в тому, щоб дозволити підключення до ALB тільки тим клієнтам, які пройдуть аутентифікацію, і в випадку Cloudflare+AWS ALB – Cloudflare має підписувати всі свої запити з TLS-сертифікатом (див. Cloudflare Authenticated Origin Pulls), а AWS ALB буде їх перевіряти – таким чином доступ до Load Balancer URL буде можливий тільки для Cloudflare.

Надихнула мене на цю ідею нещодавня історія з TCP/IP: SYN flood атака на сервер RTFM, та “Hacker News hug of death” – коли TCP-конекти відкривались напряму на порт 443 на сервері в DigitalOcean, а не йшли через Cloudflare.

Правда, окрім mTLS я пізніше все одно вирішив додати ще і обмеження по IP – налаштував доступ до ALB тільки для адрес Cloudflare, які задані в AWS Security Rules, бо mTLS – це всеж про аутентифікацію, а Security Rules – це вже саме захист на рівні мережі.

Хоча спочатку думав робити чисто mTLS – бо і цікаво було подивитись, як це в ALB працює – і лінь було робити автоматизацію для оновлення AWS Security Rules.

Приклад тут робився на тестовому домені – тому заодно додамо його на Cloudflare, але обидва рішення вже працюють і для самого rtfm.co.ua.

Для чого, власне, обмежувати доступ до AWS Application Load Balancer:

  • на Cloudflare є багато Security rules, які блокують небажаний трафік, якщо ж запити йдуть напряму на ALB – то вони дойдуть до NGINX, і тоді частину правил треба робити і там (в мене так і було)
  • вартість при DDoS: поки блог жив на сервері в DigitalOcean, де просто був Public IP для дроплету, то запити до цього IP на вартість не впливали (тільки додатковий трафік), але в AWS та ALB кількість підключень впливає на вартість

P.S. І знов наче намагаєшся писати стисло – а вийшло багато тексту 🙁

Вартість AWS ALB та LCU

Вартість AWS Load Balancer включає в себе погодинну оплату за сам інстанс, стандартну плату Data Transfer Charge, за виділені Public IPs, та окремо – Load Balancer Capacity Units (LCU).

Для Network Load Balancer є власний юніт – Network Load Balancer Capacity Unit (NLCU), а для Gateway Load Balancer, відповідно, GLCU.

Див. Elastic Load Balancing pricing.

LCU не дуже очевидний юніт, тому кілька слів про нього.

1 LCU – це:

  • New connections: нові підключення до ALB – 25 підключень в секунду “з’їдають” 1 LCU
  • Active connection: кожні 3000 активних підключень (або 1500 при використанні mTLS) на хвилину
  • Processed bytes: 1 LCU покриває 1 GB трафіку на годину (або 0.4 GB для Lambda targets) – рахується сума ingress + egress
  • Rule evaluations: перевірка 1.000 правил в секунду – це один LCU

Під Rule evaluations маються на увазі Listener rules:

І якщо маємо багато правил по типу “IF path is /api/* THEN forward to target-group-api” – платимо за кожну перевірку кожного нового HTTP request.

При визначені того, з якого саме параметра вище рахувати LCU, AWS бере той, який перший досяг потрібного значення: тобто, як тільки маємо 26 запитів на секунду, але при цьому Processed bytes був лише 10 мегабайт – то нам порахуються 1 витрачений LCU за рахунок New connections.

Отже, у випадку DDoS (а у RTFM кілька раз траплялись, хоча і не сильні) – ми легко можемо “попасти на гроші”.

Є калькулятор Load Balancer Capacity Unit Reservation Calculator – можна прикинути, скільки LCU буде коштувати навантаження.

AWS Load Balancer mTLS use cases

Насправді якщо використовувати обмеження доступу до ALB через Security Group зі списком тільки дозволених IP – то mTLS не треба взагалі, бо ми вже “ріжемо” підключення на рівні мережі AWS, ще до того, як запит взагалі дійде до самого Load Balancer.

Що дійсно може бути корисним – це:

  • аутентифікація між сервісами:
    • наприклад, у нас є Internal ALB, за яким живуть різні сервіси моніторингу, такі як VictoriaLogs
    • в логах може бути чутлива інформація типу токенів або навіть паролів, тому до цього ендпоінта треба мати обмежений доступ
    • vmauth з парольною аутентифікацію – це добре, але mTLS додасть ще один рівень захисту (див. VictoriaMetrics: VMAuth – проксі, аутентифікація та авторизація)
  • аутентифікація мобільних клієнтів на API:
    • в клієнтах “зашиті” сертифікати (certificate pinning), які вони використовують для доступу до API
    • але тут треба мати на увазі можливі проблеми з ротацією сертифікатів – або у випадку компрометації, або просто коли закінчується строк дії
  • IoT: у нас (поки що) нема, але в цілому – дуже корисний варіант

Налаштування ALB mutual TLS з Cloudflare

Спершу подивимось варіант з обмеженням доступу з mTLS, бо штука цікава і корисна.

А потім додамо вже з AWS Security Rules та автоматизацією оновлень правил в ній.

Налаштування Cloudflare

Пост писався ще до міграції RTFM, робив на тестовому домені, тому швиденько пройдемось по процесу додавання і налаштування домену в Cloudflare.

Додавання нового домену до Cloudflare

Переходимо в Domains, клікаємо Onboard a domain:

Вказуємо ім’я, решту налаштувань можна з дефолтними значеннями:

При імпорті записів з AWS DNS пропустило кілька записів по Let’s Encrypt validation, але зараз це ОК:

Отримуємо список нових Name Servers:

Міняємо у реєстратора домену:

Чекаємо на оновлення – час залежить від адміністратора доменної зони:

Для kiev.ua (в мене вже є і setevoy.kyiv.ua) це зайняло з півгодини:

Переходимо в SSL/TLS, включаємо опцію Authenticated Origin Pulls.

Як тільки ми включимо цю опцію, Cloudflre при кожному новому запиті до origin – в нашому випадку AWS ALB – почне додавати свій клієнтський сертифікат.

Тут дуже хотілось трохи детальніше описати за SSL/TLS handshake та ключі-сертифікати – але подумав, що в рамках цього посту це буде зайвим, тим більш, що колись розбирався з деталями в пості What is: SSL/TLS в деталях.

Хоча, може, і зроблю новий пост на цю тему, тим більш зараз вже TLS 1.3, а там описаний актуальний на той момент TLS 1.2.

Або можна почитати вже згаданий на початку Introducing mTLS for Application Load Balancer – там непогано описано базові концепції по самому TLS.

Отже, Cloudflre тільки передає клієнтський сертифікат, а далі вже справа origin – що з ним робити, і наступним кроком ми як раз і налаштуємо його перевірку на самому ALB.

Поки просто вмикаємо опцію – грошей вона не просить (окрім того, що вартість 1 LCU по Active connections буде 1500 замість 3000), значного навантаження на ALB чи додаткового трафіку не зробить:

Для домену додаємо запис CNAME з value == ALB URL:

Перевіряємо, що зараз все працює – бо хоч Authenticated Origin Pulls на Cloudflare ми включили, але на ALB ще ніяких перевірок не виконується:

І запит на URL самого ALB все ще працює, тільки з помилкою SSL:

Налаштування Application Load Balancer

Тепер нам треба налаштувати перевірку клієнтського сертифіката від Cloudflare на нашому ALB.

Отримання Cloudflare Certificate Authority

ALB для перевірки буде використовувати публічний Certificate Authority сертифікат від Cloudflare, який нам треба додати в Trust Store самого Load Balancer, а потім підключити його до ALB Listener.

Майте на увазі, що використання Trust Store теж платне – “$0.0056 per hour per Trust Store Associated with Application Load Balancer when using Mutual TLS“, тобто ~ 4 USD на місяць.

Завантажуємо сертифікат:

$ curl -o cloudflare-origin-pull-ca.pem https://developers.cloudflare.com/ssl/static/authenticated_origin_pull_ca.pem

Сертифікат загальний для всього Cloudflare – хоча є можливість додати свій власний і проходити перевірку тільки з ним.

Але для випадку, коли нам просто треба закрити доступ всяким китайським ботам – сертифіката самого Cloudflare вистачить.

Перевіряємо скачаний файл:

$ file cloudflare-origin-pull-ca.pem
cloudflare-origin-pull-ca.pem: PEM certificate

Або можна глянути шо в ньому:

$ openssl x509 -in cloudflare-origin-pull-ca.pem -text -noout
Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number: 6310029703491235425 (0x5791ba9556c22e61)
        Signature Algorithm: sha512WithRSAEncryption
        Issuer: C=US, O=CloudFlare, Inc., OU=Origin Pull, L=San Francisco, ST=California, CN=origin-pull.cloudflare.net
        Validity
            Not Before: Oct 10 18:45:00 2019 GMT
            Not After : Nov  1 17:00:00 2029 GMT
        Subject: C=US, O=CloudFlare, Inc., OU=Origin Pull, L=San Francisco, ST=California, CN=origin-pull.cloudflare.net
...

З важливого тут – Not After : Nov 1 17:00:00 2029:

  • можна в налаштуваннях ALB для mTLS включити опцію “Allow expired client certificates”
  • або просто в кінці 2029 року додати в Trust Store новий сертифікат та видалити старий

Створення Trust Store

Для додавання сертифіката до ALB нам треба буде створити Trust Store, але в Trust Store додати сертифікат можна тільки з AWS S3 – тому спочатку завантажуємо в якусь свою корзину:

Переходимо да EC2 > Load Balancing > Trust Stores, створюємо новий:

Вказуємо шлях до файлу сертифіката в S3:

Налаштування mTLS для ALB Listener

Редагуємо HTTPS Listener – бо mTLS, логічно, відбувається на ньому:

Включаємо опцію mTLS, і маємо дві опції:

  • Passthrough: передаємо сертифікат на таргети в Target Group – валідацію виконує бекенд
  • Verify with trust store: виконуємо перевірку на самого ALB з сертифікатом із Trust Store – наш випадок

Переключаємо на “Verify with trust store”:

Тепер пробуємо пряме підключення до ALB URL:

$ curl -kI https://rtfm-alb-1984146384.eu-west-1.elb.amazonaws.com
curl: (56) Recv failure: Connection reset by peer

І в браузері:

Але через Cloudflare все працює:

$ curl -I https://test-tls.setevoy.kiev.ua
HTTP/2 200

 

Ну і сам rtfm.co.ua вже теж на цьому ALB і має включений mTLS.

Захист з AWS Security Group та Cloudflare IP ranges

Але тільки mTLS для повноцінного захисту ALB все ж недостатньо:

  1. при DDoS ми все-одно будемо витрачати LCU на підключення
  2. запити, які пройдуть не через Cloudflare треба фільтрувати на бекенді – в моєму випадку NGINX

Тому самий надійний спосіб – це обмежити трафік на рівні AWS Security Group, яка підключена до Load Balancer: тоді ніяких LCU не буде витрачатись, бо пакети до ALB не дійдуть взагалі.

Є готові Terraform-модулі, наприклад – cloudflare-security-group, але він працює тільки з одною Security Group, є більш просунутий варіант – cloudflare-sg-updater, який шукає SG по тегам.

Втім, особисто я не дуже люблю AWS Lambda і, принаймні поки що, для RTFM не використовую Terraform – то зробив просто з shell-скриптом, який використовує AWS CLI.

Як раз до цього було багато практики, аби згадати як писати скрипти – див. FreeBSD: Home NAS, part 15: автоматизація бекапів – скрипти, rsync, rclone.

AWS CLI на Amazon Linux вже йде в комплекті, треба тільки або налаштувати AWS Profile – або підключити EC2 Instance Profile з IAM-політикою, яка дасть дозвіл на внесення змін в Security Group.

Єдиний нюанс, який треба мати на увазі, якщо пишете скрипт самі: IP іноді змінюються, хоч і рідко, і їх треба видаляти з Security Group. Але при цьому просто видалити всі старі записи, а потім додати нові – не варіант, бо в цей момент ALB втратить підключення до Cloudflare.

Тому в скрипті виконується порівняння старих і нових адрес, і видаляються тільки ті, яких нема в останньому апдейті від Cloudflare.

Створення IAM Role для EC2 Instance profile

Рано чи пізно все одно треба буде додавати IAM Role для інстансу, тому замість використання AWS CLI профайл з ключами краще відразу робити доступ до редагування Security Group через EC2 Instance profile.

Для ролі потрібно дати права на виконання ec2:DescribeSecurityGroups, ec2:AuthorizeSecurityGroupIngress та ec2:RevokeSecurityGroupIngress – створимо окрему IAM Policy.

Створюємо нову Security Group для ALB – правила Inbound залишаємо пустими, в Outbound – залишаємо дефолтний “All to All”:

Створюємо нову IAM Policy, обмежуємо доступ тільки цією Security Group.

Для ec2:DescribeSecurityGroups задати обмеження не можна, бо правило глобальне для всіх SG, тому описуємо двома окремими Statement – один з  Resource": "*", другий вже з конкретною SG.

Робимо з обмеженням по Resource: вказуємо обмеження доступу конкретною групою, бо скрипт буде виконувати destructive дії – видалення правил, і для таких дій на випадок “щось пішло не так” завжди краще обмежувати blust radius:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": "ec2:DescribeSecurityGroups",
            "Resource": "*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "ec2:AuthorizeSecurityGroupIngress",
                "ec2:RevokeSecurityGroupIngress"
            ],
            "Resource": "arn:aws:ec2:eu-west-1:ACCOUNT_ID:security-group/sg-029ffc5f56be700ea"
        }
    ]
}

 

Задаємо ім’я політики, зберігаємо:

Створюємо IAM Role, в Use Case вибираємо EC2:

Підключаємо політику, яку створили вище:

Задаємо ім’я нової ролі, зберігаємо (хоча ім’я тут вибрав не найкраще – бо 100% якісь ще доступу в цю роль додаватись будуть):

В Security > Modify IAM Role підключаємо роль до EC2 – можна робити без зупинки інстансу:

Підключаємо нашу нову роль:

Підключаємось по SSH до інстансу, перевіряємо, що в нього тепер є доступ до, наприклад, ec2:DescribeSecurityGroups:

[ec2-user@ip-10-0-3-146 ~]$ aws ec2 describe-security-groups --group-ids sg-029ffc5f56be700ea
{
    "SecurityGroups": [
        {
            "GroupId": "sg-029ffc5f56be700ea",
            "IpPermissionsEgress": [
                {
                    "IpProtocol": "-1",
                    "UserIdGroupPairs": [],
                    "IpRanges": [
                        {
                            "CidrIp": "0.0.0.0/0"
                        }
                    ],
                    "Ipv6Ranges": [],
                    "PrefixListIds": []
                }
            ],
...

Shell script для оновлення ALB Security Group

Додаємо скрипт, який буде:

  1. отримувати список актуальних Cloudflare IP CIDR (і IPv4, і IPv6)
  2. з AWS CLI отримувати список поточних правил в Security Group
  3. порівнювати нові адреси і значення з Security Group і додавати тільки ті адреси, яких зараз нема
  4. і аналогічна перевірка для видалення адрес з Security Group – якщо IP нема в актуальному списку від Cloudflare, то видаляємо

Security Group одна, тому її просто задаємо на початку в змінну $SG_ID:

#!/bin/bash

# Update ALB security group with current Cloudflare IP ranges
SG_ID="sg-029ffc5f56be700ea"
PORT=443

# Fetch current Cloudflare IP ranges
CF_IPS=$(curl -s https://www.cloudflare.com/ips-v4; echo; curl -s https://www.cloudflare.com/ips-v6)

# Get current IPs from security group
CURRENT_IPS=$(aws ec2 describe-security-groups --group-ids $SG_ID --query "SecurityGroups[0].IpPermissions[?FromPort==\`$PORT\`].[IpRanges[].CidrIp, Ipv6Ranges[].CidrIpv6]" --output text)

# Add missing IPs
for IP in $CF_IPS; do
    if ! echo "$CURRENT_IPS" | grep -q "$IP"; then
        echo "Adding $IP"
        if echo "$IP" | grep -q ":"; then
            aws ec2 authorize-security-group-ingress --group-id $SG_ID --ip-permissions "[{\"IpProtocol\":\"tcp\",\"FromPort\":$PORT,\"ToPort\":$PORT,\"Ipv6Ranges\":[{\"CidrIpv6\":\"$IP\"}]}]"
        else
            aws ec2 authorize-security-group-ingress --group-id $SG_ID --protocol tcp --port $PORT --cidr $IP
        fi
    fi
done

# Remove IPs no longer in Cloudflare list
for IP in $CURRENT_IPS; do
    if ! echo "$CF_IPS" | grep -q "$IP"; then
        echo "Removing $IP"
        if echo "$IP" | grep -q ":"; then
            aws ec2 revoke-security-group-ingress --group-id $SG_ID --ip-permissions "[{\"IpProtocol\":\"tcp\",\"FromPort\":$PORT,\"ToPort\":$PORT,\"Ipv6Ranges\":[{\"CidrIpv6\":\"$IP\"}]}]"
        else
            aws ec2 revoke-security-group-ingress --group-id $SG_ID --protocol tcp --port $PORT --cidr $IP
        fi
    fi
done

echo "Done"

Задаємо права на запуск скрипта:

[root@ip-10-0-3-146 ~]# chmod +x /opt/update-alb-sg/update-alb-sg-from-cloudflare.sh

Запускаємо вручну для перевірки:

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

Заміна Security Group на ALB

Редагуємо список Security Groups для Load Balancer:

Додаємо нову, видаляємо стару:

Перевіряємо, що все працює:

$ curl -I https://rtfm.co.ua/
HTTP/2 200 

Але з дому напряму до ALB тепер доступу нема:

$ curl --connect-timeout 5 -kI rtfm-alb-1984146384.eu-west-1.elb.amazonaws.com
curl: (28) Connection timed out after 5002 milliseconds

Додаємо в cron.

Запуск з crontab

В Amazon Linux версії AL2023 стандартний cron випиляли – встановлюємо його:

# yum install -y cronie
# systemctl enable crond
# systemctl start crond

Запускаємо, наприклад, раз на добу в 3 ночі:

0 3 * * * /opt/update-alb-sg/update-alb-sg-from-cloudflare.sh >> /var/log/update-cf-sg.log 2>&1

Можна ще підписатись на Cloudflare changelog/RSS, аби отримувати нотифікації коли щось міняється в IP діапазонах.

Але зміна IP ranges прям дуже рідко буває – останній раз у 2023 році, див IP Ranges:

Власне, на цьому все.

Тепер можна спокійно спати: “frontend” у нас Cloudflare, де є Security rules, WAF і захист від DoS/DDoS, а на “бекенді” ми захищені на рівні мережі, L3/L4 – і Load Balancer та EC2 живуть спокійно.

Див. мій пост по TCP/IP: моделі OSI та TCP/IP, TCP-пакети, Linux sockets і порти та документацію самого AWS – Network security.

P.S. Документація AWS – одна з тих речей, за що його дійсно люблю. Колись довелось багато працювати з Azure – і там це дійсно велика проблема. До речі, див. Azure: почему никогда.

Loading

AWS: власний EC2 в ролі NAT Gateway замість AWS Managed NAT Gateway
5 (1)

12 Березня 2026

Подивився я на costs по інфраструктурі, яка описана в попередньому пості AWS: сетап базової інфраструктури для WordPress, і тяжко зітхнув:

Один NAT Gateway – це чверть витрат на AWS, і навіть маючи AWS Credits мене трошки душить жаба.

Є, звісно, варіант прибрати NAT Gateway зі схеми взагалі:

  • можна просто перевести EC2 в Public Subnet: насправді, описаний сетап з використанням AWS Load Balancer дійсно трохи overkill для проекту типу якогось невеликого блогу – але мені все ж хочеться мати “красиву” інфрастуктуру, побудовану “по канонам” та більш наближену до AWS Well-Architected Framework (свідомо без Reliability – тільки одна Availability Zone, та без infrastructure as code – див. AWS Well-Architected Framework Design Principles)
  • можна прибрати NAT Gateway взагалі – але залишити EC2 в Private Subnet:
    • по факту, в описаному сетапі NAT GW використовується тільки на самому початку, коли встановлюються пакети nginx, php та скачується архів з WordPress
    • але потім, по-перше – треба встановлювати апгрейди із зовнішніх репозиторіїв, по-друге – сам WordPress може ходити в інтернет при запуску своїх cron jobs або робити виклики з плагінів (цікавий приклад є в пості TCP/IP: SYN flood атака на сервер RTFM, та “Hacker News hug of death”, де плагін Page View Count постійно робив запити з серверу RTFM до Cloudflare)

Але є і третій варіант – це власний підняти “poor man’s NAT gateway“: просто запустити окремий мінімальний t3.nano EC2, на ньому мати Linux чи FreeBSD, а там налаштувати звичайний NAT.

Бо по суті AWS VPC можна розглядати по аналогії з домашньою мережею, де в ролі NAT Gateway виступає роутер – TP-Link, MikroTik тощо, і цей роутер можна замінити на будь-який ноутбук або ПК з Linux/FreeBSD та налаштувати там NAT з блекджеком і моніторингом.

В коментах до цього поста підсказали про проект fck-nat – вже готовий до використання AWS AMI, до того ж для fck-nat є Terraform модуль terraform-aws-fck-nat.

UPD: Картина в Costs Explorer через кілька днів:

  • AWS Managed NAT Gateway прибрано взагалі
  • додано t2.nano в ролі self-managed EC2 NAT Gateway
  • EC2 блога замінено з t3.medium на t3.small

Результат: 8-9 березня загальна вартість була рівно $5.00, 13-го стало $3.26.

Плюси-мінуси

Звісно, для якогось production сетапу якогось реального проекту простіше і – головне – надійніше мати стандартний AWS Managed NAT Gateway: zero геморою з апгрейдами, zero проблем з availability – за все відповідає AWS.

Якщо ж мати власний NAT Gateway – то це додатковий час на встановлення апгрейдів, моніторинг, ну і якщо такий інстанс впаде – то інстанси в приватних сабнетах залишаться без доступу в Internet.

Ну і якщо робити автоматизацію – то описати з Terraform один чи кілька NAT Gateways набагато простіше, ніж описувати налаштування окремого EC2 – фактично, достатньо просто вказати один параметр у VPC module resource.

Але якщо подивитись на вартість…

А шо по грошам? 

Давайте порівняємо вартість AWS Managed NAT Gateway та власного NAT на t3.nano.

Порівнювати будемо і вартість самого інстансу, і трафіку, бо все рахується окремо.

NAT Gateway vs t3.nano: per-hour pricing

Перше, і саме відчутне – це погодинна оплата:

  • AWS Managed NAT Gateway: $0.048/година – це ~$32 в місяць
  • AWS EC2 t3.nano: $0.0059/год – це ~$4.3 на місяць

t3.nano в ролі NAT Gateway для якогось невеликого проекту – з головою: всі операції виконуються в ядрі системи, навантаження на CPU/RAM мінімальне. Єдине. на що варто звертати увагу, це network bandwidth – але для t3.nano маємо до 5 Gbps burst – з запасом, див. Amazon EC2 instance network bandwidth.

NAT Gateway vs t3.nano: per-gigabyte pricing

Тут є нюанс з тим, за що саме ми платимо при використанні AWS Managed NAT Gateway, див. Amazon VPC pricing:

  1. NAT Gateway Data Processing Charge: 1 GB data went through the NAT gateway. The Data Processing charge will result in a charge of $0.045.
    • тобто кожен переданий гігабайт, незалежно від напрямку, ми платимо $0.045
  2. Data Transfer Charge: This is the standard EC2 Data Transfer charge for internet traffic […] With the Data Transfer Out to Internet rate set at $0.09 per GB […]
    • і на додачу до NAT Gateway Data Processing – ми ще сплачуємо $0.09 за кожен гігабайт, яки віддали в інтернет

Отже, кожен переданий з VPC в інтернет через AWS Managed NAT Gateway гігабайт нам коштує $0.135.

Тоді як при використанні AWS EC2 instance в ролі NAT Gateway ми сплачуємо тільки Data Transfer Charge $0.09 per GB.

План дій

Отже, що будемо робити:

  • запустимо EC2 в публічній мережі
  • Security Group:
    • дозволяємо тільки out трафік – на вхід з інтернету нічого не має приходити
    • дозволяємо весь ingress з Private Subnets – наш EC2 буде передавати дані назовні
    • дозволяємо SSH з VPC або користуємось EC2 Instance Connect
  • на EC2 маємо якийсь Linux, на якому з iptables налаштовуємо NAT
  • в Route Tables для VPC і Private Subnets налаштуємо default route – 0.0.0.0/0 через Private IP цього EC2

Вибір операційної системи – в принципі, будь-який Linux, тут будемо робити з Amazon Linux, бо дійсно менше геморою ніж з Debian.

Хоча спочатку взагалі думав про FreeBSD – бо FreeBSD традиційно сильна саме в networking, але як вже основний сервер для самого блогу на Amazon Linux, то не буду розводити зоопарк.

На Amazon Linux 2023 по-дефолту встановлений nftables – але iptables теж є як wrapper, встановлюється з репозиторію окремим пакетом.

Створення NAT Gateway Security Group

Переходимо в VPC, додаємо нову групу:

Створення EC2

Задаємо ім’я, вибираємо операційну систему, тут буде Amazon Linux:

Вибираємо тип інстансу, вибираємо або створюємо ключ для SSH:

В Network settings вибираємо VPC, в Subnet вказуємо той, в якому “main EC2” з блогом – аби уникнути cross Availability Zone traffic (див. Overview of Data Transfer Costs for Common Architectures), та включаємо Auto-assign public IP – бо NAT Gateway повинен мати Public IP, на який буде отримувати пакети від клієнтів в інтернеті:

Тут все – запускаємо створення інстансу.

Після старту робимо важливу зміну в його налаштуваннях – треба виключити перевірку Source/Destination check: вона вказує EC2 приймати і відправляти тільки той трафік, де source або destination співпадає з його власним IP.

Для NAT це треба вимкнути, бо NAT instance по визначенню пересилає чужий трафік, де ні source, ні destination не є його власним IP:

Задаємо Stop:

Налаштування Linux в ролі NAT Gateway

Підключаємось до EC2 (EC2 Instance Connect робили в попередньому пості):

[setevoy@setevoy-work ~]  $ aws --region eu-west-1 --profile setevoy ec2-instance-connect ssh --instance-id i-0e54f36ce6bb90da4 --connection-type eice
...
[ec2-user@ip-10-0-1-79 ~]$

Перевіряємо параметр net.ipv4.ip_forward, який задає ядру системи дозвіл на пересилання пакетів між мережевими інтерфейсами – саме це і є основою будь-якого NAT і роутингу:

[ec2-user@ip-10-0-1-79 ~]$ sysctl net.ipv4.ip_forward
net.ipv4.ip_forward = 0

0 – відключений, вмикаємо його:

[ec2-user@ip-10-0-1-79 ~]$ sudo sysctl -w net.ipv4.ip_forward=1
net.ipv4.ip_forward = 1

Зміни з sysctl -w застосовуються зараз, але після перезавантаження системи зникнуть, тому додаємо в конфіг /etc/sysctl.conf:

[ec2-user@ip-10-0-1-79 ~]$ echo "net.ipv4.ip_forward = 1" | sudo tee -a /etc/sysctl.conf
net.ipv4.ip_forward = 1

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

[ec2-user@ip-10-0-1-79 ~]$ sudo sysctl -p
net.ipv4.ip_forward = 1

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

[ec2-user@ip-10-0-1-79 ~]$ sysctl net.ipv4.ip_forward
net.ipv4.ip_forward = 1

Налаштування iptables та MASQUERADE

Можна зробити з nftables, але iptables частіше зустрічається, та і більш звично.

Детальніше про iptables писав ще у 2014 році (OMG!), див. Linux: IPTABLES – руководство: часть 1 – основы IPTABLES – кардинальних змін в основах не було, хоча сам iptables вже поступово замінюється на nftables.

Перевіряємо імена інтерфейсів:

[ec2-user@ip-10-0-1-79 ~]$ ip a s
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
...
2: enX0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 9001 qdisc fq_codel state UP group default qlen 1000
...

Встановлюємо пакет iptables:

[ec2-user@ip-10-0-1-79 ~]$ sudo dnf install -y iptables

Додаємо правило:

[ec2-user@ip-10-0-1-79 ~]$ sudo iptables -t nat -A POSTROUTING -o enX0 -j MASQUERADE

Тут:

  • -t nat: вносимо зміни в таблицю nat
  • -A POSTROUTING: додаємо правило в POSTROUTING chain – тобто правило спрацьовує після того, як ядро вже вирішило куди відправити пакет – але ще не відправило отримувачу
  • -o enX0: правило тільки для пакетів що виходять через інтерфейс enX0 (наш публічний інтерфейс)
  • -j MASQUERADE: підміняємо source IP пакету (Private IP інстансу EC2, на якому буде блог) на IP інтерфейсу enX0 – публічний IP нашого інстансу EC2 з NAT Gateway

Перевіряємо правила:

[ec2-user@ip-10-0-1-79 ~]$ sudo iptables -t nat -L POSTROUTING -n -v
Chain POSTROUTING (policy ACCEPT 0 packets, 0 bytes)
 pkts bytes target     prot opt in     out     source               destination
    0     0 MASQUERADE  all  --  *      enX0    0.0.0.0/0            0.0.0.0/0

Аби правило зберігалось при рестартах інстансу – додаємо (вже має бути) пакет iptables-services, зберігаємо правила у файл /etc/sysconfig/iptables:

[ec2-user@ip-10-0-1-79 ~]$ sudo dnf install -y iptables-services

[ec2-user@ip-10-0-1-79 ~]$ sudo service iptables save

[ec2-user@ip-10-0-1-79 ~]$ sudo systemctl enable iptables

Зміни в AWS VPC Route Tables

У нас є окремі таблиці маршрутизації на кожну Private Subnet:

В яких зараз маршрут до інтернету, тобто 0.0.0.0/0, заданий через AWS Managed NAT Gateway:

Додаємо нове правило, вказуємо новий EC2 інстанс:

Видаляємо маршрут через NAT gateway – тут можливий короткий downtime, бо активні підключення із VPC в інтернет будуть розірвані:

Повторюємо зміни для обох Route Tables, і перевіряємо.

Підключаємось на EC2, дивимось результат ifconfig.me – і отримуємо Public IP нашого нового self-managed NAT Gateway:

[ec2-user@ip-10-0-3-146 ~]$ curl -s ifconfig.me
108.130.182.54

Власне, на цьому і все.

Можна видаляти AWS Managed NAT Gateway:

Bonus: Amazon Linux auto-upgrades

Ну і аби не ходити на інстанс з ручними апгрейдами – налаштуємо аналог unattended-upgrades з Debian.

В RHEL/CentOS/Fedora/Amazon Linux це пакет dnf-automatic:

[ec2-user@ip-10-0-1-79 ~]$ sudo dnf install -y dnf-automatic

Редагуємо конфіг /etc/dnf/automatic.conf, мінімально – задаємо apply_updates = yes.

В upgrade_type можна задати security, а не встановлювати всі – бо це все ж gateway:

...
# default                            = all available upgrades
# security                           = only the security upgrades
upgrade_type = security

...

apply_updates = yes
...

[email]
# TODO in the following rtfm.co.ua posts about setting up AWS

Про відправку email з AWS EC2 інстансів може буду писати окремо в частині по налаштуванню AWS Simple Email Service.

Додаємо запуск по крону за розкладом:

[ec2-user@ip-10-0-1-79 ~]$ sudo systemctl enable --now dnf-automatic.timer
Created symlink /etc/systemd/system/timers.target.wants/dnf-automatic.timer → /usr/lib/systemd/system/dnf-automatic.timer.

Готово.

Loading