# Розгортання NetPulse Один хост, `docker compose`, автоматичний TLS. Такого розгортання вистачає до кількох тисяч хостів на моніторингу; розносити служби по машинах має сенс тоді, коли впирається БД, а не застосунок. ## Що з чого складається | Служба | Роль | Порт | | ----------- | ------------------------------------------------ | ----- | | `db` | PostgreSQL 16 + TimescaleDB — усі дані | — | | `cache` | DragonflyDB — черги й тимчасові стани | — | | `migrate` | накочування схеми; відпрацьовує і зупиняється | — | | `api` | HTTP API + вшитий інтерфейс | 8080 | | `collector` | gRPC-колектор, до якого підключаються зонди | 9443 | | `proxy` | Caddy: сертифікати, HTTPS, проксі до двох служб | 80/443/9443 | Історія конфігів живе в Git на томі `git-data`, спільному для `api` й `collector`: перший читає її для порівняння версій, другий пише під час бекапу. Назовні дивиться лише `proxy`. `api` і `collector` портів не публікують: до них ходять через нього. ## Перший запуск ```sh git clone <репозиторій> netpulse && cd netpulse cp deploy/.env.example .env ``` Заповнити `.env`. Три значення обовʼязкові й генеруються так: ```sh openssl rand -base64 24 # POSTGRES_PASSWORD echo "np1=$(openssl rand -hex 32)" # NETPULSE_DEK openssl rand -base64 48 # NETPULSE_JWT_SECRET ``` Далі: ```sh docker compose up -d --build docker compose run --rm --entrypoint netpulse-user cli \ -tenant default -login admin -role owner -name "Адміністратор" ``` Пароль команда спитає інтерактивно — щоб він не осів в історії оболонки й у списку процесів. Інтерфейс — на `https://`. ## Пісочниця: повна установка, яку можна перевірити Твердження «нова інсталяція піднімається сама» довго було доведене міркуванням, а не запуском. Сухий прогін проходив, окремі кроки перевірялись на живій базі — а повної установки з нуля не робив ніхто, бо ніде: єдина доступна машина була бойовим стендом на 4 ГБ, і другий повний стек поклав би робочу систему. `./netpulse sandbox` ставить NetPulse **по-справжньому**, з тим самим `docker-compose.yml`, тими самими міграціями й тією самою самоперевіркою, але в окремому проєкті `compose` з власними томами й портами на `127.0.0.1`. Це не імітація: вона або справді піднімає стек і заходить у нього справжнім паролем, або зупиняється й каже, на чому. ```sh ./netpulse sandbox # підняти й лишити, щоб подивитись ./netpulse sandbox once # підняти, перевірити, знести все ./netpulse sandbox upgrade # другий режим: оновлення з версії на версію ./netpulse sandbox check # та сама перевірка ще раз ./netpulse sandbox status # що вона зараз займає ./netpulse sandbox down # знести все (обидва режими), включно з томами ./netpulse sandbox logs [служба] # журнали ``` Режимів два, і вони перевіряють різне: `sandbox` — установку з нуля, `sandbox upgrade` — шлях «стояла стара версія, стала нова». Другий описано нижче окремим розділом. **Розробникові перед випуском** — `./netpulse sandbox once`. Він робить повну установку з нуля, проганяє всі твердження самоперевірки (вхід справжнім паролем, кабінет назвався, сім переліків із міграцій непорожні, дванадцять ендпоїнтів відповідають, зонд зареєструвався в колекторі) і прибирає за собою до останнього тому. Ненульовий код виходу означає, що цю збірку клієнтові віддавати не можна. **Клієнтові — щоб подивитись до того, як ставити** — `./netpulse sandbox` без підкоманди. Після установки вона друкує адресу `https://localhost:<порт>`, логін і пароль; система жива, у ній є кабінет, локальний зонд і всі довідники. Подивились — `./netpulse sandbox down`, і на машині не лишається нічого. ### Скільки це коштує | Що | Скільки | | -------------- | ------------------------------------------------------------ | | Пам'ять у роботі | ~550–900 МБ на всі шість служб | | Стеля пам'яті | ~2.05 ГБ — жорстке обмеження з накладки, вище не підніметься | | Пік при збірці | ще ~1–2 ГБ, поки збираються образи Go | | Диск: томи | ~250 МБ (порожня база зі схемою, внутрішній CA Caddy, посвідчення зонда) | | Диск: образи | ~2 ГБ, **спільні** з бойовою інсталяцією — якщо вони вже зібрані, пісочниця не додає нічого | | Порти | 18080, 18081, 18082 (або наступна вільна трійка), усі на `127.0.0.1` | Точні цифри після запуску показує `./netpulse sandbox status` — вони виміряні, а не оцінені. ### Коли її запускати НЕ можна Установник перевіряє це сам і **відмовляється**, а не пробує: - **вільно менше 3 ГБ пам'яті** (4 ГБ, якщо образи ще треба зібрати). Міряється `MemAvailable`, а не вся пам'ять: на машині, де вже працює бойовий стек, «4 ГБ встановлено» не має жодного стосунку до того, скільки з них можна взяти; - **вільно менше 3 ГБ диска** (8 ГБ без готових образів); - **на машині працює бойова інсталяція NetPulse.** Пісочниця не зіпсує їй ані даних, ані портів — вона в іншому проєкті `compose`. Але пам'ять і диск у них спільні, і два Postgres не вміщуються там, де ледве вміщується один. Обійти можна прапорцем `--alongside`, і це свідоме рішення, а не формальність; - **docker compose старший за 2.24.4** — див. нижче; - **пісочниця вже стоїть.** Друга поверх першої поділила б із нею томи, і прогін «з нуля» перестав би бути прогоном з нуля. Правильне місце для пісочниці — машина розробника або окрема віртуалка. ### Що буде при обриві Пісочниця, яка лишила по собі том на 250 МБ і контейнер, що тримає порт, — це та сама шкода, від якої вона мала захистити. Тому: - `Ctrl-C`, `SIGTERM`, `SIGHUP` перехоплюються і прибирають усе; - будь-яка зупинка установки (`ЗУПИНКА на кроці …`) теж прибирає все; - у накладці стоїть `restart: "no"` — забута пісочниця **не воскресає** після перезавантаження хоста; - прибирання йде трьома ешелонами: `compose down -v`, потім пряме `docker rm`/`docker volume rm` за міткою проєкту (ловить те, що лишилось від обірваного `up`), потім видалення `.env.sandbox`; - `--keep` лишає уламки для розбору журналів — але тільки при **невдачі установки**, не при сигналі: перерваний прогін лишає по собі не стенд, а половину стенду. Чого перехопити неможливо: `kill -9` і зникнення живлення. Саме тому джерелом правди про залишки є не файл-позначка, а сам docker — і `./netpulse install` та `./netpulse check` при кожному запуску кажуть, якщо на машині висить забута пісочниця. ### Чого пісочниця НЕ доводить Це найважливіший абзац розділу. Зелена перевірка доводить тільки те, що вона перевіряє — цей проєкт уже платив за протилежне припущення. - **Let's Encrypt і DNS.** Пісочниця стоїть на `localhost` із самопідписаним сертифікатом і навмисно не читає `netpulse.conf`: справжній `DOMAIN` звідти відправив би її по сертифікат для адреси, яка веде на бойовий стенд, і витрачені спроби списались би з тижневої квоти домену. - **Прийом SNMP-трапів і правило `DOCKER-USER`.** Порт 162/udp назовні не виставляється взагалі: він не має автентифікації, і відкривати його заради перевірки означало б купити перевірку ціною дірки. - **Розрахунок `shared_buffers` із пам'яті хоста.** У пісочниці він заданий числом — це умова того, щоб вона нічого не поклала. - **Поведінка під навантаженням.** База порожня, хостів на моніторингу немає, історії немає. ### Чому потрібен compose 2.24.4 Списки `ports` при накладанні compose-файлів **додаються**, а не замінюються. Щоб зсунуті порти пісочниці не стали *додатком* до базових 80/443/9443, накладка перевизначає їх тегом `!override`, а він з'явився у docker compose 2.24.4. На старішій версії пісочниця відмовляється працювати — бо мовчки зайняти порти бойового проксі гірше, ніж не запуститись. Вимога знімається одним рядком у `docker-compose.yml`: якщо зробити базові порти змінними — ```yaml ports: - "${NETPULSE_BIND:-0.0.0.0}:${NETPULSE_PORT_HTTP:-80}:80" - "${NETPULSE_BIND:-0.0.0.0}:${NETPULSE_PORT_HTTPS:-443}:443" - "${NETPULSE_BIND:-0.0.0.0}:${NETPULSE_PORT_GRPC:-9443}:9443" ``` — то пісочниці вистачить власного `.env.sandbox`, накладка портів стане непотрібною, а мінімальна версія compose лишиться 2.0, як у решті установника. ## Пісочниця, режим другий: оновлення з версії на версію Установку з нуля клієнт робить один раз, а оновлення — щоразу. І ламається воно частіше: міграції котяться не на порожню базу, а на ту, де вже лежать чужі дані; конфігурація змінюється; зонд у мережі клієнта лишається старим, а колектор стає новим. Досі цього не перевіряло нічого, тобто перевіряв перший клієнт. ```sh ./netpulse sandbox upgrade # попередня версія обереться сама ./netpulse sandbox upgrade --from REF # попередня версія — цей коміт або тег ./netpulse sandbox upgrade --keep # лишити стенд після прогону ``` Прогін іде п'ятьма етапами, і кожен друкує свою назву — падіння називає етап, а не лише крок: | Етап | Що відбувається | | ---- | --------------- | | **0** | вибір попередньої версії й розгортання її дерева | | **А** | попередня версія ставиться **з нуля** тим самим `install` і проходить ту саму самоперевірку | | **Б** | у базу наливаються локації, 24 хости, доба ICMP і метрик із кроком 5 хв, конфіги NCM, тригер і алерти; знімаються кількості «до» | | **В** | дамп, збірка нової версії, **міграції поверх наявних даних**, перезапуск служб, перевірка старого зонда проти нового колектора, оновлення зонда | | **Г** | та сама `selfcheck`, що й після чистої установки | | **Д** | кількості «після» проти «до», по рядках | За замовчуванням стенд зноситься разом із томами: це ворота випуску, а не стенд для розглядання. `--keep` лишає все на місці — і тоді прибрати **обов'язково**: `./netpulse sandbox down`. ### Що таке «попередня версія», якщо версіонування ще немає Питання не риторичне: від відповіді залежить, чого вартий прогін. Тегів немає, реєстру немає, образ завжди `netpulse/server:dev`. **Обрано коміт git.** Коміт — це повне дерево: Go-код, міграції, `docker-compose.yml`, `Caddyfile`, `Dockerfile`. З нього збираються справжні старі образи, тобто попередня версія тут не описана, а **виконується**. Це відтворювано: той самий ref дасть той самий стенд і за півроку. І це не тимчасове рішення. Тег у git — теж ref, тому в день першого тегу тут не зміниться жодного рядка: `--from v0.1.0` запрацює сам. Чому не інакше: * **Тег.** Найправильніше — і неможливе сьогодні: перевірка оновлення є вхідним квитком *до* першого тегу (ROADMAP, Етап 13). Вимагати тег означало б вимагати те, заради чого вона й пишеться. * **Збережений дамп бази.** Найдешевше й доводить найменше. Дамп — це схема з даними, але не бінарники: він не запускає старий колектор і старий зонд, тобто не бачить двох названих класів поломки з трьох. Гірше інше: дамп старіє мовчки. Його зробила версія, яку вже ніхто не збере, і коли прогін почервоніє, розрізнити «зламався код» і «протух дамп» буде нічим. Типовий ref обчислюється, а не вписаний числом: береться найновіший коміт, у якого міграцій **менше**, ніж у HEAD. Причина та сама, що й у решті установника: зелений прогін, який нічого не перевірив, гірший за відсутність прогону. Оновлення без жодної нової міграції доводить лише те, що служби перезапустились, — і мовчки видається за доказ, що міграції котяться поверх даних. Якщо в парі версій нових міграцій немає, прогін про це кричить — і в момент вибору, і в підсумку. **Перед випуском беріть `--from` явно** — із тією версією, з якої клієнти оновлюватимуться насправді. Автоматичний вибір — це «найближча попередня», а не «та, що справді стоїть». ### Ізоляція: чому це не може зачепити нічого чужого | Що | Як розведено | | -- | ------------ | | Проєкт compose | `netpulse-sandbox-upgrade` — окремі контейнери, мережа й **томи** | | Порти | від **18300** на `127.0.0.1`; режим установки з нуля перебирає 18080…18272 і сюди не дістає | | `.env` | власний `.env.sandbox-upgrade`; бойовий `.env` не читається й не пишеться | | `netpulse.conf` | не читається взагалі (див. `read_conf`) | | Образи | власні теги `netpulse/*:sandbox-prev` і `:sandbox-new`; спільний тег `:dev` не чіпається жодного разу | | Файрвол хоста | правило `DOCKER-USER` не додається: `TRAPS_FROM` порожній | | Репозиторій | дерево розгортається через `git archive` — він **лише читає**; ані `worktree`, ані `checkout` | Окремі теги образів — не дрібниця. Без них обидві версії називались би `netpulse/server:dev`, друга перетерла б першу, а обірваний прогін лишив би цей тег указувати на **стару** збірку — на очах у бойової інсталяції, яка ділить із пісочницею реєстр образів. Дерево попередньої версії лежить у `.sandbox-prev/` поруч із проєктом (не в `/tmp`: там часто tmpfs, і другий примірник вихідних текстів ліг би в оперативну пам'ять). З контексту збірки його виключає `.dockerignore`. `./netpulse sandbox down` прибирає **обидва** режими одразу — контейнери, томи, мережу, `.env`, дерево й власні теги образів. Пам'ятати, у якому режимі стенд піднімали, не треба. ### Що саме звіряється в кінці Дві різні вимоги, тому й два правила: * **`exact`** — рядки, які налила сама пісочниця (хости `sb-sw-*`, їхні проби, ряди метрик, конфіги, тригер, алерти). Їхня кількість не має права змінитись **ані в який бік**: поменшало — оновлення знищило, побільшало — роздвоїло. Обидва однаково погані й обидва беззвучні. * **`min`** — усе інше (кабінети, користувачі, довідники, аудит). Тут дозволено лише рости: система під час оновлення жива. Кожен `exact`-запит звужено до рядків пісочниці, бо локальний зонд весь цей час пише свою телеметрію. Алерти рахуються по `alr.alerts` **і** `alr.alerts_history` одразу: фоновий такт переносить погашені в історію, і рядок, що переїхав, — це не втрачений рядок. ### Чого цей режим НЕ доводить * **Гілку «наявна інсталяція» в міграторі.** База пісочниці народжується чистою, тому `public.netpulse_install.fresh` назавжди `true`, і мігратор іде гілкою «видати паролі ролям». Перехід стенду, зробленого до 0063 (`deploy/RLS-EXISTING-INSTALL.md`), лишається неперевіреним. * **Зміни в самому установнику між версіями.** Обидва етапи веде один і той самий `./netpulse` — вимірювальний прилад має бути тим самим на обох кінцях вимірювання, інакше різниця в приладі читається як різниця у виробі. Під перевіркою тут те, що установник ставить, а не він сам. * **Оновлення через кілька версій підряд.** Перевіряється рівно один стрибок: з обраного ref у HEAD. * Усе те, чого не доводить і перший режим: Let's Encrypt і DNS, трапи на 162/udp, поведінка під навантаженням. ## Підключення зонда В інтерфейсі: **Зонди → Додати зонд**. Видане запрошення (`np_enr_…`) одноразове — після реєстрації воно згоряє. На машині, де стоятиме зонд: ```sh docker run -d --name netpulse-agent --restart unless-stopped \ --cap-add NET_RAW \ -v netpulse-agent:/var/lib/netpulse \ netpulse/agent:dev \ -server netpulse.example.com:9443 \ -enroll np_enr_… \ -name "Зонд у Львові" \ -modules icmp,snmp,topology,ncm ``` Зонд обміняє запрошення на постійний токен і збереже його в `/var/lib/netpulse/agent.json` (права 0600). Наступні запуски токена вже не потребують — том із посвідченням має пережити перестворення контейнера, інакше кожен старт вимагатиме нового запрошення. Усі зʼєднання зонда вихідні: у мережі клієнта не треба відкривати жодного порту. ## Бекап Три речі, і всі три обовʼязкові: 1. **База** — усе, крім секретів у відкритому вигляді. 2. **`NETPULSE_DEK`** — без нього паролі SSH і SNMP-community з бекапу не розшифрувати. У БД лежить лише шифротекст. 3. **`NETPULSE_JWT_SECRET`** — без нього після відновлення всі активні сесії відваляться. Не смертельно, але користувачі помітять. ```sh docker compose exec -T db \ pg_dump -U netpulse -d netpulse -Fc --no-owner \ > netpulse-$(date +%F).dump ``` Формат `-Fc` (custom), а не простий SQL: він стискається і дозволяє відновлювати вибірково. Ключі зберігати **окремо від дампа** — інакше сенс шифрування секретів зникає: той, хто дістав бекап, дістав і ключ до нього. Том `git-data` бекапити не обовʼязково: тіла конфігів лежать зашифрованими в базі, і репозиторій повністю відтворюється з неї — ```sh docker compose run --rm --entrypoint netpulse-gitsync api ``` Зворотне невірно: з репозиторію базу не відновити. Тому джерелом істини лишається дамп, а Git — похідне сховище, яке коштує один запуск команди. ### Автоматично, щодня ```cron 15 3 * * * cd /opt/netpulse && docker compose exec -T db pg_dump -U netpulse -d netpulse -Fc --no-owner > /var/backups/netpulse-$(date +\%F).dump && find /var/backups -name 'netpulse-*.dump' -mtime +30 -delete ``` ## Відновлення TimescaleDB вимагає рамки навколо відновлення: без неї фонові процеси агрегації втручаються в наливання даних і дамп лягає пошкодженим. ```sh docker compose stop api collector docker compose exec -T db psql -U netpulse -d postgres -c \ 'DROP DATABASE IF EXISTS netpulse; CREATE DATABASE netpulse;' docker compose exec -T db psql -U netpulse -d netpulse -c \ 'CREATE EXTENSION IF NOT EXISTS timescaledb;' docker compose exec -T db psql -U netpulse -d netpulse -c \ 'SELECT timescaledb_pre_restore();' docker compose exec -T db pg_restore -U netpulse -d netpulse --no-owner \ < netpulse-2026-08-25.dump docker compose exec -T db psql -U netpulse -d netpulse -c \ 'SELECT timescaledb_post_restore();' docker compose up -d api collector ``` У `.env` має лежати **той самий** `NETPULSE_DEK`, що й на момент дампа. Інакше застосунок підніметься, але кожна спроба скористатись збереженим паролем поверне помилку розшифрування — і виглядатиме це як зламані креденшели, а не як втрачений ключ. Перевірка після відновлення: ```sh docker compose exec -T db psql -U netpulse -d netpulse -c \ 'SELECT count(*) FROM core.devices;' curl -sf https:///healthz && echo OK ``` ## Оновлення ```sh git pull docker compose up -d --build ``` `migrate` відпрацює першим і не дасть піднятись API, якщо схема не накотилась. Міграції йдуть по одній у транзакції; уже застосований файл зі зміненою контрольною сумою зупиняє весь запуск — це захист від мовчазного розходження схеми з кодом. Відкат схеми не передбачений: зворотні міграції на даних телеметрії коштують дорожче, ніж відновлення з бекапу. Перед тим як віддавати оновлення клієнтові, цей самий шлях треба прогнати в ізоляції: `./netpulse sandbox upgrade --from <версія, що стоїть у клієнта>`. Він ставить стару версію з нуля, наливає в неї дані, оновлює й окремо звіряє, що дані пережили. Розділ «Пісочниця, режим другий» вище. ## Ізоляція кабінетів (RLS) **Нова інсталяція вже під політиками — робити нічого не треба.** `netpulse-migrate` на чистій базі сам видає паролі ролям `netpulse_app` і `netpulse_worker`, і застосунок з першої секунди ходить роллю без `BYPASSRLS`. Ізоляція тримається на двох незалежних рубежах: політика RLS у базі й предикат `tenant_id` у кожному запиті коду. Другий потрібен окремо, бо на гіпертаблицях RLS не працює взагалі — TimescaleDB не поєднує його зі стисненням, а туди йде вся телеметрія. **Інсталяціям, старшим за 0063,** застосунок і далі ходить роллю `netpulse` — тобто суперкористувачем, який політики обходить, — і другий рубіж вмикається окремою оборотною процедурою: **deploy/RLS-EXISTING-INSTALL.md**. Вона не змінює даних. Робити її разом з оновленням версії не варто: ламатись у них різне, і розбирати доведеться одночасно. Свій випадок видно одним запитом: ```sh docker compose exec -T db psql -U netpulse -d netpulse -c "SELECT fresh FROM public.netpulse_install" ``` ## Зміна ключа шифрування Ключі перелічуються через кому, новий — першим: ``` NETPULSE_DEK=np2=<новий hex>,np1=<старий hex> ``` Нові секрети шифруються першим ключем, старі читаються своїм. Прибирати старий ключ можна лише після того, як усі секрети перезаписані. ## Чому саме так **Два образи, а не пʼять.** `api`, `collector`, `migrate`, `netpulse-user` і `netpulse-secret` — з одного модуля, з половиною спільного коду. Один образ гарантує, що API і колектор ходять у схему БД однією версією; окремі образи дають їм можливість розʼїхатись саме там, де це найдорожче. Зонд — окремо: він їде в чужу мережу, і DSN, ключі шифрування та команди заведення користувачів не повинні бути в тому образі навіть як невикористані файли. **Міграції окремою службою.** API піднімається в кількох примірниках; накочування схеми зі старту означало б гонку між ними. **TLS на проксі, а не в застосунку.** Прострочений сертифікат на системі, яка сама має повідомляти про проблеми, — найгірший спосіб дізнатись про проблему. Caddy оновлює його сам. ## Без домену Домен потрібен для справжнього сертифіката: Let's Encrypt не видає їх на IP-адреси. Поки домену немає, лишіть `ACME_EMAIL` порожнім — Caddy випише самопідписаний, і система працюватиме одразу, з попередженням у браузері. Коли домен зʼявиться: замінити `NETPULSE_DOMAIN`, вписати `ACME_EMAIL` і `docker compose restart proxy`. Caddyfile змонтований, тож перезбирати образи не треба — але й `up -d` без `restart` його не перечитає. ## Дашборд на телевізор В інтерфейсі: **Дашборд → На телевізор → Видати посилання**. Отриману адресу відкривають на екрані в диспетчерській — вона не потребує входу. Телевізор нікуди не залогиниш: сесія протермінується, браузер оновиться, і зранку на стіні висітиме форма входу замість карти мережі — рівно тоді, коли на неї дивляться. Що дає посилання і чого не дає: - **тільки читання** цього дашборда: його плитки, активні алерти й метрики тих хостів, які на ньому показані; - метрики чужого хоста за ним не дістати навіть підбором ідентифікатора; - решта кабінету — інвентар, конфіги, налаштування, секрети — недоступна; - відкликається одним рухом, старе посилання одразу мертве. Токен показується один раз. Видати нове можна будь-коли — попереднє при цьому перестає працювати.