Дві сесії роботи: 0058–0068, розгортання однією командою, тести
Один коміт, а не десяток тематичних, свідомо: теми переплетені в
спільних файлах (store.go, docker-compose.yml, deploy/README.md), і
розділити їх можна було б лише індексуванням шматків. Коміти, які не
збираються, гірші за один великий — тим паче що це рівно той стан, який
перевірявся разом.
ЩО ПРАЦЮЄ НА СТЕНДІ Й ПЕРЕВІРЕНО ТАМ
0058 подієві алерти: syslog, ncm, compliance спрацьовують у мить
події; правило з нереалізованим джерелом більше не зберігається
мовчки
0059 snmp.walk і прототипи шаблонів — таблиці з динамічним індексом
описуються шаблоном, а не Go
0060 відкат конфігу: план як різниця, маскування паролів із підписом
плану, обов'язковий контрольний збір, verifying при обриві
0061 кнопки Telegram: довге опитування, авторизація не з callback_data
0062 аудит і архів хостів; тест на AST, що падає на ключі без назви
0063 RLS: три ролі, окремий пул для фонових тактів
0064 строки зберігання даних і сторінка сховища
0065 приймач SNMP-трапів; перевірено справжніми пакетами по дроту,
переклад v1→v2 за RFC 3584 дає правильний OID
0066 ескалації сповіщень
0067 алерт про вичерпання диска
0068 поля заливки конфігу переїхали в каталог профілів
Плюс: 137 тестів вебу з нуля (їх не було взагалі), одинадцять справжніх
вад, знайдених ними й виправлених, і виправлення двох інтеграційних
тестів grpcapi, які мовчки пропускались півтора року.
ЩО ЩЕ НЕ ЗАПУСКАЛОСЬ
netpulse установник: одна команда замість 18 змінних і
593 рядків інструкції
RLS з першого запуску нова інсталяція під політиками одразу;
RLS-EXISTING-INSTALL.md лишається тільки для
старих інсталяцій
.forgejo + CI раннер не зареєстрований
Ці три перевірені компіляцією й міркуванням, але не виконанням.
ГОЛОВНИЙ ВИСНОВОК ДВОХ СЕСІЙ
Зелена перевірка доводить рівно те, що вона перевіряє. Тест ізоляції RLS
був правильний і зелений — і пропустив зламаний вхід, бо перевіряв «чи
не видно чужого», коли зламалось «чи видно своє». Інтеграційні тести
grpcapi були зелені, бо не виконувались. Схема, довідник і протокол
описували те, чого в коді не існувало, і виглядало це як готове.
Тому в кожному завданні цих сесій стояла вимога назвати НЕПОКРИТЕ, а
чотири задачі закінчились не можливістю, а відмовою: правило з
нереалізованим джерелом не зберігається, профіль без команд заливки
каже про це замість мовчазної кнопки, міграція RLS валить сама себе на
таблиці без політики, тест словника аудиту падає на ключі без назви.
Подробиці — HISTORY.md, розділи за 26 і 27 серпня.
This commit is contained in:
parent
fe4ed4e7d9
commit
ed8fc831bf
315 changed files with 78587 additions and 1904 deletions
|
|
@ -1,8 +1,34 @@
|
|||
# Складання і перевірки NetPulse на Forgejo Actions.
|
||||
#
|
||||
# Три роботи паралельно, а не одна послідовна: фронтенд, сервер і зонд
|
||||
# ламаються незалежно, і чекати збірки Go заради помилки типізації в
|
||||
# TypeScript — марно витрачений час на кожному пуші.
|
||||
# Проганяється рівно те, що досі проганяли руками (`sh scripts/check.sh`),
|
||||
# плюс три речі, яких скрипт зробити не може: інтеграційні тести проти
|
||||
# живої бази, звірка каталогу профілів зі згенерованою міграцією і
|
||||
# перевірка закінчень рядків.
|
||||
#
|
||||
# ЧОМУ ЦЕ ВАЖЛИВІШЕ ЗА САМІ ТЕСТИ. Інтеграційні тести grpcapi мовчки
|
||||
# пропускались півтора року: без NETPULSE_TEST_DSN вони роблять t.Skip,
|
||||
# а `go test ./...` рахує пропуск успіхом. Коли їх нарешті прогнали
|
||||
# проти справжньої бази, два впали одразу — вони описували світ до появи
|
||||
# розпізнавання пристроїв. Тест, який не виконується, не є перевіркою;
|
||||
# він є її виглядом. Тому в роботі server стоїть окремий крок, який
|
||||
# ПАДАЄ, якщо інтеграційні тести пропустились: без нього цей workflow
|
||||
# повторив би ту саму помилку — зелений на тестах, яких не було.
|
||||
#
|
||||
# ЧОМУ ТУТ НЕМАЄ ЖОДНОЇ JS-ДІЇ (actions/checkout тощо). Раннер виконує
|
||||
# JS-дії тим node, який знайде В ОБРАЗІ роботи. У golang:1.25-alpine
|
||||
# node немає, у node:22-alpine немає git. Ставити ubuntu-подібні образи
|
||||
# заради checkout — це +2 ГБ на диску, якого на цій машині ~7 ГБ усього.
|
||||
# Тому викачування зроблено звичайним `git fetch` у кроці `run`. Побічна
|
||||
# вигода: CI не залежить ані від доступу до реєстру дій, ані від того,
|
||||
# що в цьому реєстрі колись поміняють.
|
||||
#
|
||||
# ЧОМУ shell: sh. Усі образи — alpine, bash у них немає, а act за
|
||||
# замовчуванням кличе саме bash. `sh -e` до того ж зупиняє крок на
|
||||
# першій невдалій команді, тож перевірки не проїжджають повз.
|
||||
#
|
||||
# ЯК ПІДНЯТИ РАННЕР: deploy/docker-compose.ci.yml (там же реєстрація).
|
||||
# Без раннера цей файл не виконується взагалі — саме в такому стані він
|
||||
# і прожив увесь час свого існування.
|
||||
|
||||
name: CI
|
||||
|
||||
|
|
@ -11,40 +37,183 @@ on:
|
|||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
GO_VERSION: "1.25"
|
||||
NODE_VERSION: "22"
|
||||
# Раннер на цій машині один і з місткістю 1: черга з пʼяти прогонів на
|
||||
# одну гілку не дає нічого, крім затримки відповіді на останній пуш.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: sh
|
||||
|
||||
jobs:
|
||||
# ------------------------------------------------------------------
|
||||
# Гігієна репозиторію. Найдешевша робота й найшвидший сигнал: образ
|
||||
# 8 МБ, увесь прогін — секунди. Навмисно без `needs` у решти робіт:
|
||||
# коли зламано і схему, і фронтенд, корисно побачити обидва за один
|
||||
# прогін, а не за два.
|
||||
# ------------------------------------------------------------------
|
||||
hygiene:
|
||||
runs-on: docker
|
||||
timeout-minutes: 10
|
||||
container:
|
||||
image: alpine:3.21
|
||||
steps:
|
||||
- name: Інструменти
|
||||
run: apk add --no-cache git python3
|
||||
|
||||
- name: Викачування
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
# set -e явно, а не в надії на прапорці оболонки раннера:
|
||||
# якщо git тихо не викачає дерево, наступні кроки побачать
|
||||
# порожній каталог, gofmt не знайде жодного файлу, а робота
|
||||
# позеленіє. Порожня перевірка виглядає так само, як успішна.
|
||||
set -e
|
||||
git config --global --add safe.directory '*'
|
||||
git init -q .
|
||||
git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git"
|
||||
# Токен у заголовку, а не в URL: інакше він осідає в
|
||||
# .git/config назавжди і виїжджає в кожен `git remote -v`.
|
||||
auth=$(printf 'x-access-token:%s' "$TOKEN" | base64 | tr -d '\n')
|
||||
git config http.extraheader "Authorization: basic $auth"
|
||||
# Не всі інсталяції дозволяють тягнути голий sha, тому запасний
|
||||
# варіант — гілка або refs/pull/N/merge з GITHUB_REF.
|
||||
git fetch --depth=1 origin "$GITHUB_SHA" 2>/dev/null \
|
||||
|| git fetch --depth=1 origin "$GITHUB_REF"
|
||||
git checkout -q FETCH_HEAD
|
||||
# Доказ, що дерево справді на місці, а не «команди не впали».
|
||||
[ -f scripts/check.sh ] || { echo "робоче дерево порожнє"; exit 1; }
|
||||
git log -1 --format='%h %s'
|
||||
|
||||
# --strict, бо у свіжому клоні на Linux CRLF у робочій копії бути
|
||||
# не може за побудовою: якщо він там є — зламаний сам checkout.
|
||||
# Чому ця перевірка взагалі існує — у шапці scripts/check-eol.sh:
|
||||
# за один день CRLF і показав чужі файли неформатованими для
|
||||
# gofmt, і змінив sha256 згенерованої міграції так, що сторож
|
||||
# netpulse-migrate зупинив би накат на стенді.
|
||||
- name: Закінчення рядків
|
||||
run: sh scripts/check-eol.sh --strict
|
||||
|
||||
# Каталог профілів — джерело істини, міграція 0014 з нього
|
||||
# породжується. Розійтися вони можуть тихо: людина править каталог
|
||||
# і забуває перезапустити генератор. Таке розходження знайшлось
|
||||
# сьогодні — випадково.
|
||||
- name: Каталог профілів
|
||||
run: python3 db/profiles/build.py --check
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Фронтенд. Окремою роботою, бо ламається незалежно від Go: чекати
|
||||
# збірки сервера заради помилки типізації в TypeScript — марно.
|
||||
# ------------------------------------------------------------------
|
||||
web:
|
||||
runs-on: docker
|
||||
timeout-minutes: 25
|
||||
container:
|
||||
image: node:22-alpine
|
||||
# Кеш npm — іменований том, а не actions/cache. На одній машині
|
||||
# том просто лежить і читається; кеш-сервер натомість пакував би
|
||||
# той самий вміст у tar на кожному прогоні й тримав ДВІ копії на
|
||||
# тому самому диску. Томи мають бути дозволені в конфізі раннера
|
||||
# (container.valid_volumes) — див. deploy/act-runner.config.yml.
|
||||
volumes:
|
||||
- netpulse-ci-npm:/root/.npm
|
||||
env:
|
||||
npm_config_cache: /root/.npm
|
||||
NO_COLOR: "1"
|
||||
CI: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Інструменти
|
||||
run: apk add --no-cache git
|
||||
|
||||
- name: Викачування
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
# set -e явно, а не в надії на прапорці оболонки раннера:
|
||||
# якщо git тихо не викачає дерево, наступні кроки побачать
|
||||
# порожній каталог, gofmt не знайде жодного файлу, а робота
|
||||
# позеленіє. Порожня перевірка виглядає так само, як успішна.
|
||||
set -e
|
||||
git config --global --add safe.directory '*'
|
||||
git init -q .
|
||||
git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git"
|
||||
auth=$(printf 'x-access-token:%s' "$TOKEN" | base64 | tr -d '\n')
|
||||
git config http.extraheader "Authorization: basic $auth"
|
||||
git fetch --depth=1 origin "$GITHUB_SHA" 2>/dev/null \
|
||||
|| git fetch --depth=1 origin "$GITHUB_REF"
|
||||
git checkout -q FETCH_HEAD
|
||||
# Доказ, що дерево справді на місці, а не «команди не впали».
|
||||
[ -f scripts/check.sh ] || { echo "робоче дерево порожнє"; exit 1; }
|
||||
|
||||
- name: Залежності
|
||||
working-directory: web
|
||||
run: npm ci
|
||||
run: npm ci --no-audit --no-fund
|
||||
|
||||
- name: Типи
|
||||
working-directory: web
|
||||
run: npx tsc --noEmit
|
||||
|
||||
# Тести — ДО збірки: збірка коштує хвилини, падіння тесту видно за
|
||||
# секунди. --reporter=verbose навмисно: у логу має бути ПЕРЕЛІК
|
||||
# виконаних тестів, а не лише підсумкове число. Уся ця робота
|
||||
# існує через тести, про які всі думали, що вони йдуть.
|
||||
#
|
||||
# Що покрито й, головне, що НІ — у web/TESTING.md. Зелений прогін
|
||||
# доводить рівно те, що перелічено там.
|
||||
#
|
||||
# --maxWorkers=2: пул vitest за замовчуванням — процеси-форки по
|
||||
# числу ядер, кожен зі своїм jsdom (~200 МБ). На машині з 4 ГБ,
|
||||
# де поруч живе бойовий стек, чотири форки з'їдають більше, ніж
|
||||
# тут є вільного. Паралельність файлів при цьому лишається
|
||||
# увімкненою — тобто ізоляція між файлами перевіряється й далі,
|
||||
# на відміну від --no-file-parallelism.
|
||||
- name: Тести
|
||||
working-directory: web
|
||||
run: npx vitest run --reporter=verbose --maxWorkers=2
|
||||
|
||||
- name: Збірка
|
||||
working-directory: web
|
||||
run: npm run build
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: web-dist
|
||||
path: web/dist/
|
||||
# Прибирання за собою. Кеш npm росте з кожною зміною залежностей і
|
||||
# старе з нього не зникає саме. На диску ~7 ГБ, і CI, який його
|
||||
# забиває, зупинить продукт, а не покращить.
|
||||
- name: Кеш npm у межах
|
||||
if: always()
|
||||
run: |
|
||||
budget=400
|
||||
size=$(du -sm /root/.npm 2>/dev/null | cut -f1)
|
||||
size=${size:-0}
|
||||
echo "кеш npm: ${size} МБ (стеля ${budget} МБ)"
|
||||
if [ "$size" -gt "$budget" ]; then
|
||||
echo "перевищено — чищу"
|
||||
npm cache clean --force || rm -rf /root/.npm/_cacache
|
||||
fi
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Сервер. Єдина робота з базою: тут накочуються міграції на ЧИСТУ базу
|
||||
# й тут виконуються інтеграційні набори store/httpapi/grpcapi.
|
||||
# ------------------------------------------------------------------
|
||||
server:
|
||||
runs-on: docker
|
||||
timeout-minutes: 40
|
||||
container:
|
||||
image: golang:1.25-alpine
|
||||
volumes:
|
||||
- netpulse-ci-gomod:/go/pkg/mod
|
||||
- netpulse-ci-gobuild:/root/.cache/go-build
|
||||
services:
|
||||
# Той самий образ, що й у docker-compose.yml. Не «свіжіший»
|
||||
# навмисно: CI має ловити поломки того Postgres, який реально
|
||||
# крутиться на стенді, а не сусіднього.
|
||||
#
|
||||
# База чиста за побудовою: контейнер сервісу створюється під
|
||||
# прогін і зникає разом із ним, тому міграції щоразу накочуються з
|
||||
# нуля. Це не косметика — накат «з нуля» і накат «поверх»
|
||||
# ламаються по-різному, а на стенді перший буває лише раз.
|
||||
db:
|
||||
image: timescale/timescaledb:2.17.2-pg16
|
||||
env:
|
||||
|
|
@ -52,50 +221,199 @@ jobs:
|
|||
POSTGRES_PASSWORD: netpulse
|
||||
POSTGRES_DB: netpulse_ci
|
||||
TIMESCALEDB_TELEMETRY: "off"
|
||||
# Без цього initdb бере кодування з локалі й цілком може
|
||||
# створити базу як SQL_ASCII. netpulse-migrate це помічає і
|
||||
# відмовляється працювати — задаємо одразу, як у compose.
|
||||
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C.UTF-8"
|
||||
# Автотюнер образу інакше візьме чверть ПАМʼЯТІ МАШИНИ під
|
||||
# shared_buffers. На машині з 4 ГБ це майже гарантований OOM у
|
||||
# парі з go build.
|
||||
TS_TUNE_MEMORY: 512MB
|
||||
TS_TUNE_NUM_CPUS: "2"
|
||||
env:
|
||||
# Стемпінг VCS у контейнері вміє падати на правах на .git —
|
||||
# прибираємо цілий клас відмов, який до продукту не має стосунку.
|
||||
# -p=2 обмежує кількість пакетів, які Go компілює й тестує
|
||||
# одночасно: без нього пік памʼяті на цій машині впирається у стелю
|
||||
# разом із бойовим стеком, і ядро вибирає жертву не на нашу користь.
|
||||
GOFLAGS: -buildvcs=false -p=2
|
||||
# Заборона мовчки тягнути іншу версію Go: на диску ~7 ГБ, і
|
||||
# неочікуваний +300 МБ тулчейну краще побачити як помилку.
|
||||
GOTOOLCHAIN: local
|
||||
NETPULSE_DSN: postgres://netpulse:netpulse@db:5432/netpulse_ci?sslmode=disable
|
||||
NETPULSE_TEST_DSN: postgres://netpulse:netpulse@db:5432/netpulse_ci?sslmode=disable
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Інструменти
|
||||
run: apk add --no-cache git postgresql16-client
|
||||
|
||||
- name: Формат
|
||||
working-directory: server
|
||||
run: |
|
||||
# gofmt -l друкує список, а не код виходу: без перевірки
|
||||
# порожнечі крива форма проїжджає в main непоміченою.
|
||||
bad=$(gofmt -l .)
|
||||
if [ -n "$bad" ]; then
|
||||
echo "не відформатовано:"; echo "$bad"; exit 1
|
||||
fi
|
||||
|
||||
- name: Vet
|
||||
working-directory: server
|
||||
run: go vet ./...
|
||||
|
||||
- name: Схема
|
||||
working-directory: server
|
||||
env:
|
||||
NETPULSE_DSN: postgres://netpulse:netpulse@db:5432/netpulse_ci?sslmode=disable
|
||||
run: |
|
||||
until pg_isready -h db -U netpulse -d netpulse_ci; do sleep 1; done
|
||||
go run ./cmd/netpulse-migrate
|
||||
|
||||
- name: Тести
|
||||
working-directory: server
|
||||
env:
|
||||
NETPULSE_TEST_DSN: postgres://netpulse:netpulse@db:5432/netpulse_ci?sslmode=disable
|
||||
run: go test ./...
|
||||
|
||||
agent:
|
||||
runs-on: docker
|
||||
container:
|
||||
image: golang:1.25-alpine
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Інструменти
|
||||
run: apk add --no-cache git
|
||||
|
||||
- name: Викачування
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
# set -e явно, а не в надії на прапорці оболонки раннера:
|
||||
# якщо git тихо не викачає дерево, наступні кроки побачать
|
||||
# порожній каталог, gofmt не знайде жодного файлу, а робота
|
||||
# позеленіє. Порожня перевірка виглядає так само, як успішна.
|
||||
set -e
|
||||
git config --global --add safe.directory '*'
|
||||
git init -q .
|
||||
git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git"
|
||||
auth=$(printf 'x-access-token:%s' "$TOKEN" | base64 | tr -d '\n')
|
||||
git config http.extraheader "Authorization: basic $auth"
|
||||
git fetch --depth=1 origin "$GITHUB_SHA" 2>/dev/null \
|
||||
|| git fetch --depth=1 origin "$GITHUB_REF"
|
||||
git checkout -q FETCH_HEAD
|
||||
# Доказ, що дерево справді на місці, а не «команди не впали».
|
||||
[ -f scripts/check.sh ] || { echo "робоче дерево порожнє"; exit 1; }
|
||||
|
||||
# gofmt -l друкує список, а не код виходу: без перевірки порожнечі
|
||||
# крива форма проїжджає в main непоміченою.
|
||||
- name: Формат
|
||||
working-directory: server
|
||||
run: |
|
||||
bad=$(gofmt -l .)
|
||||
if [ -n "$bad" ]; then
|
||||
echo "не відформатовано:"; echo "$bad"; exit 1
|
||||
fi
|
||||
echo "форма в порядку"
|
||||
|
||||
- name: Vet
|
||||
working-directory: server
|
||||
run: go vet ./...
|
||||
|
||||
- name: Збірка
|
||||
working-directory: server
|
||||
run: go build ./...
|
||||
|
||||
# Очікування бази через TCP, а не через pg_isready: клієнта
|
||||
# Postgres в образі немає, а ставити його заради одного циклу —
|
||||
# зайвий пакет на диску. Образ під час initdb слухає лише
|
||||
# unix-сокет, тож відкритий 5432 і означає «готова».
|
||||
- name: Очікування бази
|
||||
run: |
|
||||
for i in $(seq 1 90); do
|
||||
if nc -z db 5432; then echo "база слухає (${i}с)"; break; fi
|
||||
if [ "$i" = "90" ]; then echo "база не піднялась"; exit 1; fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Накат з нуля + звірка кількості. Число не зашите: воно рахується
|
||||
# з каталогу. Тому крок ловить не лише невдалу міграцію, а й файл,
|
||||
# який не потрапив у бінарник — embed бере `*.sql`, і міграція з
|
||||
# іншим розширенням чи в підкаталозі зникла б МОВЧКИ, а на стенді
|
||||
# вилізла б відсутньою таблицею.
|
||||
- name: Схема з нуля
|
||||
working-directory: server
|
||||
run: |
|
||||
set -e
|
||||
want=$(ls migrations/*.sql | wc -l | tr -d ' ')
|
||||
echo "у каталозі міграцій файлів: $want"
|
||||
go run ./cmd/netpulse-migrate -dry-run
|
||||
out=$(go run ./cmd/netpulse-migrate)
|
||||
echo "$out"
|
||||
if ! echo "$out" | grep -q "застосовано міграцій: $want$"; then
|
||||
echo "накотилось не $want міграцій — частина не доїхала в бінарник"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# -count=1 обовʼязково: кеш тестів Go віддає «ok (cached)» без
|
||||
# запуску, а кешований результат — той самий різновид брехні, що й
|
||||
# мовчазний пропуск. -v — щоб у логу було ВИДНО, що саме виконалось.
|
||||
- name: Тести
|
||||
working-directory: server
|
||||
run: |
|
||||
if go test ./... -count=1 -v > /tmp/server-tests.log 2>&1; then
|
||||
rc=0
|
||||
else
|
||||
rc=$?
|
||||
fi
|
||||
cat /tmp/server-tests.log
|
||||
exit "$rc"
|
||||
|
||||
# Сторож проти повторення історії з grpcapi.
|
||||
#
|
||||
# Тести проти бази пропускають себе самі, коли NETPULSE_TEST_DSN
|
||||
# порожній, а `go test` рахує пропуск успіхом. Якщо DSN колись
|
||||
# перестане доїжджати (перейменують сервіс, зміниться пароль,
|
||||
# хтось прибере env), уся ця робота лишиться ЗЕЛЕНОЮ й перестане
|
||||
# перевіряти найдорожче. Тому пропуск тут — помилка, а поіменний
|
||||
# перелік доводить, що конкретні набори справді відпрацювали.
|
||||
- name: Інтеграційні тести справді виконались
|
||||
working-directory: server
|
||||
run: |
|
||||
if grep -q "NETPULSE_TEST_DSN не задано" /tmp/server-tests.log; then
|
||||
echo "інтеграційні тести ПРОПУЩЕНО — DSN не доїхав до go test."
|
||||
echo "зелений прогін тут нічого б не довів; зупиняюсь."
|
||||
grep -n "NETPULSE_TEST_DSN не задано" /tmp/server-tests.log | head
|
||||
exit 1
|
||||
fi
|
||||
missing=""
|
||||
for t in TestRLSTenantIsolation \
|
||||
TestEscalationAgainstDB \
|
||||
TestPurgeDeviceLeavesNothing \
|
||||
TestMapStateIsRenderReady \
|
||||
TestTelemetryPersisted \
|
||||
TestDiscoveryCreatesInterfaceChecks; do
|
||||
if ! grep -q "^--- PASS: $t" /tmp/server-tests.log; then
|
||||
missing="$missing $t"
|
||||
fi
|
||||
done
|
||||
if [ -n "$missing" ]; then
|
||||
echo "не бачу успішного виконання:$missing"
|
||||
exit 1
|
||||
fi
|
||||
echo "інтеграційні набори виконались проти живої бази"
|
||||
|
||||
# Кеші Go ростуть необмежено. Стеля тут, у кінці роботи, а не в
|
||||
# сторонньому прибиральнику: у цей момент точно ніхто не компілює,
|
||||
# і чистка не може вирвати файл з-під go build.
|
||||
- name: Кеші Go у межах
|
||||
if: always()
|
||||
run: |
|
||||
bc=$(du -sm "$(go env GOCACHE)" 2>/dev/null | cut -f1); bc=${bc:-0}
|
||||
mc=$(du -sm "$(go env GOMODCACHE)" 2>/dev/null | cut -f1); mc=${mc:-0}
|
||||
echo "кеш збірки: ${bc} МБ (стеля 1200), модулі: ${mc} МБ (стеля 900)"
|
||||
if [ "$bc" -gt 1200 ]; then echo "чищу кеш збірки"; go clean -cache; fi
|
||||
if [ "$mc" -gt 900 ]; then echo "чищу кеш модулів"; go clean -modcache; fi
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Зонд. Без бази: він до неї не ходить.
|
||||
# ------------------------------------------------------------------
|
||||
agent:
|
||||
runs-on: docker
|
||||
timeout-minutes: 30
|
||||
container:
|
||||
image: golang:1.25-alpine
|
||||
volumes:
|
||||
- netpulse-ci-gomod:/go/pkg/mod
|
||||
- netpulse-ci-gobuild:/root/.cache/go-build
|
||||
env:
|
||||
GOFLAGS: -buildvcs=false -p=2
|
||||
GOTOOLCHAIN: local
|
||||
steps:
|
||||
- name: Інструменти
|
||||
run: apk add --no-cache git
|
||||
|
||||
- name: Викачування
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
# set -e явно, а не в надії на прапорці оболонки раннера:
|
||||
# якщо git тихо не викачає дерево, наступні кроки побачать
|
||||
# порожній каталог, gofmt не знайде жодного файлу, а робота
|
||||
# позеленіє. Порожня перевірка виглядає так само, як успішна.
|
||||
set -e
|
||||
git config --global --add safe.directory '*'
|
||||
git init -q .
|
||||
git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git"
|
||||
auth=$(printf 'x-access-token:%s' "$TOKEN" | base64 | tr -d '\n')
|
||||
git config http.extraheader "Authorization: basic $auth"
|
||||
git fetch --depth=1 origin "$GITHUB_SHA" 2>/dev/null \
|
||||
|| git fetch --depth=1 origin "$GITHUB_REF"
|
||||
git checkout -q FETCH_HEAD
|
||||
# Доказ, що дерево справді на місці, а не «команди не впали».
|
||||
[ -f scripts/check.sh ] || { echo "робоче дерево порожнє"; exit 1; }
|
||||
|
||||
- name: Формат
|
||||
working-directory: agent
|
||||
run: |
|
||||
|
|
@ -103,17 +421,24 @@ jobs:
|
|||
if [ -n "$bad" ]; then
|
||||
echo "не відформатовано:"; echo "$bad"; exit 1
|
||||
fi
|
||||
echo "форма в порядку"
|
||||
|
||||
- name: Vet
|
||||
working-directory: agent
|
||||
run: go vet ./...
|
||||
|
||||
- name: Збірка
|
||||
working-directory: agent
|
||||
run: go build ./...
|
||||
|
||||
- name: Тести
|
||||
working-directory: agent
|
||||
run: go test ./...
|
||||
run: go test ./... -count=1
|
||||
|
||||
# Зонд їде на чуже залізо: перевіряємо, що збирається під усі
|
||||
# платформи, які обіцяємо, а не лише під ту, де крутиться CI.
|
||||
# Результат нікуди не потрібен — пишемо в /dev/null, бо пʼять
|
||||
# бінарників по ~30 МБ на диску, якого ~7 ГБ, це не дрібниця.
|
||||
- name: Крос-збірка
|
||||
working-directory: agent
|
||||
run: |
|
||||
|
|
@ -121,5 +446,14 @@ jobs:
|
|||
os=${target%/*}; arch=${target#*/}
|
||||
echo "== $os/$arch"
|
||||
CGO_ENABLED=0 GOOS=$os GOARCH=$arch go build -trimpath \
|
||||
-o /tmp/netpulse-agent-$os-$arch ./cmd/netpulse-agent
|
||||
-o /dev/null ./cmd/netpulse-agent
|
||||
done
|
||||
|
||||
- name: Кеші Go у межах
|
||||
if: always()
|
||||
run: |
|
||||
bc=$(du -sm "$(go env GOCACHE)" 2>/dev/null | cut -f1); bc=${bc:-0}
|
||||
mc=$(du -sm "$(go env GOMODCACHE)" 2>/dev/null | cut -f1); mc=${mc:-0}
|
||||
echo "кеш збірки: ${bc} МБ (стеля 1200), модулі: ${mc} МБ (стеля 900)"
|
||||
if [ "$bc" -gt 1200 ]; then echo "чищу кеш збірки"; go clean -cache; fi
|
||||
if [ "$mc" -gt 900 ]; then echo "чищу кеш модулів"; go clean -modcache; fi
|
||||
|
|
|
|||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -41,3 +41,6 @@
|
|||
server/webui/dist/*
|
||||
!server/webui/dist/index.html
|
||||
!server/webui/dist/.gitignore
|
||||
|
||||
# Кеш інкрементної збірки TypeScript — машинний стан, не код.
|
||||
web/tsconfig.tsbuildinfo
|
||||
|
|
|
|||
4169
HISTORY.md
4169
HISTORY.md
File diff suppressed because it is too large
Load diff
141
ROADMAP.md
141
ROADMAP.md
|
|
@ -24,8 +24,8 @@ API віддає готове полотно з живими статусами,
|
|||
| Мапа, топологія, телеметрія | ✅ | ✅ |
|
||||
| Автовиявлення LLDP/CDP/ARP/FDB | ✅ | ✅ |
|
||||
| **Користувачі, ролі, вхід** | ✅ | ✅ |
|
||||
| **Шаблони опитування** | ✅ | ⚠️ є, з тригерами й автопризначенням; немає snmp.walk і прототипів |
|
||||
| **NCM (збір конфігів)** | ✅ | ⚠️ збір, розклад, Git, syslog і compliance є; немає відкату |
|
||||
| **Шаблони опитування** | ✅ | ✅ з тригерами, автопризначенням, `snmp.walk` і прототипами (0059) |
|
||||
| **NCM (збір конфігів)** | ✅ | ✅ збір, розклад, Git, syslog, compliance і відкат (0060) |
|
||||
| **Керування зондом із UI** | ✅ | ✅ |
|
||||
| **Алерти й сповіщення** | ✅ | ✅ |
|
||||
| **Мобільна адаптивність, PWA** | — | ⚠️ адаптив є, PWA немає |
|
||||
|
|
@ -113,6 +113,11 @@ POST /api/v1/roles кастомна роль із набором
|
|||
> **Додано 2026-08-25:** тригери описуються в шаблоні й розгортаються в
|
||||
> правила сповіщень; автопризначення за `sysObjectID` — пристрій сам
|
||||
> каже, що він таке, і шаблон чіпляється без жодного натискання.
|
||||
> За тим самим `sysObjectID` тепер підбирається й профіль збору конфігів
|
||||
> (`ncm.profile_auto_assign`, уточнення за `sysDescr` для випадків, коли
|
||||
> один OID покриває різні типи заліза). Щоб було з чого підбирати, хост
|
||||
> зі SNMP-доступом сам отримує чек розпізнавання: полегшений
|
||||
> `topology.discover` — три OID, без сусідів і без обходу `ifTable`.
|
||||
>
|
||||
> **Лишилось:** прототипи шаблонів і `snmp.walk` як тип елемента
|
||||
> (таблиці з динамічним індексом).
|
||||
|
|
@ -311,6 +316,99 @@ Diff* — усе з ТЗ.
|
|||
|
||||
---
|
||||
|
||||
## Етап 11. Сім задач одним заходом — 2026-08-27
|
||||
|
||||
> Міграції 0058–0064, зроблено паралельно. Розбір спільного знаменника —
|
||||
> в [HISTORY.md](HISTORY.md), розділ «Сім задач одним заходом».
|
||||
>
|
||||
> - **0058 подієві алерти** — `syslog`, `ncm`, `compliance` спрацьовують
|
||||
> у мить надходження події; правило з нереалізованим джерелом більше
|
||||
> не зберігається мовчки.
|
||||
> - **0059 `snmp.walk` і прототипи** — таблиці з динамічним індексом
|
||||
> описуються шаблоном, а не Go.
|
||||
> - **0060 відкат конфігу** — план як різниця, маскування паролів із
|
||||
> підписом плану, обов'язковий контрольний збір, `verifying` при
|
||||
> обриві. MikroTik і Juniper відмовлені з поясненням.
|
||||
> - **0061 кнопки Telegram** — довге опитування (домену немає й не
|
||||
> передбачається), авторизація не з `callback_data`, прив'язка
|
||||
> акаунта одноразовим кодом.
|
||||
> - **0062 аудит і архів хостів** — вісім відсутніх назв дій; тест на
|
||||
> AST, що падає на ключі без назви; «відновити» повертає хост робочим,
|
||||
> а не мовчазним.
|
||||
> - **0063 RLS** — три ролі, окремий пул для фонових тактів. Інертна до
|
||||
> перемикання DSN.
|
||||
> - **0064 строки зберігання** — три гіпертаблиці й дві звичайні
|
||||
> таблиці, що росли назавжди; сторінка сховища з прогнозом.
|
||||
|
||||
### Лишилось із цього етапу
|
||||
|
||||
- ~~**Тест ізоляції RLS не прогнано.**~~ ✅ 2026-08-27: прогнано на
|
||||
бойовій базі, перемикання зроблено. Ізоляція діє, вхідні шляхи
|
||||
переведено на воркерний пул. Подробиці — [HISTORY.md](HISTORY.md),
|
||||
розділ «Перехід на роль без BYPASSRLS». **Лишилось:** телеметрія,
|
||||
аудит та історія алертів під RLS не підпадають і не підпадуть —
|
||||
TimescaleDB не поєднує стиснення з row level security. Їхню ізоляцію
|
||||
далі тримає предикат у запиті.
|
||||
- **0064 не прогнано на живій БД.** Перевірити першими:
|
||||
`chunks_detailed_size` над матеріалізованою гіпертаблицею,
|
||||
`hypertable_compression_stats` на нестиснутій, `add_retention_policy`
|
||||
всередині транзакції під `SECURITY DEFINER`.
|
||||
- **Алерт про вичерпання диска** — найдешевший шлях без правок движка:
|
||||
писати `db.size.bytes` і `db.days_left` звичайними метриками на хості
|
||||
машини зонда, тоді наявне метричне правило працює як є.
|
||||
- **`apply_*` у генераторі профілів.** Поля заливки задані міграцією
|
||||
через `UPDATE`; `db/profiles/catalog.json` про них не знає.
|
||||
- **`plural()` повертає рядок разом із числом**, а частина місць виклику
|
||||
додає число ще раз — на екрані «5 5 хостів». Стара вада, не з цього
|
||||
етапу.
|
||||
|
||||
## Етап 12. Друга сімка — 2026-08-27
|
||||
|
||||
> Міграції 0065–0068 плюс роботи без міграцій. Розбір спільного — у
|
||||
> [HISTORY.md](HISTORY.md), розділ «Друга сімка».
|
||||
>
|
||||
> - **0065 трапи** — приймач 162/udp на зонді, словник із шести
|
||||
> протокольних трапів плюс словник кабінету, джерело алертів `trap`.
|
||||
> - **0066 ескалації** — драбина сходинок, стан у базі, зупинка при
|
||||
> підтвердженні, заглушення відкладає сходинку, а не витрачає.
|
||||
> - **0067 алерт про диск** — пороги за часом (21 доба / 4 доби), а не
|
||||
> за відсотками; вільне місце міряється `statfs` по `Bavail`.
|
||||
> - **0068 каталог профілів** — поля заливки переїхали з разової
|
||||
> міграції в `catalog.json`.
|
||||
> - **`plural`** — 69 місць виклику, 13 друкували число двічі; підпис
|
||||
> змінено так, щоб помилка стала неможливою.
|
||||
> - **Тести вебу** — з нуля до 137; знайшли 11 справжніх вад,
|
||||
> усі виправлені.
|
||||
|
||||
### Увімкнути трапи — рішення власника
|
||||
|
||||
Код розгорнуто, модуль **не увімкнено**. Щоб запрацював, потрібні три
|
||||
речі, і третя виходить за межі технічної:
|
||||
|
||||
1. `traps` у `-modules` зонда;
|
||||
2. `NET_BIND_SERVICE` — процес не root, а 162 привілейований;
|
||||
3. **публікація 162/udp на хост** — порт без автентифікації приймає
|
||||
будь-кого, хто знає адресу.
|
||||
|
||||
Обмеження в модулі є (20 трапів/с з адреси, стеля черги, окремий облік
|
||||
невідомих джерел), але вони зменшують шкоду, а не прибирають рішення.
|
||||
|
||||
### Лишилось із цього етапу
|
||||
|
||||
- **`db/profiles/build.py --check` не в CI.** Один рядок у кроці «Схема»
|
||||
ловив би розходження каталогу зі згенерованим — саме те, що цього разу
|
||||
знайшлось випадково.
|
||||
- **CI без раннера.** `scripts/check.sh` робить те саме однією командою
|
||||
вже сьогодні; сам workflow чекає на раннера.
|
||||
- **Перетягування вузлів на мапі не покрите й не буде** — d3-drag не
|
||||
запускається синтетичними подіями. Наслідок: вузол візуально стає на
|
||||
місце, запит не йде, розкладка «сама відкочується» після
|
||||
перезавантаження, і всі тести при цьому зелені.
|
||||
- **Тригери шаблонів не можуть отримати драбину ескалації** — поля в
|
||||
тригері шаблону немає, а драбина ще й тенант-специфічна.
|
||||
- **SNMPv3-трапи не перевіряються** — розбираються й зберігаються, підпис
|
||||
і шифрування не звіряються.
|
||||
|
||||
## Порядок і чому саме такий
|
||||
|
||||
1. ~~**Етап 5 (користувачі)** — без входу продукт не можна віддати нікому.~~ ✅
|
||||
|
|
@ -341,8 +439,6 @@ Diff* — усе з ТЗ.
|
|||
| Що | Де схема | Чого бракує |
|
||||
|---|---|---|
|
||||
| **Білінг і ліцензії** | `0009_billing_licensing.sql` | усього: тарифи, ліміти, Stripe, ключі. Для Micro-SaaS це те, через що продають |
|
||||
| **Відкат конфігу** | `ncm.rollbacks` | API, UI і — головне — запис на пристрій. Двоетапне погодження в схемі є, виконавця немає |
|
||||
| **Ескалації сповіщень** | `alr.escalation_policies` | «не підтвердили за 15 хв — буди наступного» |
|
||||
| **Web Push** | `alr.push_subscriptions` | підписки й доставка. Потрібне для PWA |
|
||||
| **Звіти SLA** | `core.sla_targets`, `core.sla_periods` | розрахунок доступності за період і вивантаження |
|
||||
| **Збережені подання** | `core.saved_views` | фільтри інвентарю, які можна назвати й повернутись |
|
||||
|
|
@ -352,28 +448,21 @@ Diff* — усе з ТЗ.
|
|||
|
||||
| Що | Стан | Чому не зроблено |
|
||||
|---|---|---|
|
||||
| **`snmp.walk`** | тип чека є в `core.check_types`, модуля немає | таблиці з динамічним індексом: потрібне виявлення рядків і породження метрик на кожен. Найбільша з відсутніх можливостей опитування |
|
||||
| **Modbus-TCP** | плагін у сіді, `is_core = false` | немає жодного інвертора чи UPS під рукою. Неперевірений промисловий протокол у мережі з живим обладнанням — гірше, ніж його відсутність |
|
||||
| **NetFlow / sFlow** | плагін у сіді, `is_core = false` | окремий приймач потоків, за обсягом — власний етап |
|
||||
| **Приймач SNMP-трапів** | `ts.snmp_traps` і `WriteLogs` готові | на зонді немає слухача 162/udp. Syslog зроблено, трапи — ні |
|
||||
|
||||
Обидва плагіни позначені `is_core = false`, тож у переліку перевірок
|
||||
система показує їх недоступними — обіцянки користувачу немає.
|
||||
|
||||
### Зроблено наполовину
|
||||
|
||||
**Сповіщення за подіями не спрацьовують.** Движок правил рахує тільки
|
||||
джерела `metric`, `icmp` і `interface`. Для `syslog`, `trap`, `ncm` і
|
||||
`compliance` він свідомо повертає порожньо — їх треба обробляти подією в
|
||||
момент надходження, а не опитуванням. Тобто правило з такими джерелами
|
||||
можна створити, і воно ніколи не спрацює. Дані при цьому збираються:
|
||||
журнал пишеться, знахідки відповідності рахуються — не вистачає ланки
|
||||
між ними й алертом.
|
||||
|
||||
**Кнопки в Telegram нікуди не ведуть.** Сповіщення йде з кнопками
|
||||
«Підтвердити» й «Заглушити 1 год» (`callback_data` у `notify.go`), але
|
||||
приймача натискань немає: сервер не має вебхука Telegram. Кнопки видно,
|
||||
натиснути можна, нічого не станеться.
|
||||
**Сповіщення за подіями — лишився `trap`.** `syslog`, `ncm` і
|
||||
`compliance` зроблено подієво (0058): правило перевіряється в мить
|
||||
надходження події. `trap` свідомо не реалізовано — без словника MIB
|
||||
умова звелась би до порівняння сирих OID, тобто до другої мовчазної
|
||||
обіцянки замість першої. Джерела `link` і `agent` не подієві за
|
||||
природою. Правило з нереалізованим джерелом тепер не зберігається, а не
|
||||
мовчить.
|
||||
|
||||
**Підкладки-плани приміщень.** `topo.map_backgrounds` віддається в
|
||||
`GET /maps/{id}`, полотно їх не малює. Потрібен прийом і роздача файлів
|
||||
|
|
@ -385,14 +474,20 @@ S3. Приблизно пів дня.
|
|||
явний режим редагування, інакше кожна спроба посунути карту рухатиме
|
||||
вузол.
|
||||
|
||||
**Прототипи шаблонів.** Шаблон описує сталий перелік метрик. Для
|
||||
інтерфейсів і дисків потрібні прототипи: «на кожен знайдений рядок
|
||||
таблиці — свій набір метрик». Без цього кожен порт заводиться руками.
|
||||
**Інтерфейси досі захардкоджені.** Прототипи шаблонів зроблено (0059),
|
||||
але `snmp.if` через них не виражається: зонд тримає попередній замір,
|
||||
рахує швидкість за фактичним інтервалом і ловить перевертання
|
||||
лічильника, а лічильники лягають у `ts.if_counters` за `interface_id`, а
|
||||
не в `ts.samples` за міткою. На цьому `interface_id` тримаються анімація
|
||||
трафіку на мапі, інспектор лінка й тригери з джерелом `interface`.
|
||||
Виграш — мінус ~200 рядків Go; ризик — обірвані графіки на живих хостах.
|
||||
Свідомо відкладено.
|
||||
|
||||
### Перевірки, яких немає
|
||||
|
||||
**У вебі немає тестового прогону взагалі** — ні vitest, ні jest.
|
||||
Перевіряється типами й живим браузером. Найпомітніший наслідок:
|
||||
**Тести вебу з'явились 2026-08-27** — vitest, 137 перевірок, і
|
||||
`scripts/check.sh` проганяє обидва світи однією командою. Що покрито і,
|
||||
головне, що НІ — у `web/TESTING.md`. Найбільша діра лишається та сама:
|
||||
перетягування вузла на мапі не покрите, бо d3-drag не запускається
|
||||
синтетичними подіями; серверний бік цієї дії тестами покритий.
|
||||
|
||||
|
|
|
|||
|
|
@ -20,11 +20,13 @@ import (
|
|||
|
||||
"github.com/netpulse/netpulse/agent/internal/config"
|
||||
"github.com/netpulse/netpulse/agent/internal/module"
|
||||
"github.com/netpulse/netpulse/agent/internal/modules/filecfg"
|
||||
"github.com/netpulse/netpulse/agent/internal/modules/httpx"
|
||||
"github.com/netpulse/netpulse/agent/internal/modules/icmp"
|
||||
"github.com/netpulse/netpulse/agent/internal/modules/snmp"
|
||||
"github.com/netpulse/netpulse/agent/internal/modules/syslog"
|
||||
"github.com/netpulse/netpulse/agent/internal/modules/topology"
|
||||
"github.com/netpulse/netpulse/agent/internal/modules/traps"
|
||||
"github.com/netpulse/netpulse/agent/internal/scheduler"
|
||||
"github.com/netpulse/netpulse/agent/internal/session"
|
||||
"github.com/netpulse/netpulse/agent/internal/telemetry"
|
||||
|
|
@ -136,16 +138,36 @@ func run() error {
|
|||
if err := reg.Register(httpx.New()); err != nil {
|
||||
return err
|
||||
}
|
||||
// Модуль конфіг-файлів реєструється завжди, а не за переліком
|
||||
// -modules: він не виконує чеків і нічого не споживає, доки його не
|
||||
// попросили зібрати. Реєстрація тут лише для того, щоб він
|
||||
// потрапив у compiled_modules — оголошення «цей зонд уміє читати
|
||||
// локальні файли». Чи буде щось прочитано, вирішує не це й не
|
||||
// сервер, а локальний перелік файлів на самій машині.
|
||||
fileCfg := filecfg.New(cfg.FileCfgPolicy)
|
||||
if err := reg.Register(fileCfg); err != nil {
|
||||
return err
|
||||
}
|
||||
reg.EnsureDefaults(cfg.DefaultModules...)
|
||||
|
||||
// Приймач syslog не модуль реєстру: у нього немає задач і розкладу,
|
||||
// він просто слухає порт. Вмикається тим самим переліком -modules,
|
||||
// щоб людині не треба було знати про цю різницю.
|
||||
var syslogRecv *syslog.Receiver
|
||||
//
|
||||
// Приймач трапів — те саме: слухає 162/udp і теж не має ані задач,
|
||||
// ані розкладу. Вмикається окремим модулем, а не разом із syslog:
|
||||
// це другий порт, який має бути відкритий у фаєрволі клієнта, і
|
||||
// відкривати його «заодно» ми не маємо права.
|
||||
var (
|
||||
syslogRecv *syslog.Receiver
|
||||
trapRecv *traps.Receiver
|
||||
)
|
||||
for _, m := range cfg.DefaultModules {
|
||||
if m == "syslog" {
|
||||
switch m {
|
||||
case "syslog":
|
||||
syslogRecv = syslog.New(cfg.SyslogListen, log)
|
||||
break
|
||||
case "traps":
|
||||
trapRecv = traps.New(cfg.TrapsListen, log)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -180,6 +202,8 @@ func run() error {
|
|||
MaxBackoff: cfg.MaxBackoff,
|
||||
DefaultModules: cfg.DefaultModules,
|
||||
Syslog: syslogRecv,
|
||||
Traps: trapRecv,
|
||||
FileCfg: fileCfg,
|
||||
})
|
||||
|
||||
sched := scheduler.New(scheduler.Config{
|
||||
|
|
@ -224,6 +248,21 @@ func run() error {
|
|||
}
|
||||
}()
|
||||
}
|
||||
if trapRecv != nil {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := trapRecv.Run(ctx); err != nil {
|
||||
// Найчастіша причина саме тут: 162 нижче 1024, і без
|
||||
// CAP_NET_BIND_SERVICE зонд його не займе. Помилка в
|
||||
// журналі, а не мовчазна відсутність трапів, — бо
|
||||
// відсутність трапів виглядає точнісінько як спокійна
|
||||
// мережа.
|
||||
log.Error("приймач трапів не запустився",
|
||||
"адреса", cfg.TrapsListen, "err", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
runErr := sess.Run(ctx)
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import (
|
|||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/netpulse/netpulse/agent/internal/localfiles"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
|
|
@ -51,6 +53,10 @@ type Config struct {
|
|||
// фаєрвола можна скрізь.
|
||||
SyslogListen string
|
||||
|
||||
// Адреса приймача SNMP-трапів. Той самий випадок із
|
||||
// CAP_NET_BIND_SERVICE, що й у syslog: 162 — привілейований порт.
|
||||
TrapsListen string
|
||||
|
||||
// Одноразовий токен запрошення. Якщо заданий — зонд спершу
|
||||
// реєструється, зберігає посвідчення й лише потім працює.
|
||||
EnrollToken string
|
||||
|
|
@ -58,6 +64,19 @@ type Config struct {
|
|||
EnrollName string
|
||||
// Де лежить посвідчення між запусками.
|
||||
IdentityPath string
|
||||
|
||||
// Перелік конфіг-файлів, які вільно читати з ЦІЄЇ машини.
|
||||
//
|
||||
// Єдине налаштування зонда, яке принципово не може приходити з
|
||||
// сервера. Зонд читає файли правами машини, і поле «які саме»,
|
||||
// кероване з кабінету, дало б будь-кому з правом ncm:write читання
|
||||
// довільного файлу на всіх серверах клієнта одразу. Тому перелік
|
||||
// лежить у файлі на самій машині — там, де його редагує той, хто
|
||||
// вже має на ній права, — а сервер про його вміст не знає нічого.
|
||||
//
|
||||
// Прапорець задає лише ШЛЯХ до переліку, і сам по собі нічого не
|
||||
// дозволяє: без файлу збір локальних конфігів не робиться взагалі.
|
||||
FileCfgPolicy string
|
||||
}
|
||||
|
||||
func envOr(key, def string) string {
|
||||
|
|
@ -113,6 +132,9 @@ func Parse(args []string) (*Config, error) {
|
|||
fs.StringVar(&c.SyslogListen, "syslog-listen", envOr("NETPULSE_SYSLOG_LISTEN", ":514"),
|
||||
"адреса приймача syslog; діє лише з модулем syslog")
|
||||
|
||||
fs.StringVar(&c.TrapsListen, "traps-listen", envOr("NETPULSE_TRAPS_LISTEN", ":162"),
|
||||
"адреса приймача SNMP-трапів; діє лише з модулем traps")
|
||||
|
||||
fs.StringVar(&c.EnrollToken, "enroll", envOr("NETPULSE_ENROLL", ""),
|
||||
"одноразовий токен запрошення з UI")
|
||||
fs.StringVar(&c.EnrollName, "name", envOr("NETPULSE_NAME", ""),
|
||||
|
|
@ -120,6 +142,10 @@ func Parse(args []string) (*Config, error) {
|
|||
fs.StringVar(&c.IdentityPath, "identity", envOr("NETPULSE_IDENTITY", DefaultIdentityPath()),
|
||||
"файл посвідчення зонда")
|
||||
|
||||
fs.StringVar(&c.FileCfgPolicy, "files-policy",
|
||||
envOr("NETPULSE_FILES_POLICY", localfiles.DefaultPolicyPath()),
|
||||
"перелік конфіг-файлів цієї машини, дозволених до збору")
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
92
agent/internal/localfiles/bundle.go
Normal file
92
agent/internal/localfiles/bundle.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
package localfiles
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Склейка набору в один текст.
|
||||
//
|
||||
// # Чому один текст, а не окрема версія на кожен файл
|
||||
//
|
||||
// Питання, з яким приходять до архіву сервера, — «що на ньому змінилось
|
||||
// із учора», а не «що змінилось у nginx.conf». Один набір відповідає на
|
||||
// нього одним diff; десять окремих історій довелося б спершу зводити
|
||||
// докупи очима.
|
||||
//
|
||||
// Практичніша половина причини — розкладка Git. Шлях у репозиторії
|
||||
// виводиться з пари (ім'я хоста, config_type) у ДВОХ місцях: коли конфіг
|
||||
// приймають (store.StoreConfig) і коли репозиторій відновлюють із бази
|
||||
// (store.SyncGit). Версія на файл вимагала б переписати формулу шляху в
|
||||
// обох і вигадати другу розкладку поряд із наявною. Набір лягає в неї як
|
||||
// є: <хост>/files.cfg, гілка device/<id>, — і дзеркало, ретеншен,
|
||||
// перегляд і порівняння працюють без жодної правки.
|
||||
//
|
||||
// Ціна, яку платимо чесно: `git log -- etc/nginx/nginx.conf` тут не
|
||||
// працює. Історія одного файлу читається як його ділянка в спільному
|
||||
// diff — заголовки нижче для того й потрібні, щоб ділянку було видно.
|
||||
//
|
||||
// # Чому в тексті немає жодної мітки часу
|
||||
//
|
||||
// Зміну ловлять порівнянням хеша тіла. Дата збору, тривалість або час
|
||||
// зміни файлу зробили б КОЖЕН збір відмінним від попереднього: щоночі
|
||||
// новий коміт, і за місяць тридцять однакових записів, серед яких треба
|
||||
// шукати одну справжню правку. Тому в набір іде лише те, що змінюється
|
||||
// разом зі змістом: шлях, права, власник, розмір.
|
||||
|
||||
// bundle складає набір у текст.
|
||||
func bundle(machine string, files []File, skips []Skip) []byte {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString("# NetPulse: конфіг-файли сервера\n")
|
||||
// Машина — частина ВМІСТУ, а не метаданих. Якщо зонд переставлять
|
||||
// на інший сервер, це буде видно другим рядком diff, а не через
|
||||
// півроку по дивних змінах у конфігу.
|
||||
b.WriteString("# машина: " + machine + "\n")
|
||||
b.WriteString(fmt.Sprintf("# файлів: %d", len(files)))
|
||||
if len(skips) > 0 {
|
||||
b.WriteString(fmt.Sprintf(", пропущено: %d", len(skips)))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
for _, f := range files {
|
||||
b.WriteString("\n===== " + f.Path + " =====\n")
|
||||
b.WriteString(fmt.Sprintf("# %v %s %d Б\n", f.Mode, f.Owner, f.Size))
|
||||
b.Write(f.Body)
|
||||
// Рівно один перевід рядка в кінці: інакше файл без кінцевого
|
||||
// «\n» злипався б із заголовком наступного, а файл із двома
|
||||
// давав би зайвий порожній рядок у diff.
|
||||
if !strings.HasSuffix(string(f.Body), "\n") {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
if len(skips) > 0 {
|
||||
// Пропуски — теж вміст: файл, який зник, має з'явитись у diff
|
||||
// як зміна, а не тихо випасти з набору.
|
||||
b.WriteString("\n===== пропущено =====\n")
|
||||
for _, s := range skips {
|
||||
b.WriteString("# " + s.Path + " — " + s.Reason + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
return []byte(b.String())
|
||||
}
|
||||
|
||||
// report — протокол збору для транскрипту завдання.
|
||||
//
|
||||
// Окремо від набору саме тому, що набір мусить бути стабільним: сюди
|
||||
// вільно писати все, що допомагає розібратись, не платячи за це зайвим
|
||||
// комітом.
|
||||
func report(machine string, files []File, skips []Skip) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("машина: " + machine + "\n")
|
||||
b.WriteString(fmt.Sprintf("зібрано файлів: %d, пропущено: %d\n\n", len(files), len(skips)))
|
||||
for _, f := range files {
|
||||
b.WriteString(fmt.Sprintf(" + %s (%d Б, %v, %s)\n", f.Path, f.Size, f.Mode, f.Owner))
|
||||
}
|
||||
for _, s := range skips {
|
||||
b.WriteString(fmt.Sprintf(" - %s: %s\n", s.Path, s.Reason))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
282
agent/internal/localfiles/collect.go
Normal file
282
agent/internal/localfiles/collect.go
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
package localfiles
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// File — один зібраний файл.
|
||||
type File struct {
|
||||
Path string
|
||||
Mode fs.FileMode
|
||||
Owner string
|
||||
Size int64
|
||||
Body []byte
|
||||
}
|
||||
|
||||
// Skip — файл, який зібрати не вдалося, і чому.
|
||||
//
|
||||
// Пропуск — це результат, а не збій: файл міг зникнути разом із
|
||||
// демоном, який його ставив, і це саме те, що людина має побачити.
|
||||
// Обірвати через нього все завдання означало б втратити ще дев'ять
|
||||
// файлів, які цілком читаються.
|
||||
type Skip struct {
|
||||
Path string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// Result — те, що піде в архів.
|
||||
type Result struct {
|
||||
Body []byte
|
||||
SHA256 []byte
|
||||
LineCount int
|
||||
Files []File
|
||||
Skips []Skip
|
||||
Duration time.Duration
|
||||
// Report — людський протокол збору: що взято, що пропущено й чому.
|
||||
// Їде транскриптом завдання, бо потрібен рівно тоді, коли щось
|
||||
// пішло не так.
|
||||
Report string
|
||||
}
|
||||
|
||||
// Limits — стелі, з якими виконується збір.
|
||||
type Limits struct {
|
||||
MaxFileBytes int64
|
||||
MaxFiles int
|
||||
MaxTotalBytes int64
|
||||
}
|
||||
|
||||
// Narrow звужує стелі політики серверними, ніколи не розширюючи.
|
||||
//
|
||||
// Напрямок односторонній навмисно: сервер може попросити взяти менше
|
||||
// (наприклад, на вузькому каналі), але не може випросити більше, ніж
|
||||
// дозволила машина. Нуль із сервера означає «не звужую».
|
||||
func (l Limits) Narrow(o Limits) Limits {
|
||||
if o.MaxFileBytes > 0 && o.MaxFileBytes < l.MaxFileBytes {
|
||||
l.MaxFileBytes = o.MaxFileBytes
|
||||
}
|
||||
if o.MaxFiles > 0 && o.MaxFiles < l.MaxFiles {
|
||||
l.MaxFiles = o.MaxFiles
|
||||
}
|
||||
if o.MaxTotalBytes > 0 && o.MaxTotalBytes < l.MaxTotalBytes {
|
||||
l.MaxTotalBytes = o.MaxTotalBytes
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// Collect читає дозволені файли машини й складає з них один набір.
|
||||
func Collect(ctx context.Context, p *Policy, srv Limits, machine string) (Result, error) {
|
||||
start := time.Now()
|
||||
lim := Limits{
|
||||
MaxFileBytes: p.MaxFileBytes,
|
||||
MaxFiles: p.MaxFiles,
|
||||
MaxTotalBytes: p.MaxTotalBytes,
|
||||
}.Narrow(srv)
|
||||
|
||||
var (
|
||||
files []File
|
||||
skips []Skip
|
||||
total int64
|
||||
)
|
||||
|
||||
for _, path := range expand(p) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if len(files) >= lim.MaxFiles {
|
||||
skips = append(skips, Skip{path, fmt.Sprintf("перевищено ліміт у %d файлів", lim.MaxFiles)})
|
||||
continue
|
||||
}
|
||||
f, reason := read(p, path, lim.MaxFileBytes)
|
||||
if reason != "" {
|
||||
skips = append(skips, Skip{path, reason})
|
||||
continue
|
||||
}
|
||||
if total+f.Size > lim.MaxTotalBytes {
|
||||
skips = append(skips, Skip{path, fmt.Sprintf("набір перевищив би %d Б", lim.MaxTotalBytes)})
|
||||
continue
|
||||
}
|
||||
total += f.Size
|
||||
files = append(files, *f)
|
||||
}
|
||||
|
||||
// Порожній набір у сховище не йде.
|
||||
//
|
||||
// Той самий висновок, що й у ncmx.looksLikeConfig: зібране, що не
|
||||
// схоже на конфіг, не має ставати версією конфігу. Нуль прочитаних
|
||||
// файлів означає, що політика вказує в порожнечу або все зникло;
|
||||
// покласти це в архів — значить записати «конфіг сервера порожній»
|
||||
// і показати цю версію в diff як видалення всього.
|
||||
if len(files) == 0 {
|
||||
reason := "перелік дозволених файлів нічого не знайшов"
|
||||
if len(skips) > 0 {
|
||||
reason = fmt.Sprintf("усі %d шляхів пропущено; перший: %s — %s",
|
||||
len(skips), skips[0].Path, skips[0].Reason)
|
||||
}
|
||||
return Result{Duration: time.Since(start), Skips: skips, Report: report(machine, nil, skips)},
|
||||
fmt.Errorf("жодного конфіг-файлу не зібрано: %s", reason)
|
||||
}
|
||||
|
||||
body := bundle(machine, files, skips)
|
||||
sum := sha256.Sum256(body)
|
||||
|
||||
return Result{
|
||||
Body: body,
|
||||
SHA256: sum[:],
|
||||
LineCount: strings.Count(string(body), "\n") + 1,
|
||||
Files: files,
|
||||
Skips: skips,
|
||||
Duration: time.Since(start),
|
||||
Report: report(machine, files, skips),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// expand розкриває маски політики в конкретні шляхи.
|
||||
//
|
||||
// filepath.Glob навмисно: його «*» не переходить через розділювач, тож
|
||||
// «/etc/nginx/*.conf» не може дотягнутись до /etc/nginx/ssl/server.key.
|
||||
// Рекурсивних масок немає взагалі — рівні пишуться явно.
|
||||
func expand(p *Policy) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, pat := range p.Allow {
|
||||
matches, err := filepath.Glob(pat)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if len(matches) == 0 && !strings.ContainsAny(pat, "*?[") {
|
||||
// Файл, названий буквально й відсутній, має дати чесний
|
||||
// пропуск із причиною, а не тихо зникнути з набору: саме
|
||||
// зникнення конфігу найцікавіше в цій задачі.
|
||||
matches = []string{pat}
|
||||
}
|
||||
for _, m := range matches {
|
||||
if !seen[m] {
|
||||
seen[m] = true
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Сталий порядок: інакше той самий набір давав би різний вміст і
|
||||
// кожен збір виглядав би зміною.
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// read перевіряє один файл і читає його.
|
||||
//
|
||||
// Порядок перевірок — від найдешевших і найважливіших до змістовних.
|
||||
// Перевіряється і початковий шлях, і той, у який він розкрився: інакше
|
||||
// посилання з дозволеного каталогу читало б що завгодно.
|
||||
func read(p *Policy, path string, maxBytes int64) (*File, string) {
|
||||
if reason := Denied(path); reason != "" {
|
||||
return nil, reason
|
||||
}
|
||||
|
||||
real, err := filepath.EvalSymlinks(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, "файл зник"
|
||||
}
|
||||
return nil, "не вдалося розкрити шлях: " + err.Error()
|
||||
}
|
||||
if normPath(real) != normPath(path) {
|
||||
if reason := Denied(real); reason != "" {
|
||||
return nil, "посилання веде на " + real + ": " + reason
|
||||
}
|
||||
if !p.Allows(real) {
|
||||
// Найтонше місце всієї перевірки. /etc/resolv.conf на
|
||||
// systemd — посилання в /run; дозволити перехід «бо початок
|
||||
// шляху дозволений» означало б дозволити й посилання на
|
||||
// /etc/shadow, покладене поруч. Тому ціль мусить бути
|
||||
// дозволена сама по собі, а відмова називає її явно, щоб
|
||||
// адміністратор міг дописати рядок свідомо.
|
||||
return nil, "посилання веде за межі дозволеного: " + real
|
||||
}
|
||||
}
|
||||
|
||||
st, err := os.Stat(real)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, "файл зник"
|
||||
}
|
||||
return nil, err.Error()
|
||||
}
|
||||
if st.IsDir() {
|
||||
return nil, "це каталог"
|
||||
}
|
||||
if !st.Mode().IsRegular() {
|
||||
return nil, "це не звичайний файл (" + st.Mode().String() + ")"
|
||||
}
|
||||
if !p.AllowNonPublic && st.Mode().Perm()&0o004 == 0 {
|
||||
// Права 0640 має /etc/shadow, 0600 — приватні ключі, 0644 —
|
||||
// nginx.conf, interfaces і main.cf. Одне правило відрізає рівно
|
||||
// перший клас, не зачепивши другого.
|
||||
return nil, fmt.Sprintf("файл закритий для читання всіма (%v) — "+
|
||||
"конфігом такий не вважаємо; дозволяється директивою !allow-nonpublic", st.Mode().Perm())
|
||||
}
|
||||
if st.Size() > maxBytes {
|
||||
return nil, fmt.Sprintf("завеликий: %d Б при межі %d Б", st.Size(), maxBytes)
|
||||
}
|
||||
|
||||
f, err := os.Open(real)
|
||||
if err != nil {
|
||||
return nil, err.Error()
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Читаємо через LimitReader попри перевірку розміру: між Stat і
|
||||
// Open файл могли переписати, і саме на це розраховує той, хто
|
||||
// хотів би згодувати зонду гігабайт.
|
||||
body, err := io.ReadAll(io.LimitReader(f, maxBytes+1))
|
||||
if err != nil {
|
||||
return nil, err.Error()
|
||||
}
|
||||
if int64(len(body)) > maxBytes {
|
||||
return nil, fmt.Sprintf("завеликий: понад %d Б", maxBytes)
|
||||
}
|
||||
|
||||
if reason := notText(body); reason != "" {
|
||||
return nil, reason
|
||||
}
|
||||
|
||||
return &File{
|
||||
Path: filepath.ToSlash(path),
|
||||
Mode: st.Mode().Perm(),
|
||||
Owner: owner(st),
|
||||
Size: int64(len(body)),
|
||||
Body: body,
|
||||
}, ""
|
||||
}
|
||||
|
||||
// notText — та сама думка, що й у ncmx.looksLikeConfig, перекладена на
|
||||
// файли.
|
||||
//
|
||||
// Порогу «щонайменше п'ять рядків» звідти тут НЕМАЄ, і це свідомо:
|
||||
// /etc/resolv.conf має два рядки й лишається конфігом. Що переноситься —
|
||||
// це головне: зібране, що не є текстом, не має ставати версією конфігу.
|
||||
// Двійковий файл, покладений у Git, робить історію нечитабельною, а diff
|
||||
// безглуздим, і помічають це через півроку.
|
||||
func notText(body []byte) string {
|
||||
if len(body) == 0 {
|
||||
return "файл порожній"
|
||||
}
|
||||
for _, b := range body {
|
||||
if b == 0 {
|
||||
return "двійковий файл (нульовий байт)"
|
||||
}
|
||||
}
|
||||
if !utf8.Valid(body) {
|
||||
return "не текст у UTF-8"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
116
agent/internal/localfiles/deny.go
Normal file
116
agent/internal/localfiles/deny.go
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
package localfiles
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Чорний перелік — другий рубіж, а не перший.
|
||||
//
|
||||
// Першим і головним лишається те, що сервер узагалі не називає шляхів:
|
||||
// читається тільки те, що дозволив адміністратор машини. Але дозволити
|
||||
// зайве можна й помилково — «/etc/ssh/*» пишеться так само легко, як
|
||||
// «/etc/ssh/sshd_config», — а ціна такої описки надто висока, щоб
|
||||
// покладатись на уважність.
|
||||
//
|
||||
// Тому цей перелік вкомпільований, перевіряється ПІСЛЯ дозволу й
|
||||
// перекриває його завжди. Вимкнути його не можна ні з сервера, ні
|
||||
// директивою в локальному файлі: усе, що сюди потрапило, не є конфігом
|
||||
// ні за яких обставин, а є або секретом, або не файлом узагалі.
|
||||
//
|
||||
// Звіряється і початковий шлях, і той, у який він розкрився після
|
||||
// символьних посилань.
|
||||
|
||||
// denyDirs — каталоги, з яких не читається нічого.
|
||||
var denyDirs = []string{
|
||||
// Не файли: віртуальні файлові системи ядра. Читання /proc/self/mem
|
||||
// або /dev/mem — це не збір конфігу, а знімок чужої пам'яті.
|
||||
"/proc/", "/sys/", "/dev/",
|
||||
// Секрети за домовленістю: сюди їх кладуть Docker і Kubernetes.
|
||||
"/run/secrets/",
|
||||
// Приватні частини PKI.
|
||||
"/etc/ssl/private/", "/etc/pki/tls/private/",
|
||||
// Власне господарство зонда: там лежить його токен до сервера.
|
||||
// Дозволити зонду вивантажити на сервер власне посвідчення означало
|
||||
// б зробити з нього інструмент захоплення сусідніх зондів.
|
||||
"/etc/netpulse/", "/var/lib/netpulse/",
|
||||
"c:/programdata/netpulse/",
|
||||
}
|
||||
|
||||
// denyExact — окремі файли, які ніколи не конфіг.
|
||||
var denyExact = []string{
|
||||
"/etc/shadow", "/etc/shadow-", "/etc/gshadow", "/etc/gshadow-",
|
||||
"/etc/security/opasswd", "/etc/krb5.keytab",
|
||||
}
|
||||
|
||||
// denySegments — будь-який рівень шляху з таким іменем.
|
||||
var denySegments = []string{
|
||||
".ssh", // ключі доступу, свої й чужі
|
||||
".gnupg", //
|
||||
".git", // історія разом з усім, що в неї колись поклали
|
||||
"private", // /etc/*/private/ у кількох дистрибутивів
|
||||
"passwords", //
|
||||
}
|
||||
|
||||
// denyNames — маски імені файлу на будь-якому рівні.
|
||||
//
|
||||
// Ключі й «файли оточення» — найдорожче, що взагалі буває в /etc, і
|
||||
// саме вони найчастіше лежать поруч зі справжніми конфігами: nginx.conf
|
||||
// і ssl.key в одному каталозі — звичайна розкладка.
|
||||
var denyNames = []string{
|
||||
"id_rsa*", "id_dsa*", "id_ecdsa*", "id_ed25519*",
|
||||
"*.key", "*.pem", "*.pfx", "*.p12", "*.jks", "*.keystore", "*.kdbx",
|
||||
".env", ".env.*", "*.env",
|
||||
".netrc", ".pgpass", ".my.cnf", "*.htpasswd",
|
||||
"*_key", "*-key", "*.secret", "*secrets.y*ml",
|
||||
// Файли паролів за ІМЕНЕМ, а не лише за повним шляхом.
|
||||
//
|
||||
// denyExact вище звіряє «/etc/shadow» буквально й промахується, щойно
|
||||
// корінь інший: контейнер із примонтованим /hostetc, chroot, образ,
|
||||
// розпакований у /srv/backup. Живою перевіркою на стенді саме це й
|
||||
// знайшлось: «/hostetc/shadow» повного збігу не дав і тримався лише
|
||||
// на правилі про права — тобто впав би від однієї директиви
|
||||
// !allow-nonpublic.
|
||||
"shadow", "shadow-", "gshadow", "gshadow-",
|
||||
}
|
||||
|
||||
// Denied повертає причину відмови або порожній рядок.
|
||||
func Denied(path string) string {
|
||||
np := normPath(path)
|
||||
|
||||
for _, d := range denyDirs {
|
||||
if strings.HasPrefix(np, d) {
|
||||
return "каталог " + strings.TrimSuffix(d, "/") + " закритий назавжди"
|
||||
}
|
||||
}
|
||||
for _, f := range denyExact {
|
||||
if np == f {
|
||||
return "файл із паролями"
|
||||
}
|
||||
}
|
||||
|
||||
segs := splitSegments(np)
|
||||
for i, s := range segs {
|
||||
// Останній сегмент — ім'я файлу; його перевіряють маски нижче,
|
||||
// а як «каталог» він рахуватись не повинен: файл із іменем
|
||||
// «private» — це просто файл.
|
||||
if i == len(segs)-1 {
|
||||
break
|
||||
}
|
||||
for _, d := range denySegments {
|
||||
if s == d {
|
||||
return "шлях проходить через «" + d + "»"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(segs) > 0 {
|
||||
name := segs[len(segs)-1]
|
||||
for _, pat := range denyNames {
|
||||
if ok, err := filepath.Match(pat, name); err == nil && ok {
|
||||
return "ім'я підпадає під заборонену маску «" + pat + "»"
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
254
agent/internal/localfiles/localfiles_test.go
Normal file
254
agent/internal/localfiles/localfiles_test.go
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
package localfiles
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParsePolicyDirectives(t *testing.T) {
|
||||
p, err := ParsePolicy(strings.NewReader(`
|
||||
# перелік
|
||||
/etc/nginx/nginx.conf
|
||||
/etc/nginx/conf.d/*.conf
|
||||
|
||||
!max-files 3
|
||||
!max-file-bytes 1024
|
||||
!allow-nonpublic
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(p.Allow) != 2 {
|
||||
t.Fatalf("очікували 2 зразки, маємо %d", len(p.Allow))
|
||||
}
|
||||
if p.MaxFiles != 3 || p.MaxFileBytes != 1024 || !p.AllowNonPublic {
|
||||
t.Fatalf("директиви не застосувались: %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// Головна перевірка політики: широкий зразок не має проходити взагалі.
|
||||
//
|
||||
// «/etc/*» пишеться так само легко, як «/etc/nginx/nginx.conf», а
|
||||
// коштує на кілька порядків більше — саме на такій описці й з'являється
|
||||
// віддалене читання всього /etc.
|
||||
func TestParsePolicyRejectsBroad(t *testing.T) {
|
||||
for _, bad := range []string{"/etc/*", "/*", "/*/*.conf", "etc/nginx.conf", "/etc/../etc/shadow"} {
|
||||
if _, err := ParsePolicy(strings.NewReader(bad)); err == nil {
|
||||
t.Errorf("зразок %q мав бути відхилений", bad)
|
||||
}
|
||||
}
|
||||
for _, good := range []string{"/etc/nginx/nginx.conf", "/etc/nginx/conf.d/*.conf", "/etc/hosts"} {
|
||||
if _, err := ParsePolicy(strings.NewReader(good)); err != nil {
|
||||
t.Errorf("зразок %q мав пройти: %v", good, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Чорний перелік перекриває дозвіл завжди.
|
||||
func TestDenied(t *testing.T) {
|
||||
deny := []string{
|
||||
"/etc/shadow",
|
||||
"/etc/gshadow",
|
||||
// Не лише за повним шляхом: у контейнері з примонтованим /etc
|
||||
// корінь інший, а файл той самий.
|
||||
"/hostetc/shadow",
|
||||
"/srv/backup/rootfs/etc/shadow",
|
||||
"/proc/self/mem",
|
||||
"/dev/mem",
|
||||
"/home/ops/.ssh/authorized_keys",
|
||||
"/etc/ssh/ssh_host_ed25519_key",
|
||||
"/etc/ssl/private/site.pem",
|
||||
"/srv/app/.env",
|
||||
"/var/lib/netpulse/identity.json",
|
||||
"/run/secrets/db_password",
|
||||
}
|
||||
for _, p := range deny {
|
||||
if Denied(p) == "" {
|
||||
t.Errorf("%s мав бути заборонений", p)
|
||||
}
|
||||
}
|
||||
allow := []string{
|
||||
"/etc/nginx/nginx.conf",
|
||||
"/etc/network/interfaces",
|
||||
"/etc/postfix/main.cf",
|
||||
"/etc/hosts",
|
||||
"/etc/ssh/sshd_config",
|
||||
}
|
||||
for _, p := range allow {
|
||||
if r := Denied(p); r != "" {
|
||||
t.Errorf("%s заборонений даремно: %s", p, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimitsNarrowOnly(t *testing.T) {
|
||||
base := Limits{MaxFileBytes: 100, MaxFiles: 10, MaxTotalBytes: 1000}
|
||||
|
||||
got := base.Narrow(Limits{MaxFileBytes: 50, MaxFiles: 0, MaxTotalBytes: 9999})
|
||||
if got.MaxFileBytes != 50 {
|
||||
t.Errorf("звуження не спрацювало: %d", got.MaxFileBytes)
|
||||
}
|
||||
if got.MaxFiles != 10 {
|
||||
t.Errorf("нуль мав означати «не звужую», маємо %d", got.MaxFiles)
|
||||
}
|
||||
if got.MaxTotalBytes != 1000 {
|
||||
t.Errorf("сервер розширив стелю до %d — цього не мало статись", got.MaxTotalBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotText(t *testing.T) {
|
||||
if notText([]byte("server {\n}\n")) != "" {
|
||||
t.Error("текст визнано не текстом")
|
||||
}
|
||||
if notText(nil) == "" {
|
||||
t.Error("порожній файл мав бути відхилений")
|
||||
}
|
||||
if notText([]byte{'a', 0, 'b'}) == "" {
|
||||
t.Error("двійковий файл мав бути відхилений")
|
||||
}
|
||||
if notText([]byte{0xff, 0xfe, 0xfd}) == "" {
|
||||
t.Error("не-UTF8 мав бути відхилений")
|
||||
}
|
||||
}
|
||||
|
||||
// Збір цілком: справжні файли на диску, справжні права, справжні
|
||||
// посилання. Саме тут ловиться те, чого не видно в юніт-перевірках.
|
||||
func TestCollect(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("права доступу перевіряються лише на unix")
|
||||
}
|
||||
// Каталог розкриваємо одразу: на деяких системах /tmp сам є
|
||||
// посиланням, і тоді перевірка «ціль посилання теж дозволена»
|
||||
// відхилила б геть усе — не через помилку в коді, а через оточення.
|
||||
dir, err := filepath.EvalSymlinks(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
etc := filepath.Join(dir, "etc", "app")
|
||||
if err := os.MkdirAll(etc, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
write := func(name, body string, mode os.FileMode) string {
|
||||
p := filepath.Join(etc, name)
|
||||
if err := os.WriteFile(p, []byte(body), mode); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chmod(p, mode); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
write("main.conf", "listen 80;\n", 0o644)
|
||||
write("extra.conf", "gzip on;\n", 0o644)
|
||||
// Закритий для «інших» — має бути пропущений, як /etc/shadow.
|
||||
write("private.conf", "password = hunter2\n", 0o600)
|
||||
// Двійковий — теж пропуск.
|
||||
write("blob.conf", "a\x00b\n", 0o644)
|
||||
|
||||
pol := &Policy{
|
||||
Allow: []string{filepath.Join(etc, "*.conf")},
|
||||
MaxFileBytes: 1 << 20,
|
||||
MaxFiles: 10,
|
||||
MaxTotalBytes: 1 << 20,
|
||||
}
|
||||
|
||||
res, err := Collect(context.Background(), pol, Limits{}, "test-host")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.Files) != 2 {
|
||||
t.Fatalf("очікували 2 файли, зібрано %d (%s)", len(res.Files), res.Report)
|
||||
}
|
||||
if len(res.Skips) != 2 {
|
||||
t.Fatalf("очікували 2 пропуски, маємо %d (%s)", len(res.Skips), res.Report)
|
||||
}
|
||||
body := string(res.Body)
|
||||
if strings.Contains(body, "hunter2") {
|
||||
t.Fatal("файл, закритий для читання всіма, потрапив у набір")
|
||||
}
|
||||
if !strings.Contains(body, "listen 80;") || !strings.Contains(body, "test-host") {
|
||||
t.Fatalf("у наборі бракує вмісту або відбитка машини:\n%s", body)
|
||||
}
|
||||
|
||||
// Другий збір без змін на диску мусить дати той самий байт-у-байт
|
||||
// набір: інакше кожен нічний бекап створював би коміт «змін немає».
|
||||
again, err := Collect(context.Background(), pol, Limits{}, "test-host")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(again.Body) != body {
|
||||
t.Fatal("повторний збір дав інший вміст — кожен бекап виглядав би зміною")
|
||||
}
|
||||
}
|
||||
|
||||
// Посилання за межі дозволеного не читається.
|
||||
//
|
||||
// Найтонше місце всієї перевірки: без нього рядок «/etc/app/*.conf» у
|
||||
// політиці означав би «будь-що, на що з цього каталогу вкаже посилання».
|
||||
func TestCollectRefusesSymlinkEscape(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("символьні посилання на windows потребують окремих прав")
|
||||
}
|
||||
dir, err := filepath.EvalSymlinks(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
etc := filepath.Join(dir, "etc")
|
||||
secret := filepath.Join(dir, "secret")
|
||||
for _, d := range []string{etc, secret} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// Ім'я цілі навмисно «нешкідливе»: назви на кшталт shadow ловить
|
||||
// чорний перелік, і перевірка мовчки перестала б перевіряти саме те,
|
||||
// заради чого написана, — заборону виходу за межі дозволеного.
|
||||
if err := os.WriteFile(filepath.Join(secret, "db.conf"), []byte("password=1\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(filepath.Join(secret, "db.conf"), filepath.Join(etc, "sneaky.conf")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pol := &Policy{
|
||||
Allow: []string{filepath.Join(etc, "*.conf")},
|
||||
MaxFileBytes: 1 << 20,
|
||||
MaxFiles: 10,
|
||||
MaxTotalBytes: 1 << 20,
|
||||
}
|
||||
res, cerr := Collect(context.Background(), pol, Limits{}, "test-host")
|
||||
if cerr == nil && len(res.Files) > 0 {
|
||||
t.Fatal("посилання за межі дозволеного прочиталось")
|
||||
}
|
||||
if len(res.Skips) != 1 || !strings.Contains(res.Skips[0].Reason, "за межі") {
|
||||
t.Fatalf("очікували пропуск із поясненням, маємо %+v", res.Skips)
|
||||
}
|
||||
}
|
||||
|
||||
// Порожній набір у сховище не йде — та сама думка, що й у
|
||||
// ncmx.looksLikeConfig: зібране, що не схоже на конфіг, не зберігається.
|
||||
func TestCollectRefusesEmptySet(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pol := &Policy{
|
||||
Allow: []string{filepath.Join(dir, "etc", "nothing.conf")},
|
||||
MaxFileBytes: 1 << 20,
|
||||
MaxFiles: 10,
|
||||
MaxTotalBytes: 1 << 20,
|
||||
}
|
||||
if _, err := Collect(context.Background(), pol, Limits{}, "h"); err == nil {
|
||||
t.Fatal("порожній набір мав дати відмову")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPolicyMissingFile(t *testing.T) {
|
||||
_, err := LoadPolicy(filepath.Join(t.TempDir(), "files.conf"))
|
||||
if err == nil || !strings.Contains(err.Error(), "не дозволено") {
|
||||
t.Fatalf("відсутній перелік мав дати ErrNoPolicy, маємо %v", err)
|
||||
}
|
||||
}
|
||||
66
agent/internal/localfiles/machine.go
Normal file
66
agent/internal/localfiles/machine.go
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
package localfiles
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Machine — відбиток машини, з якої знято набір.
|
||||
//
|
||||
// # Навіщо взагалі
|
||||
//
|
||||
// inv.devices.agent_id досі означав «який зонд ОПИТУЄ цей хост» — між
|
||||
// зондом і хостом лежала мережа, і переставити зонд означало лише
|
||||
// змінити маршрут до незмінного пристрою. Тут інакше: хост — це і є
|
||||
// машина зонда, і перенесення зонда змінює не маршрут, а сам предмет
|
||||
// збору.
|
||||
//
|
||||
// А посвідчення зонда — звичайний файл. Його копіюють разом з образом
|
||||
// контейнера, переносять при міграції, відновлюють із бекапу на іншому
|
||||
// залізі. У всіх цих випадках agent_id лишається тим самим, і без
|
||||
// відбитка історія сервера А мовчки продовжилась би файлами сервера Б у
|
||||
// тій самій гілці Git. Це найгірший різновид помилки в архіві: він
|
||||
// виглядає справним рівно до того дня, коли з нього треба відновитись.
|
||||
//
|
||||
// # З чого складається
|
||||
//
|
||||
// Ім'я хоста плюс machine-id. Кожна половина сама по собі ненадійна:
|
||||
// ім'я хоста міняють, machine-id на Linux іноді копіюють разом з
|
||||
// образом. Разом вони дають достатньо: щоб збіглись обидві, це має бути
|
||||
// та сама машина або її точна копія — а точна копія в ролі «того самого
|
||||
// сервера» і є те, чого архів не мусить розрізняти.
|
||||
//
|
||||
// Відбиток їде В ТІЛІ набору (перші рядки) І в заголовку вивантаження.
|
||||
// Перше робить переїзд видимим у diff, друге дозволяє серверу відмовити
|
||||
// в прийомі до того, як чужі файли ляжуть у чужу історію.
|
||||
func Machine() string {
|
||||
host, err := os.Hostname()
|
||||
if err != nil || strings.TrimSpace(host) == "" {
|
||||
host = "?"
|
||||
}
|
||||
id := machineID()
|
||||
if id == "" {
|
||||
return host
|
||||
}
|
||||
return host + " · " + id
|
||||
}
|
||||
|
||||
// machineID шукає стабільний ідентифікатор машини.
|
||||
//
|
||||
// Windows тут свідомо не покритий: його MachineGuid лежить у реєстрі, а
|
||||
// читання реєстру тягне golang.org/x/sys/windows у бінарник, який має
|
||||
// лишатись єдиним і маленьким. Наслідок чесно названий: під Windows
|
||||
// відбиток складається з самого лише імені хоста, тобто переїзд зонда
|
||||
// на машину з таким самим іменем пройде непоміченим.
|
||||
func machineID() string {
|
||||
for _, p := range []string{"/etc/machine-id", "/var/lib/dbus/machine-id"} {
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if s := strings.TrimSpace(string(b)); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
24
agent/internal/localfiles/owner_unix.go
Normal file
24
agent/internal/localfiles/owner_unix.go
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
//go:build !windows
|
||||
|
||||
package localfiles
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"strconv"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// owner — власник файлу числами, без os/user.
|
||||
//
|
||||
// Числа, а не імена: розкладка імен читається з /etc/passwd, а це ще
|
||||
// один файл, який довелося б відкривати з тими самими питаннями про
|
||||
// права. Для «що змінилось» пари чисел досить: зміна власника конфігу
|
||||
// видно як зміну рядка в diff, а хто такий 33 — знає той, хто цей
|
||||
// сервер адмініструє.
|
||||
func owner(fi fs.FileInfo) string {
|
||||
st, ok := fi.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return "?"
|
||||
}
|
||||
return strconv.FormatUint(uint64(st.Uid), 10) + ":" + strconv.FormatUint(uint64(st.Gid), 10)
|
||||
}
|
||||
14
agent/internal/localfiles/owner_windows.go
Normal file
14
agent/internal/localfiles/owner_windows.go
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
//go:build windows
|
||||
|
||||
package localfiles
|
||||
|
||||
import "io/fs"
|
||||
|
||||
// owner на Windows не визначається.
|
||||
//
|
||||
// Там власник — це SID у списку контролю доступу, а не пара чисел у
|
||||
// inode; дістати його без залежності від golang.org/x/sys/windows не
|
||||
// вийде, а прикидатись, що визначили, гірше, ніж чесно не визначати.
|
||||
// Це одна з причин, чому збір під Windows у цій версії неповний
|
||||
// (див. коментар у machine.go).
|
||||
func owner(fi fs.FileInfo) string { return "-" }
|
||||
260
agent/internal/localfiles/policy.go
Normal file
260
agent/internal/localfiles/policy.go
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
// Package localfiles — збір конфіг-файлів САМОЇ машини, на якій працює
|
||||
// зонд, у той самий архів версій, що й конфіги комутаторів.
|
||||
//
|
||||
// # Чому перелік файлів живе тут, а не на сервері
|
||||
//
|
||||
// Зонд стоїть на сервері клієнта й читає файли його правами — зазвичай
|
||||
// root. Якби перелік «що прочитати» приходив із сервера, будь-хто з
|
||||
// правом ncm:write отримав би віддалене читання довільного файлу на
|
||||
// КОЖНІЙ машині, де стоїть зонд: /etc/shadow, приватні ключі, .env із
|
||||
// паролем бази — з усіх серверів одним запитом. Це не гіпотетична
|
||||
// загроза, а типова й найдорожча помилка систем цього класу.
|
||||
//
|
||||
// Тому в протоколі (LocalFilesJob) немає й не буде поля зі шляхом.
|
||||
// Перелік лежить у текстовому файлі на самій машині; редагувати його
|
||||
// може лише той, хто вже має на ній права. Сервер може попросити
|
||||
// зібрати — і не може сказати, що саме.
|
||||
//
|
||||
// Конфіг-файл тут з'являється попри те, що агент його свідомо не має
|
||||
// (див. config.Config): саме в цьому й сенс. Налаштування, яке мусить
|
||||
// бути недосяжним для сервера, не можна передати ані прапорцем із
|
||||
// unit-файла (він теж локальний, але туди не влізе тридцять шляхів),
|
||||
// ані тим паче з кабінету. Формат навмисно рядковий, без YAML: жодної
|
||||
// нової залежності й жодного зайвого кілобайта в бінарнику.
|
||||
package localfiles
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Типові стелі. Свідомо тісні: конфіг-файл сервера — це кілобайти, а не
|
||||
// мегабайти, і те, що в них не вміщується, майже напевно не конфіг.
|
||||
const (
|
||||
DefaultMaxFileBytes = 1 << 20 // 1 МіБ на файл
|
||||
DefaultMaxFiles = 64
|
||||
DefaultMaxTotalBytes = 8 << 20 // 8 МіБ на весь набір
|
||||
)
|
||||
|
||||
// Policy — рішення машини про те, що з неї вільно читати.
|
||||
type Policy struct {
|
||||
// Path — звідки прочитано; порожній, якщо політику склали в пам'яті.
|
||||
Path string
|
||||
|
||||
// Allow — зразки дозволених шляхів у порядку з файлу.
|
||||
Allow []string
|
||||
|
||||
MaxFileBytes int64
|
||||
MaxFiles int
|
||||
MaxTotalBytes int64
|
||||
|
||||
// AllowNonPublic знімає вимогу «файл читається всіма».
|
||||
//
|
||||
// Вимкнено типово, і це один із найдешевших запобіжників у всій
|
||||
// задачі: /etc/shadow має права 0640, приватні ключі — 0600, а
|
||||
// nginx.conf, interfaces і main.cf — 0644. Тобто одне правило
|
||||
// відрізає рівно той клас файлів, заради якого цю задачу й
|
||||
// перевіряють, не зачепивши жодного справжнього конфігу.
|
||||
//
|
||||
// Вмикається лише директивою в локальному файлі: це рішення
|
||||
// адміністратора САМОЇ машини, і з кабінету його не видно й не
|
||||
// змінити.
|
||||
AllowNonPublic bool
|
||||
}
|
||||
|
||||
// ErrNoPolicy — політики немає, збирати нічого.
|
||||
//
|
||||
// Окрема помилка, бо це найчастіший і цілком штатний стан: зонд
|
||||
// поставили заради пінгів, а конфіг-файли з нього ніхто не просив.
|
||||
// Відмова має звучати як інструкція, а не як поломка.
|
||||
var ErrNoPolicy = errors.New("на цій машині не дозволено збір конфіг-файлів")
|
||||
|
||||
// DefaultPolicyPath — де зонд шукає перелік.
|
||||
func DefaultPolicyPath() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
dir := os.Getenv("ProgramData")
|
||||
if dir == "" {
|
||||
dir = `C:\ProgramData`
|
||||
}
|
||||
return filepath.Join(dir, "NetPulse", "files.conf")
|
||||
}
|
||||
return "/etc/netpulse/files.conf"
|
||||
}
|
||||
|
||||
// LoadPolicy читає перелік дозволених файлів.
|
||||
//
|
||||
// Відсутній файл — це ErrNoPolicy, а не помилка читання: типовий зонд
|
||||
// його не має, і сипати в журнал «немає файлу» на кожному завданні
|
||||
// означало б навчити на цей журнал не дивитись.
|
||||
func LoadPolicy(path string) (*Policy, error) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return nil, ErrNoPolicy
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, fmt.Errorf("%w: немає %s", ErrNoPolicy, path)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
p, err := ParsePolicy(f)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
p.Path = path
|
||||
if len(p.Allow) == 0 {
|
||||
return nil, fmt.Errorf("%w: у %s немає жодного дозволеного шляху", ErrNoPolicy, path)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// ParsePolicy розбирає перелік.
|
||||
//
|
||||
// Формат: один зразок шляху на рядок, «#» — коментар, рядок із «!» —
|
||||
// директива. Порожній результат означає «нічого не дозволено»: типове
|
||||
// значення тут — заборона, і жодного «якщо не задано, беремо все»
|
||||
// не існує навмисно.
|
||||
func ParsePolicy(r io.Reader) (*Policy, error) {
|
||||
p := &Policy{
|
||||
MaxFileBytes: DefaultMaxFileBytes,
|
||||
MaxFiles: DefaultMaxFiles,
|
||||
MaxTotalBytes: DefaultMaxTotalBytes,
|
||||
}
|
||||
|
||||
sc := bufio.NewScanner(r)
|
||||
sc.Buffer(make([]byte, 0, 4096), 1<<20)
|
||||
line := 0
|
||||
for sc.Scan() {
|
||||
line++
|
||||
s := strings.TrimSpace(sc.Text())
|
||||
if s == "" || strings.HasPrefix(s, "#") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(s, "!") {
|
||||
if err := p.directive(strings.TrimPrefix(s, "!")); err != nil {
|
||||
return nil, fmt.Errorf("рядок %d: %w", line, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := validPattern(s); err != nil {
|
||||
return nil, fmt.Errorf("рядок %d: %w", line, err)
|
||||
}
|
||||
p.Allow = append(p.Allow, s)
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (p *Policy) directive(s string) error {
|
||||
name, arg, _ := strings.Cut(s, " ")
|
||||
arg = strings.TrimSpace(arg)
|
||||
|
||||
switch name {
|
||||
case "allow-nonpublic":
|
||||
p.AllowNonPublic = true
|
||||
return nil
|
||||
case "max-file-bytes", "max-total-bytes", "max-files":
|
||||
n, err := strconv.ParseInt(arg, 10, 64)
|
||||
if err != nil || n <= 0 {
|
||||
return fmt.Errorf("директива %q хоче додатне число, а не %q", name, arg)
|
||||
}
|
||||
switch name {
|
||||
case "max-file-bytes":
|
||||
p.MaxFileBytes = n
|
||||
case "max-total-bytes":
|
||||
p.MaxTotalBytes = n
|
||||
case "max-files":
|
||||
p.MaxFiles = int(n)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("невідома директива %q", name)
|
||||
}
|
||||
}
|
||||
|
||||
// validPattern відсіює зразки, які дозволяють забагато.
|
||||
//
|
||||
// Три вимоги, і кожна закриває свій спосіб випадково відкрити всю
|
||||
// файлову систему рядком, що виглядає нешкідливо.
|
||||
func validPattern(pat string) error {
|
||||
if !filepath.IsAbs(pat) && !strings.HasPrefix(pat, "/") {
|
||||
return fmt.Errorf("шлях %q не абсолютний", pat)
|
||||
}
|
||||
if strings.Contains(pat, "..") {
|
||||
return fmt.Errorf("шлях %q містить «..»", pat)
|
||||
}
|
||||
|
||||
segs := splitSegments(pat)
|
||||
if len(segs) == 0 {
|
||||
return fmt.Errorf("порожній шлях")
|
||||
}
|
||||
// Зразок мусить назвати щонайменше два рівні буквально: «/*/*.conf»
|
||||
// і «/etc/*» відкривають надто багато, щоб це можна було написати
|
||||
// випадково й не помітити.
|
||||
fixed := 0
|
||||
for _, s := range segs {
|
||||
if strings.ContainsAny(s, "*?[") {
|
||||
break
|
||||
}
|
||||
fixed++
|
||||
}
|
||||
if fixed == 0 {
|
||||
return fmt.Errorf("шлях %q починається з маски", pat)
|
||||
}
|
||||
if fixed < 2 && len(segs) > 1 {
|
||||
return fmt.Errorf("шлях %q надто широкий: перші два рівні мають бути без масок", pat)
|
||||
}
|
||||
if _, err := filepath.Match(pat, pat); err != nil {
|
||||
return fmt.Errorf("некоректна маска %q: %w", pat, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Allows каже, чи потрапляє готовий шлях під дозволені зразки.
|
||||
//
|
||||
// Потрібне окремо від розкриття масок: після переходу за символьним
|
||||
// посиланням ми маємо ІНШИЙ шлях, і його треба звірити з переліком ще
|
||||
// раз — інакше посилання з дозволеного каталогу читало б будь-що.
|
||||
func (p *Policy) Allows(path string) bool {
|
||||
np := normPath(path)
|
||||
for _, pat := range p.Allow {
|
||||
if ok, err := filepath.Match(normPath(pat), np); err == nil && ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// normPath зводить шлях до однієї форми для порівняння.
|
||||
//
|
||||
// Windows: розділювачі й регістр. Без цього C:\ProgramData\... і
|
||||
// c:/programdata/... були б різними шляхами, і чорний перелік обходився
|
||||
// б зміною регістру.
|
||||
func normPath(p string) string {
|
||||
p = strings.ReplaceAll(p, `\`, "/")
|
||||
if runtime.GOOS == "windows" {
|
||||
p = strings.ToLower(p)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func splitSegments(p string) []string {
|
||||
var out []string
|
||||
for _, s := range strings.Split(normPath(p), "/") {
|
||||
if s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
76
agent/internal/modules/filecfg/filecfg.go
Normal file
76
agent/internal/modules/filecfg/filecfg.go
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
// Package filecfg — модуль зонда «конфіг-файли власної машини».
|
||||
//
|
||||
// Модуль без жодного типу чека, і це не недогляд. Реєстр модулів
|
||||
// вирішує два різні питання: «кому віддати задачу такого типу»
|
||||
// (CheckTypes) і «що взагалі вміє цей бінарник» (Compiled). Тут потрібне
|
||||
// лише друге: збір конфіг-файлів їде не розкладом чеків, а завданням
|
||||
// ConfigJob — тим самим шляхом, що й бекап комутатора.
|
||||
//
|
||||
// Реєстрація потрібна заради одного рядка в Hello.build.compiled_modules:
|
||||
// «цей зонд уміє читати локальні файли». Сервер сьогодні цього поля не
|
||||
// читає взагалі, тож зараз воно нікуди не впливає — але саме звідти
|
||||
// кабінет колись дізнаватиметься, кому пропонувати прив'язку «сервер =
|
||||
// машина зонда», а кому ні. Доки не читає, зонд без модуля відповідає
|
||||
// на завдання зрозумілою відмовою, і це видно в стані хоста.
|
||||
//
|
||||
// Активація сервером (ModuleControl) на збір НЕ впливає, і це навмисно.
|
||||
// Вимикач, яким керує сервер, не є захистом від сервера. Єдине, що
|
||||
// вирішує, чи буде щось прочитано, — локальна політика машини
|
||||
// (agent/internal/localfiles). Тримати поруч другий вимикач із кабінету
|
||||
// означало б натякати, що захист саме в ньому.
|
||||
package filecfg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"github.com/netpulse/netpulse/agent/internal/localfiles"
|
||||
"github.com/netpulse/netpulse/agent/internal/module"
|
||||
)
|
||||
|
||||
// Module читає конфіг-файли машини, на якій працює зонд.
|
||||
type Module struct {
|
||||
policyPath string
|
||||
|
||||
// Політика перечитується з диска перед кожним збором, а не
|
||||
// кешується назавжди: адміністратор машини правит перелік і має
|
||||
// побачити наслідок із наступним бекапом, а не після перезапуску
|
||||
// зонда. Мьютекс — бо завдання приходять паралельно.
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func New(policyPath string) *Module {
|
||||
if policyPath == "" {
|
||||
policyPath = localfiles.DefaultPolicyPath()
|
||||
}
|
||||
return &Module{policyPath: policyPath}
|
||||
}
|
||||
|
||||
func (m *Module) Key() string { return "filecfg" }
|
||||
func (m *Module) CheckTypes() []string { return nil }
|
||||
func (m *Module) Close() error { return nil }
|
||||
|
||||
// Run ніколи не викликається: без типів чеків реєстр не має чого сюди
|
||||
// маршрутизувати. Помилка замість тиші — щоб помилку маршрутизації було
|
||||
// видно одразу, а не як задачу, що «виконалась» без результату.
|
||||
func (m *Module) Run(context.Context, module.Task) (module.Result, error) {
|
||||
return module.Result{}, errors.New("filecfg не виконує чеків: збір іде завданням ConfigJob")
|
||||
}
|
||||
|
||||
// PolicyPath — де лежить локальний перелік дозволених файлів.
|
||||
func (m *Module) PolicyPath() string { return m.policyPath }
|
||||
|
||||
// Collect виконує збір за локальною політикою.
|
||||
//
|
||||
// srv — стелі з завдання; вони вміють лише звузити дозволене машиною.
|
||||
func (m *Module) Collect(ctx context.Context, srv localfiles.Limits) (localfiles.Result, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
pol, err := localfiles.LoadPolicy(m.policyPath)
|
||||
if err != nil {
|
||||
return localfiles.Result{}, err
|
||||
}
|
||||
return localfiles.Collect(ctx, pol, srv, localfiles.Machine())
|
||||
}
|
||||
|
|
@ -2,8 +2,9 @@
|
|||
//
|
||||
// Два типи чеків:
|
||||
//
|
||||
// snmp.if — лічильники інтерфейсів (те, що живить анімацію трафіку)
|
||||
// snmp.get — довільні OID → узагальнені метрики
|
||||
// snmp.if — лічильники інтерфейсів (те, що живить анімацію трафіку)
|
||||
// snmp.get — довільні OID → узагальнені метрики
|
||||
// snmp.walk — обхід піддерева: які рядки має таблиця (див. walk.go)
|
||||
//
|
||||
// Свідоме рішення: агент НЕ ходить по ifTable, щоб з'ясувати, які
|
||||
// інтерфейси існують. Перелік (ifIndex → interface_id → speed_bps)
|
||||
|
|
@ -104,7 +105,7 @@ func New() *Module {
|
|||
}
|
||||
|
||||
func (m *Module) Key() string { return "snmp" }
|
||||
func (m *Module) CheckTypes() []string { return []string{"snmp.if", "snmp.get"} }
|
||||
func (m *Module) CheckTypes() []string { return []string{"snmp.if", "snmp.get", "snmp.walk"} }
|
||||
|
||||
func (m *Module) Close() error {
|
||||
m.mu.Lock()
|
||||
|
|
@ -125,6 +126,8 @@ func (m *Module) Run(ctx context.Context, task module.Task) (module.Result, erro
|
|||
return m.runInterfaces(ctx, client, task)
|
||||
case "get":
|
||||
return m.runGet(ctx, client, task)
|
||||
case "walk":
|
||||
return m.runWalk(ctx, client, task)
|
||||
default:
|
||||
return module.Result{}, fmt.Errorf("snmp: невідомий чек %q", task.CheckType)
|
||||
}
|
||||
|
|
|
|||
242
agent/internal/modules/snmp/walk.go
Normal file
242
agent/internal/modules/snmp/walk.go
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
package snmp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gosnmp/gosnmp"
|
||||
"github.com/netpulse/netpulse/agent/internal/module"
|
||||
"github.com/netpulse/netpulse/agent/internal/snmpx"
|
||||
)
|
||||
|
||||
// snmp.walk — виявлення рядків таблиці, а не збір метрик.
|
||||
//
|
||||
// Тип був оголошений у довіднику core.check_types від самого початку, а
|
||||
// модуля не існувало: система обіцяла те, чого не вміла. Тепер він є, і
|
||||
// робить рівно одне — каже, ЯКІ рядки має ця таблиця на цьому пристрої
|
||||
// зараз.
|
||||
//
|
||||
// Чому не збирає заразом і значення. Прототип шаблону описує метрики на
|
||||
// рядок; щойно перелік рядків відомий, підставити індекс і зібрати
|
||||
// числа вміє звичайний snmp.get — однією пачкою OID, як і решта метрик
|
||||
// шаблону. Зібрати все тут означало б завести другий, паралельний шлях
|
||||
// метрик із власним розкладом, власним інтервалом і власними багами
|
||||
// поруч із наявним. Натомість тут — та сама роль, що в модуля topology
|
||||
// для портів: знайти й доповісти, а що з цим робити, вирішує сервер.
|
||||
//
|
||||
// Наслідок, який видно в налаштуваннях: у прототипа ДВА інтервали.
|
||||
// Обхід таблиці рідкий (диски й сенсори не з'являються щохвилини),
|
||||
// збір значень частий. Один інтервал на обидві дії означав би або
|
||||
// щохвилинний walk усього піддерева, або графік, що з'являється лише
|
||||
// через годину після встановлення модуля.
|
||||
|
||||
// WalkParams — params_json для snmp.walk.
|
||||
//
|
||||
// Дзеркалить те, що складає сервер (server/internal/store/prototypes.go,
|
||||
// walkParams). Розбіжність між цією структурою й тим, що пише сервер, —
|
||||
// це чек, який мовчки нічого не знаходить: рівно та помилка, яку
|
||||
// виправляла міграція 0046. Тому обидві сторони й params_schema в
|
||||
// довіднику описують ОДНУ форму.
|
||||
type WalkParams struct {
|
||||
Prototypes []WalkPrototype `json:"prototypes"`
|
||||
}
|
||||
|
||||
// WalkPrototype — одне правило виявлення рядків.
|
||||
type WalkPrototype struct {
|
||||
// Ключ прототипу в шаблоні. Агент його не тлумачить — лише повертає
|
||||
// назад, бо інакше сервер не знав би, чий це перелік рядків.
|
||||
Key string `json:"key"`
|
||||
// Піддерево, обхід якого дає перелік індексів. Класично це колонка
|
||||
// з людською назвою рядка: hrStorageDescr, entPhysicalName, ifName.
|
||||
OID string `json:"oid"`
|
||||
// Звідки брати назву рядка: "value" (значення колонки) або "index"
|
||||
// (сам індекс). Друге потрібне для таблиць, де людської назви немає
|
||||
// взагалі, — тоді підписом рядка стає його номер.
|
||||
NameFrom string `json:"name_from"`
|
||||
// Стеля кількості рядків для цього прототипу. 0 — узяти типову.
|
||||
MaxRows int `json:"max_rows"`
|
||||
}
|
||||
|
||||
// WalkResult — те, що їде назад у CheckResult.payload_json.
|
||||
//
|
||||
// Через payload, а не через метрики: перелік рядків — це не вимір у
|
||||
// момент часу, і класти його в ts.samples не було б куди. Через
|
||||
// payload, а не через окремий ReportDiscovery: той звіт описує сусідів
|
||||
// і порти, має власний життєвий цикл run'ів і чистку застарілих
|
||||
// записів — приліпити до нього рядки чужих таблиць означало б зробити
|
||||
// його третьою сутністю замість двох.
|
||||
type WalkResult struct {
|
||||
Prototypes []WalkPrototypeRows `json:"prototypes"`
|
||||
}
|
||||
|
||||
// WalkPrototypeRows — знайдене за одним прототипом.
|
||||
type WalkPrototypeRows struct {
|
||||
Key string `json:"key"`
|
||||
Rows []WalkRow `json:"rows"`
|
||||
// Обхід уперся в стелю: перелік неповний, і сервер не сміє вважати
|
||||
// зниклими ті рядки, до яких просто не дійшли.
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
// Обхід не вдався. Порожній перелік і невдалий обхід — це різні
|
||||
// події: перше означає «таблиця порожня», друге — «ми не знаємо».
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// WalkRow — один рядок таблиці.
|
||||
type WalkRow struct {
|
||||
// Індекс — суфікс OID після кореня, як є: "1", "65536", "1.3.6"
|
||||
// для складених індексів. Рядком, а не числом, саме через складені:
|
||||
// entPhysicalIndex поміщається в int64, а індекс IP-адреси — ні.
|
||||
Index string `json:"index"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// Стеля рядків на один прототип.
|
||||
//
|
||||
// 256 — не про пам'ять, а про те, що з цих рядків буде складено
|
||||
// snmp.get-чек. Кожен рядок дає стільки OID, скільки метрик у
|
||||
// прототипі, а пачка ходить по 24 змінні за PDU: 256 рядків по чотири
|
||||
// метрики — це вже 43 PDU за такт. Пристрій, у якого таблиця більша,
|
||||
// майже завжди означає, що прототип описали надто широко (walk по
|
||||
// ifName замість walk по потрібній колонці), і мовчки опитувати все —
|
||||
// не послуга.
|
||||
const DefaultWalkMaxRows = 256
|
||||
|
||||
// Абсолютна стеля, вище за яку не підіймає навіть явне прохання.
|
||||
//
|
||||
// Обхід — це не Get: він триває стільки, скільки в таблиці рядків, і
|
||||
// не має жодної природної точки зупинки. Пристрій із розбитою таблицею
|
||||
// (а такі бувають: агент віддає піддерево, що не закінчується) інакше
|
||||
// тримав би задачу до самого таймаута щоразу.
|
||||
const hardWalkMaxRows = 4096
|
||||
|
||||
// errRowLimit зупиняє обхід зсередини колбека.
|
||||
//
|
||||
// gosnmp не має способу сказати «досить» інакше, ніж помилкою; ловимо
|
||||
// її на виході й перетворюємо на прапорець truncated.
|
||||
var errRowLimit = errors.New("досягнуто стелі рядків")
|
||||
|
||||
func (m *Module) runWalk(ctx context.Context, client *gosnmp.GoSNMP, task module.Task) (module.Result, error) {
|
||||
var p WalkParams
|
||||
if len(task.Params) > 0 {
|
||||
if err := json.Unmarshal(task.Params, &p); err != nil {
|
||||
return module.Result{}, fmt.Errorf("невалідні params для snmp.walk: %w", err)
|
||||
}
|
||||
}
|
||||
if len(p.Prototypes) == 0 {
|
||||
return module.Result{}, fmt.Errorf("snmp.walk без жодного прототипу: сервер має передати правила виявлення рядків")
|
||||
}
|
||||
|
||||
out := WalkResult{Prototypes: make([]WalkPrototypeRows, 0, len(p.Prototypes))}
|
||||
failed := 0
|
||||
|
||||
for _, proto := range p.Prototypes {
|
||||
// Дедлайн задачі перевіряємо між прототипами, а не лише
|
||||
// всередині обходу: чек із трьома таблицями має віддати те, що
|
||||
// встиг, а не втратити все через третю.
|
||||
if err := ctx.Err(); err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
res := WalkPrototypeRows{Key: proto.Key, Rows: []WalkRow{}}
|
||||
rows, truncated, err := walkRows(ctx, client, proto)
|
||||
switch {
|
||||
case err != nil:
|
||||
res.Error = err.Error()
|
||||
failed++
|
||||
default:
|
||||
res.Rows = rows
|
||||
res.Truncated = truncated
|
||||
}
|
||||
out.Prototypes = append(out.Prototypes, res)
|
||||
}
|
||||
|
||||
// Один невдалий прототип із трьох — це не невдалий чек: сервер
|
||||
// побачить помилку в його власному полі й лишить рядки цієї
|
||||
// таблиці як були. А от коли не вдалося нічого, чек мусить впасти
|
||||
// по-справжньому: інакше «пристрій не відповідає» виглядало б як
|
||||
// «таблиці порожні», і всі метрики прототипів тихо зникли б.
|
||||
if failed == len(out.Prototypes) && failed > 0 {
|
||||
return module.Result{}, fmt.Errorf("жоден з %d прототипів не обійшовся: %s",
|
||||
failed, out.Prototypes[0].Error)
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
return module.Result{}, err
|
||||
}
|
||||
return module.Result{Payload: payload}, nil
|
||||
}
|
||||
|
||||
// walkRows обходить піддерево одного прототипу.
|
||||
func walkRows(ctx context.Context, client *gosnmp.GoSNMP, proto WalkPrototype) ([]WalkRow, bool, error) {
|
||||
root := snmpx.Normalize(proto.OID)
|
||||
if strings.Trim(root, ".") == "" {
|
||||
return nil, false, fmt.Errorf("прототип %q без OID виявлення", proto.Key)
|
||||
}
|
||||
|
||||
limit := proto.MaxRows
|
||||
if limit <= 0 {
|
||||
limit = DefaultWalkMaxRows
|
||||
}
|
||||
if limit > hardWalkMaxRows {
|
||||
limit = hardWalkMaxRows
|
||||
}
|
||||
|
||||
byIndex := proto.NameFrom == "index"
|
||||
|
||||
var (
|
||||
rows []WalkRow
|
||||
truncated bool
|
||||
// Індекси повторюються, коли пристрій віддає піддерево з
|
||||
// перекриттям (буває на агентах із кількома контекстами).
|
||||
// Другий такий рядок дав би дубль метрики й другу серію з тим
|
||||
// самим підписом.
|
||||
seen = make(map[string]bool)
|
||||
)
|
||||
|
||||
err := snmpx.Walk(ctx, client, root, func(pdu gosnmp.SnmpPDU) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
idx := indexOf(root, snmpx.Normalize(pdu.Name))
|
||||
if idx == "" || seen[idx] {
|
||||
return nil
|
||||
}
|
||||
seen[idx] = true
|
||||
|
||||
name := idx
|
||||
if !byIndex {
|
||||
if v := snmpx.AsString(pdu); v != "" {
|
||||
name = v
|
||||
}
|
||||
}
|
||||
rows = append(rows, WalkRow{Index: idx, Name: name})
|
||||
|
||||
if len(rows) >= limit {
|
||||
truncated = true
|
||||
return errRowLimit
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil && !errors.Is(err, errRowLimit) {
|
||||
return nil, false, fmt.Errorf("обхід %s: %w", root, err)
|
||||
}
|
||||
return rows, truncated, nil
|
||||
}
|
||||
|
||||
// indexOf вирізає індекс рядка з повного OID.
|
||||
//
|
||||
// Порожній рядок означає «це не з нашого піддерева». Таке приходить
|
||||
// від агентів, які на кінці обходу віддають перший OID за межами
|
||||
// піддерева замість endOfMibView; порахувати його рядком означало б
|
||||
// завести метрику з чужим індексом.
|
||||
func indexOf(root, full string) string {
|
||||
prefix := strings.TrimRight(root, ".") + "."
|
||||
if !strings.HasPrefix(full, prefix) {
|
||||
return ""
|
||||
}
|
||||
return strings.Trim(full[len(prefix):], ".")
|
||||
}
|
||||
95
agent/internal/modules/snmp/walk_test.go
Normal file
95
agent/internal/modules/snmp/walk_test.go
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
package snmp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Індекс рядка — це те, що відрізняє «диск /» від «диска /var», і
|
||||
// помилка тут не падає, а тихо змішує два рядки в один.
|
||||
func TestIndexOf(t *testing.T) {
|
||||
cases := []struct {
|
||||
root, full, want string
|
||||
}{
|
||||
// Звичайний рядок таблиці.
|
||||
{".1.3.6.1.2.1.25.2.3.1.3", ".1.3.6.1.2.1.25.2.3.1.3.1", "1"},
|
||||
// Корінь із зайвою крапкою на кінці — так його інколи пишуть у
|
||||
// документації вендора, і відмовляти через це немає підстав.
|
||||
{".1.3.6.1.2.1.25.2.3.1.3.", ".1.3.6.1.2.1.25.2.3.1.3.31", "31"},
|
||||
// Складений індекс: беремо цілком, бо саме він адресує рядок.
|
||||
{".1.3.6.1.2.1.4.20.1.1", ".1.3.6.1.2.1.4.20.1.1.10.0.0.1", "10.0.0.1"},
|
||||
// Кінець обходу: агент віддав перший OID за межами піддерева.
|
||||
// Порахувати його рядком означало б завести метрику з чужим
|
||||
// індексом — саме те, від чого ця перевірка.
|
||||
{".1.3.6.1.2.1.25.2.3.1.3", ".1.3.6.1.2.1.25.2.3.1.4.1", ""},
|
||||
// Сам корінь без індексу рядком не є.
|
||||
{".1.3.6.1.2.1.25.2.3.1.3", ".1.3.6.1.2.1.25.2.3.1.3", ""},
|
||||
// Сусіднє піддерево з тим самим початком цифр.
|
||||
{".1.3.6.1.4.1.9.1", ".1.3.6.1.4.1.9.10.1", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := indexOf(c.root, c.full); got != c.want {
|
||||
t.Errorf("indexOf(%q, %q) = %q, хотіли %q", c.root, c.full, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Форма params — це контракт із сервером і з params_schema в довіднику.
|
||||
// Розходження між ними вже коштувало проєкту міграції 0046: схема
|
||||
// описувала не той тип, ніж читає агент, і чек мовчки нічого не збирав.
|
||||
func TestWalkParamsShape(t *testing.T) {
|
||||
// Рівно те, що складає server/internal/store/prototypes.go.
|
||||
raw := `{"prototypes":[
|
||||
{"key":"storage","oid":".1.3.6.1.2.1.25.2.3.1.3","name_from":"value","max_rows":64},
|
||||
{"key":"sensors","oid":".1.3.6.1.2.1.99.1.1.1.1","name_from":"index"}
|
||||
]}`
|
||||
|
||||
var p WalkParams
|
||||
if err := json.Unmarshal([]byte(raw), &p); err != nil {
|
||||
t.Fatalf("params не читаються: %v", err)
|
||||
}
|
||||
if len(p.Prototypes) != 2 {
|
||||
t.Fatalf("прототипів %d, хотіли 2", len(p.Prototypes))
|
||||
}
|
||||
if p.Prototypes[0].Key != "storage" || p.Prototypes[0].MaxRows != 64 {
|
||||
t.Errorf("перший прототип розібрано не так: %+v", p.Prototypes[0])
|
||||
}
|
||||
// Відсутній max_rows має означати «типова стеля», а не «жодного
|
||||
// рядка»: нуль тут — це порожній чек без жодного повідомлення.
|
||||
if p.Prototypes[1].MaxRows != 0 {
|
||||
t.Errorf("max_rows без значення має лишитись нулем, а не %d", p.Prototypes[1].MaxRows)
|
||||
}
|
||||
if p.Prototypes[1].NameFrom != "index" {
|
||||
t.Errorf("name_from розібрано як %q", p.Prototypes[1].NameFrom)
|
||||
}
|
||||
}
|
||||
|
||||
// Відповідь агента читає сервер, і порожній перелік рядків мусить
|
||||
// відрізнятись від невдалого обходу: перше означає «таблиця порожня»,
|
||||
// друге — «ми не знаємо». Сплутати їх означає стерти всі метрики
|
||||
// прототипу на першому ж таймауті.
|
||||
func TestWalkResultDistinguishesEmptyFromFailure(t *testing.T) {
|
||||
res := WalkResult{Prototypes: []WalkPrototypeRows{
|
||||
{Key: "empty", Rows: []WalkRow{}},
|
||||
{Key: "broken", Rows: []WalkRow{}, Error: "обхід .1.3: таймаут"},
|
||||
{Key: "big", Rows: []WalkRow{{Index: "1", Name: "/"}}, Truncated: true},
|
||||
}}
|
||||
b, err := json.Marshal(res)
|
||||
if err != nil {
|
||||
t.Fatalf("payload не серіалізується: %v", err)
|
||||
}
|
||||
|
||||
var back WalkResult
|
||||
if err := json.Unmarshal(b, &back); err != nil {
|
||||
t.Fatalf("payload не читається назад: %v", err)
|
||||
}
|
||||
if back.Prototypes[0].Error != "" || back.Prototypes[0].Rows == nil {
|
||||
t.Errorf("порожня таблиця має їхати як порожній перелік без помилки: %+v", back.Prototypes[0])
|
||||
}
|
||||
if back.Prototypes[1].Error == "" {
|
||||
t.Errorf("невдалий обхід втратив причину: %+v", back.Prototypes[1])
|
||||
}
|
||||
if !back.Prototypes[2].Truncated {
|
||||
t.Errorf("обрізаний перелік втратив прапорець: %+v", back.Prototypes[2])
|
||||
}
|
||||
}
|
||||
|
|
@ -67,8 +67,12 @@ const (
|
|||
|
||||
// Params — вміст params_json для topology.discover.
|
||||
type Params struct {
|
||||
// lldp | cdp | arp | fdb. Порожньо — lldp + cdp.
|
||||
Protos []string `json:"protos"`
|
||||
// lldp | cdp | arp | fdb.
|
||||
//
|
||||
// Вказівник, а не зріз, бо «поля немає» і «порожній список» тут
|
||||
// різні речі: перше означає типове lldp+cdp, друге — свідоме
|
||||
// «жодного протоколу», коли потрібен самий інвентар портів.
|
||||
Protos *[]string `json:"protos"`
|
||||
// Збирати інвентар портів разом із сусідами. Обидва беруться з
|
||||
// одного SNMP-обходу, тому окремий чек був би зайвим трафіком.
|
||||
CollectInterfaces *bool `json:"collect_interfaces"`
|
||||
|
|
@ -78,9 +82,19 @@ type Module struct{}
|
|||
|
||||
func New() *Module { return &Module{} }
|
||||
|
||||
func (m *Module) Key() string { return "topology" }
|
||||
func (m *Module) CheckTypes() []string { return []string{"topology.discover"} }
|
||||
func (m *Module) Close() error { return nil }
|
||||
// CheckTypeIdentify — розпізнавання пристрою: сама лише системна група.
|
||||
//
|
||||
// Окремий тип чека, а не набір параметрів `topology.discover`, хоч
|
||||
// робить його той самий код. Причина не технічна: у списку перевірок
|
||||
// хоста рядок мусить називати те, що робить, а «Neighbor Discovery»,
|
||||
// який не шукає жодного сусіда, — підказка, що бреше.
|
||||
const CheckTypeIdentify = "topology.identify"
|
||||
|
||||
func (m *Module) Key() string { return "topology" }
|
||||
func (m *Module) CheckTypes() []string {
|
||||
return []string{"topology.discover", CheckTypeIdentify}
|
||||
}
|
||||
func (m *Module) Close() error { return nil }
|
||||
|
||||
func (m *Module) Run(ctx context.Context, task module.Task) (module.Result, error) {
|
||||
var p Params
|
||||
|
|
@ -89,11 +103,18 @@ func (m *Module) Run(ctx context.Context, task module.Task) (module.Result, erro
|
|||
return module.Result{}, fmt.Errorf("невалідні params для topology.discover: %w", err)
|
||||
}
|
||||
}
|
||||
if len(p.Protos) == 0 {
|
||||
p.Protos = []string{"lldp", "cdp"}
|
||||
protos := []string{"lldp", "cdp"}
|
||||
if p.Protos != nil {
|
||||
protos = *p.Protos
|
||||
}
|
||||
collectIfaces := p.CollectInterfaces == nil || *p.CollectInterfaces
|
||||
|
||||
// Тип чека вирішує, а не параметри: розпізнавання не має способу
|
||||
// випадково перетворитись на повний обхід через недогляд у формі.
|
||||
if task.CheckType == CheckTypeIdentify {
|
||||
protos, collectIfaces = nil, false
|
||||
}
|
||||
|
||||
client, err := snmpx.Dial(ctx, task.Target.Address, task.Credentials, task.Timeout)
|
||||
if err != nil {
|
||||
return module.Result{}, err
|
||||
|
|
@ -113,11 +134,19 @@ func (m *Module) Run(ctx context.Context, task module.Task) (module.Result, erro
|
|||
|
||||
// Інвентар портів потрібен першим: LLDP оперує власною нумерацією
|
||||
// портів, і без ifName/ifDescr її нема на що відобразити.
|
||||
ifaces, err := collectInterfaces(ctx, client, task.DeviceID)
|
||||
if err != nil {
|
||||
errs = append(errs, "інтерфейси: "+err.Error())
|
||||
} else if collectIfaces {
|
||||
res.InterfaceRecords = ifaces
|
||||
//
|
||||
// Коли не просять ні портів, ні сусідів, обхід пропускаємо. Це не
|
||||
// мікрооптимізація: ifTable на комутаторі з 400 портами — тисячі
|
||||
// PDU, а «розпізнати пристрій» коштує три OID, які вже зняті вище.
|
||||
var ifaces []*npv1.InterfaceRecord
|
||||
if collectIfaces || len(protos) > 0 {
|
||||
var err error
|
||||
ifaces, err = collectInterfaces(ctx, client, task.DeviceID)
|
||||
if err != nil {
|
||||
errs = append(errs, "інтерфейси: "+err.Error())
|
||||
} else if collectIfaces {
|
||||
res.InterfaceRecords = ifaces
|
||||
}
|
||||
}
|
||||
|
||||
byIndex := make(map[int64]*npv1.InterfaceRecord, len(ifaces))
|
||||
|
|
@ -125,7 +154,7 @@ func (m *Module) Run(ctx context.Context, task module.Task) (module.Result, erro
|
|||
byIndex[r.IfIndex] = r
|
||||
}
|
||||
|
||||
for _, proto := range p.Protos {
|
||||
for _, proto := range protos {
|
||||
var (
|
||||
found []*npv1.NeighborRecord
|
||||
perr error
|
||||
|
|
@ -151,7 +180,8 @@ func (m *Module) Run(ctx context.Context, task module.Task) (module.Result, erro
|
|||
res.Neighbors = append(res.Neighbors, found...)
|
||||
}
|
||||
|
||||
if len(res.Neighbors) == 0 && len(res.InterfaceRecords) == 0 && len(errs) > 0 {
|
||||
if len(res.Neighbors) == 0 && len(res.InterfaceRecords) == 0 &&
|
||||
len(res.Devices) == 0 && len(errs) > 0 {
|
||||
return module.Result{}, fmt.Errorf("автовиявлення не дало результату: %s", strings.Join(errs, "; "))
|
||||
}
|
||||
|
||||
|
|
|
|||
92
agent/internal/modules/traps/mib.go
Normal file
92
agent/internal/modules/traps/mib.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
package traps
|
||||
|
||||
import "strconv"
|
||||
|
||||
// Словник трапів: чому він мінімальний і чому взагалі є.
|
||||
//
|
||||
// Трап приїжджає числом: `1.3.6.1.6.3.1.1.5.3` і десяток varbind-ів,
|
||||
// теж числами. Повний словник MIB — це тисячі файлів, окремий
|
||||
// компілятор ASN.1 і сховище під них; продукт, який обіцяє «завантажте
|
||||
// свої MIB», обіцяє все це разом із підтримкою вендорських діалектів.
|
||||
// Ми такого не обіцяємо.
|
||||
//
|
||||
// Але з протилежного боку — екран, на якому самі числа. Людина, що
|
||||
// відкриває журнал трапів о третій ночі, не знає напам'ять жодного OID,
|
||||
// і сторінка з колонкою `1.3.6.1.6.3.1.1.5.3` не відповідає на питання,
|
||||
// заради якого її відкрили.
|
||||
//
|
||||
// Тому тут рівно та частина словника, яка НЕ залежить від вендора.
|
||||
// Шість типових трапів (RFC 1215, він же snmpTraps з RFC 3418) однакові
|
||||
// в Cisco, Huawei, MikroTik і в найдешевшому некерованому світчі, який
|
||||
// узагалі вміє трапи. Це саме те, заради чого трапи вмикають у 90%
|
||||
// випадків: порт упав, порт піднявся, коробка перезавантажилась, хтось
|
||||
// підбирає community.
|
||||
//
|
||||
// Решта лишається сирим OID із чесною позначкою «невідомий трап». Це
|
||||
// принципово: підставити сюди назву, вигадану за схожістю префікса,
|
||||
// означало б збрехати рівно в тому місці, де людина найбільше довіряє
|
||||
// написаному.
|
||||
//
|
||||
// Свої відповідності «OID → назва» дописуються НЕ тут, а на сервері
|
||||
// (inv.trap_oids). Причина в тому, що словник на зонді довелося б
|
||||
// доставляти кожному зонду й синхронізувати; словник на сервері
|
||||
// застосовується до вже прийнятих трапів заднім числом — тобто назва,
|
||||
// додана сьогодні, підписує й учорашній журнал.
|
||||
|
||||
// Стандартні OID, з яких складається сам протокол сповіщень.
|
||||
//
|
||||
// Це не «трапи», а службові varbind-и, у яких лежить, ЩО саме сталося.
|
||||
// Вони потрібні коду, а не людині, тому й винесені константами.
|
||||
const (
|
||||
// snmpTrapOID.0 — у v2c/v3 саме тут лежить ідентифікатор трапа.
|
||||
// Сам PDU його не містить: v2 навмисно зробили трап звичайним
|
||||
// набором varbind-ів.
|
||||
oidSnmpTrapOID = "1.3.6.1.6.3.1.1.4.1.0"
|
||||
// sysUpTime.0 — обов'язковий перший varbind v2-трапа.
|
||||
oidSysUpTime = "1.3.6.1.2.1.1.3.0"
|
||||
// snmpTrapEnterprise.0 і snmpTrapAddress.0 — куди RFC 3584 велить
|
||||
// покласти поля заголовка v1-трапа, яких у v2 немає.
|
||||
oidSnmpTrapEnterprise = "1.3.6.1.6.3.1.1.4.3.0"
|
||||
oidSnmpTrapAddress = "1.3.6.1.6.3.18.1.3.0"
|
||||
// Префікс шести типових трапів: snmpTraps з RFC 3418.
|
||||
oidGenericTrapPrefix = "1.3.6.1.6.3.1.1.5."
|
||||
)
|
||||
|
||||
// genericTrapOID перекладає v1-трап у v2-ідентифікатор за RFC 3584.
|
||||
//
|
||||
// Це не наша вигадка й не зручність: RFC 3584 (Coexistence between
|
||||
// SNMP versions) описує рівно це перетворення, і всі NMS роблять його
|
||||
// однаково. Завдяки йому «linkDown від старого світча по v1» і
|
||||
// «linkDown від нового по v2c» — це один і той самий трап, а не два
|
||||
// різні рядки, під які треба заводити два правила.
|
||||
//
|
||||
// generic 6 означає «enterprise-specific»: ідентифікатор складається з
|
||||
// enterprise-OID виробника й номера трапа. Крапка-нуль посередині — теж
|
||||
// вимога RFC 3584, а не описка: v1 дозволяв enterprise, що вже
|
||||
// закінчується на цифру, і без розділювача два різні трапи склеїлись
|
||||
// би в один OID.
|
||||
func genericTrapOID(generic, specific int, enterprise string) string {
|
||||
if generic == 6 {
|
||||
if enterprise == "" {
|
||||
return ""
|
||||
}
|
||||
return trimOID(enterprise) + ".0." + strconv.Itoa(specific)
|
||||
}
|
||||
if generic < 0 || generic > 5 {
|
||||
return ""
|
||||
}
|
||||
return oidGenericTrapPrefix + strconv.Itoa(generic+1)
|
||||
}
|
||||
|
||||
// trimOID знімає провідну крапку.
|
||||
//
|
||||
// gosnmp віддає OID то з нею, то без — залежно від того, з якого поля
|
||||
// пакета він узявся. Різниця в один символ перетворила б умову правила
|
||||
// «цей OID» на лотерею, тож форма зводиться до однієї в найранішій
|
||||
// точці: одразу після розбору.
|
||||
func trimOID(s string) string {
|
||||
for len(s) > 0 && s[0] == '.' {
|
||||
s = s[1:]
|
||||
}
|
||||
return s
|
||||
}
|
||||
285
agent/internal/modules/traps/parse.go
Normal file
285
agent/internal/modules/traps/parse.go
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
package traps
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/gosnmp/gosnmp"
|
||||
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
// maxVarbinds — скільки varbind-ів зберігаємо з одного трапа.
|
||||
//
|
||||
// Стеля потрібна не через пам'ять зонда, а через базу: varbind-и лежать
|
||||
// у jsonb, і трап на тисячу рядків таблиці маршрутизації важить більше,
|
||||
// ніж година метрик з усього кабінету. Тридцять два — це вдвічі більше
|
||||
// за найбагатослівніший трап, який трапляється на практиці (BGP-сесія
|
||||
// з повним набором лічильників), тож обрізання означає не «нам шкода
|
||||
// місця», а «щось пішло не так».
|
||||
const maxVarbinds = 32
|
||||
|
||||
// maxValueRunes — стеля на одне значення.
|
||||
//
|
||||
// OCTET STRING у трапі буває довжиною в конфіг: деякі вендори пхають
|
||||
// туди повний текст події. У колонці таблиці з цього видно перші три
|
||||
// слова, а решта коштує місця в кожному рядку журналу.
|
||||
const maxValueRunes = 512
|
||||
|
||||
// truncationMark — синтетичний varbind про обрізання.
|
||||
//
|
||||
// Порожній OID навмисно: жоден справжній varbind його мати не може,
|
||||
// тож рядок неможливо сплутати з даними пристрою. Мовчазне обрізання
|
||||
// було б гіршим за будь-яку втрату — воно перетворює «ми показали не
|
||||
// все» на «більше нічого не було».
|
||||
const truncationMark = ""
|
||||
|
||||
// Normalize зводить пакет будь-якої версії до одного вигляду.
|
||||
//
|
||||
// Уся різниця між v1 і v2c/v3 з'їдається тут, і далі по системі їде
|
||||
// один тип події. Це не косметика: правило «linkDown» має спрацьовувати
|
||||
// незалежно від того, якою версією протоколу його прислали, а на
|
||||
// одному майданчику зазвичай живуть обидві — новий комутатор шле v2c,
|
||||
// а той, що стоїть із 2011-го, вміє лише v1.
|
||||
//
|
||||
// srcIP — адреса, з якої прийшла датаграма, а не та, що написана
|
||||
// всередині пакета. Всередині вона теж є (v1 має agent-address), але
|
||||
// довіряти їй не можна: це просто поле, і воно навмисно неправдиве в
|
||||
// кожному релеї трапів. Зіставлення з хостом іде за адресою відправника
|
||||
// саме тому; заявлену адресу зберігаємо окремим varbind-ом, щоб
|
||||
// розбіжність було видно, а не щоб на неї покладатись.
|
||||
func Normalize(pkt *gosnmp.SnmpPacket, srcIP string, now time.Time) *npv1.SnmpTrap {
|
||||
if pkt == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := &npv1.SnmpTrap{
|
||||
Ts: timestamppb.New(now),
|
||||
SourceIp: srcIP,
|
||||
AuthContext: authContext(pkt),
|
||||
}
|
||||
|
||||
// Копія, а не зріз пакета. Слухач після нашого обробника ще
|
||||
// повертається до цього ж пакета, щоб відповісти на inform, і
|
||||
// дописувати щось у його масив varbind-ів (а append із запасом
|
||||
// місткості робить саме це) означало б правити те, що зараз
|
||||
// поїде назад відправнику.
|
||||
vbs := make([]gosnmp.SnmpPDU, len(pkt.Variables))
|
||||
copy(vbs, pkt.Variables)
|
||||
|
||||
if pkt.PDUType == gosnmp.Trap {
|
||||
// v1: ідентифікатор трапа лежить не у varbind-ах, а в заголовку
|
||||
// PDU, і його треба зібрати з трьох полів за RFC 3584.
|
||||
out.TrapOid = genericTrapOID(pkt.GenericTrap, pkt.SpecificTrap, pkt.Enterprise)
|
||||
// Поля заголовка, яких у v2 немає, RFC 3584 велить дописати
|
||||
// varbind-ами. Робимо саме так, а не заводимо власні колонки:
|
||||
// інакше сторінка трапів мала б дві порожні клітинки для всього,
|
||||
// що приїхало по v2c, і одну заповнену для музейних експонатів.
|
||||
vbs = append(vbs,
|
||||
gosnmp.SnmpPDU{Name: oidSysUpTime, Type: gosnmp.TimeTicks, Value: pkt.Timestamp},
|
||||
gosnmp.SnmpPDU{Name: oidSnmpTrapEnterprise, Type: gosnmp.ObjectIdentifier, Value: pkt.Enterprise},
|
||||
)
|
||||
if pkt.AgentAddress != "" {
|
||||
vbs = append(vbs,
|
||||
gosnmp.SnmpPDU{Name: oidSnmpTrapAddress, Type: gosnmp.IPAddress, Value: pkt.AgentAddress})
|
||||
}
|
||||
} else {
|
||||
out.TrapOid = trapOIDFromVarbinds(vbs)
|
||||
}
|
||||
|
||||
out.Varbinds = convertVarbinds(vbs)
|
||||
return out
|
||||
}
|
||||
|
||||
// trapOIDFromVarbinds дістає snmpTrapOID.0 з набору varbind-ів.
|
||||
//
|
||||
// Порожній результат — не помилка розбору, а окремий стан: буває, що
|
||||
// пристрій шле v2-трап без обов'язкового snmpTrapOID.0 (так поводяться
|
||||
// деякі дешеві PDU і кілька прошивок ONU). Викидати таку подію не можна
|
||||
// — вона все одно означає, що щось сталося, — тому вона доїжджає з
|
||||
// порожнім OID і на екрані виглядає як «трап без ідентифікатора». Це
|
||||
// чесно: ми справді не знаємо, що це було.
|
||||
func trapOIDFromVarbinds(vbs []gosnmp.SnmpPDU) string {
|
||||
for _, v := range vbs {
|
||||
if trimOID(v.Name) != oidSnmpTrapOID {
|
||||
continue
|
||||
}
|
||||
switch x := v.Value.(type) {
|
||||
case string:
|
||||
return trimOID(x)
|
||||
case []byte:
|
||||
return trimOID(string(x))
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// authContext — те, чим відправник себе назвав.
|
||||
//
|
||||
// Не автентифікація: community їде відкритим текстом, і зонд його не
|
||||
// перевіряє. Зберігаємо, бо це єдине, що відрізняє «трапи від нашого
|
||||
// обладнання» від «трапи, які хтось шле нам у порт»; для v3 тут
|
||||
// security name, у якого доказова сила вже є.
|
||||
func authContext(pkt *gosnmp.SnmpPacket) string {
|
||||
if pkt.Version == gosnmp.Version3 {
|
||||
if usm, ok := pkt.SecurityParameters.(*gosnmp.UsmSecurityParameters); ok && usm != nil {
|
||||
return usm.UserName
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return pkt.Community
|
||||
}
|
||||
|
||||
// convertVarbinds перекладає varbind-и в те, що поїде на сервер.
|
||||
func convertVarbinds(vbs []gosnmp.SnmpPDU) []*npv1.VarBind {
|
||||
if len(vbs) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]*npv1.VarBind, 0, min(len(vbs), maxVarbinds+1))
|
||||
for i, v := range vbs {
|
||||
if i >= maxVarbinds {
|
||||
out = append(out, &npv1.VarBind{
|
||||
Oid: truncationMark,
|
||||
Type: "truncated",
|
||||
Value: fmt.Sprintf("ще %d varbind-ів відкинуто зондом", len(vbs)-maxVarbinds),
|
||||
})
|
||||
break
|
||||
}
|
||||
typ, val := formatValue(v)
|
||||
out = append(out, &npv1.VarBind{Oid: trimOID(v.Name), Type: typ, Value: val})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// formatValue перетворює значення varbind-а на текст і назву типу.
|
||||
//
|
||||
// Тип зберігається окремо, а не вгадується зі значення при читанні, і
|
||||
// це важливо саме для трапів: `2` у ifOperStatus — це стан «down», а
|
||||
// не число два, і різницю між INTEGER і Counter32 не відновити з
|
||||
// рядка «2» ніяк. Умова правила «varbind дорівнює цьому значенню»
|
||||
// порівнює текст, тому текст мусить бути передбачуваним: одне й те
|
||||
// саме число з двох різних трапів має дати однаковий рядок.
|
||||
func formatValue(v gosnmp.SnmpPDU) (string, string) {
|
||||
switch v.Type {
|
||||
case gosnmp.Integer:
|
||||
return "INTEGER", strconv.FormatInt(gosnmp.ToBigInt(v.Value).Int64(), 10)
|
||||
case gosnmp.Counter32:
|
||||
return "Counter32", gosnmp.ToBigInt(v.Value).String()
|
||||
case gosnmp.Gauge32:
|
||||
return "Gauge32", gosnmp.ToBigInt(v.Value).String()
|
||||
case gosnmp.Counter64:
|
||||
return "Counter64", gosnmp.ToBigInt(v.Value).String()
|
||||
case gosnmp.Uinteger32:
|
||||
return "Unsigned32", gosnmp.ToBigInt(v.Value).String()
|
||||
case gosnmp.TimeTicks:
|
||||
// Сотні секунд, як їх визначає SMI. Не перетворюємо на «3 дні
|
||||
// 4 години» тут: людське подання — робота екрана, а порівняння
|
||||
// в умові правила має мати справу з числом, яке прислав
|
||||
// пристрій.
|
||||
return "TimeTicks", gosnmp.ToBigInt(v.Value).String()
|
||||
case gosnmp.ObjectIdentifier:
|
||||
return "OID", trimOID(asString(v.Value))
|
||||
case gosnmp.IPAddress:
|
||||
return "IpAddress", asString(v.Value)
|
||||
case gosnmp.OctetString:
|
||||
return "OCTET STRING", octetString(v.Value)
|
||||
case gosnmp.Null:
|
||||
return "NULL", ""
|
||||
case gosnmp.NoSuchObject:
|
||||
return "noSuchObject", ""
|
||||
case gosnmp.NoSuchInstance:
|
||||
return "noSuchInstance", ""
|
||||
case gosnmp.EndOfMibView:
|
||||
return "endOfMibView", ""
|
||||
case gosnmp.Opaque, gosnmp.OpaqueFloat, gosnmp.OpaqueDouble:
|
||||
return "Opaque", octetString(v.Value)
|
||||
default:
|
||||
// Тип, якого ми не знаємо, — не привід втратити значення.
|
||||
// Числом типу видно, що саме приїхало, і за ним можна дописати
|
||||
// гілку, коли з'ясується, який вендор так робить.
|
||||
return "type-" + strconv.Itoa(int(v.Type)), octetString(v.Value)
|
||||
}
|
||||
}
|
||||
|
||||
// octetString подає рядок байтів так, щоб його можна було прочитати.
|
||||
//
|
||||
// OCTET STRING — це байти, а не текст: там буває і опис події
|
||||
// українською в UTF-8, і MAC-адреса, і бінарний блок стану. Друковане
|
||||
// лишаємо як є, недруковане показуємо шістнадцятковим — інакше в
|
||||
// журналі виявиться сміття, яке ще й ламає JSON на шляху до бази.
|
||||
func octetString(v any) string {
|
||||
var b []byte
|
||||
switch x := v.(type) {
|
||||
case []byte:
|
||||
b = x
|
||||
case string:
|
||||
b = []byte(x)
|
||||
default:
|
||||
return truncate(asString(v))
|
||||
}
|
||||
if utf8.Valid(b) && printable(b) {
|
||||
return truncate(string(b))
|
||||
}
|
||||
return truncate(hexPairs(b))
|
||||
}
|
||||
|
||||
// printable відсіює керівні байти. Табуляція й перенос — теж керівні,
|
||||
// але їх лишаємо: багатослівні трапи Juniper приходять саме так, і
|
||||
// перетворювати їх на hex означало б зробити нечитабельним рівно те,
|
||||
// що читабельне.
|
||||
func printable(b []byte) bool {
|
||||
for _, c := range b {
|
||||
if c < 0x20 && c != '\t' && c != '\n' && c != '\r' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// hexPairs — "00:1b:21:3c:4d:5e". Двокрапки саме тому, що найчастіший
|
||||
// бінарний OCTET STRING у трапі — це MAC-адреса, і в такому вигляді її
|
||||
// впізнають, а суцільний рядок цифр — ні.
|
||||
func hexPairs(b []byte) string {
|
||||
if len(b) > maxValueRunes/3 {
|
||||
b = b[:maxValueRunes/3]
|
||||
}
|
||||
s := hex.EncodeToString(b)
|
||||
var sb strings.Builder
|
||||
for i := 0; i < len(s); i += 2 {
|
||||
if i > 0 {
|
||||
sb.WriteByte(':')
|
||||
}
|
||||
sb.WriteString(s[i : i+2])
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func asString(v any) string {
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return x
|
||||
case []byte:
|
||||
return string(x)
|
||||
case net.IP:
|
||||
return x.String()
|
||||
case nil:
|
||||
return ""
|
||||
default:
|
||||
return fmt.Sprint(x)
|
||||
}
|
||||
}
|
||||
|
||||
// truncate обрізає значення, лишаючи видимим сам факт обрізання.
|
||||
func truncate(s string) string {
|
||||
s = strings.TrimRight(s, "\x00")
|
||||
if utf8.RuneCountInString(s) <= maxValueRunes {
|
||||
return s
|
||||
}
|
||||
r := []rune(s)
|
||||
return string(r[:maxValueRunes]) + "…"
|
||||
}
|
||||
329
agent/internal/modules/traps/receiver.go
Normal file
329
agent/internal/modules/traps/receiver.go
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
// Package traps — приймач SNMP-трапів на зонді.
|
||||
//
|
||||
// Навіщо він, коли вже є syslog. Трап і рядок журналу відповідають на
|
||||
// різні питання. Syslog розповідає, що пристрій ПРО СЕБЕ думає, і
|
||||
// робить це тоді, коли встигає: якщо коробка перевантажена або
|
||||
// перезавантажується, останні рядки не встигають вийти. Трап — це
|
||||
// одна датаграма, підготовлена заздалегідь; її встигають вислати навіть
|
||||
// ті прошивки, у яких syslog вимикається першим. Плюс сотні дешевих
|
||||
// пристроїв (керовані PDU, ДБЖ, ONU, конвертери) syslog не вміють
|
||||
// взагалі, а трапи шлють — це для них єдиний спосіб сказати «мене
|
||||
// вимикають».
|
||||
//
|
||||
// Приймач саме на зонді, а не на сервері, з тієї ж причини, що й
|
||||
// syslog: комутатор у закритій мережі до сервера не достукається, а
|
||||
// зонд уже має вихідний канал.
|
||||
//
|
||||
// Головна відмінність від syslog-приймача — inform. Трап відправник
|
||||
// шле й забуває; inform він шле, доки не отримає підтвердження. Не
|
||||
// підтвердити означає перетворити одну подію на нескінченний потік з
|
||||
// того самого джерела — і це відбувається саме тоді, коли зонду
|
||||
// найважче. Тому підтвердження тут не залежить від того, чи взяли ми
|
||||
// подію в чергу: ми відповідаємо ЗАВЖДИ, а вже потім вирішуємо, чи є в
|
||||
// нас місце.
|
||||
package traps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gosnmp/gosnmp"
|
||||
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
|
||||
)
|
||||
|
||||
// DefaultAddr — стандартний порт трапів.
|
||||
//
|
||||
// Нижче 1024, тож у Linux потрібна CAP_NET_BIND_SERVICE. Налаштовується
|
||||
// з тієї самої причини, що й порт syslog: віддати зонду право на
|
||||
// привілейований порт можна не всюди, а перекинути 162 на 1162
|
||||
// правилом фаєрвола можна скрізь.
|
||||
const DefaultAddr = ":162"
|
||||
|
||||
// maxDatagram — стеля на одну датаграму.
|
||||
//
|
||||
// RFC 3416 гарантує прийом лише 484 байтів; практика — до 1500 (MTU).
|
||||
// Вісім кілобайтів беремо з запасом на трапи, у які вендор запхав
|
||||
// текст події, і не більше: буфер виділяється на кожен прийом, а
|
||||
// датаграма, більша за нього, все одно обрізається ядром.
|
||||
const maxDatagram = 8192
|
||||
|
||||
// maxQueue — скільки трапів чекає відправки.
|
||||
//
|
||||
// Трап-шторм — це не гіпотеза, а звичайний вівторок: порт, що фліпає
|
||||
// через биту оптику, дає linkDown/linkUp по кілька разів на секунду
|
||||
// роками. Черга без стелі означає, що зонд з'їдає пам'ять машини, на
|
||||
// якій живе, — тобто ламає не себе, а сервер клієнта.
|
||||
const maxQueue = 10_000
|
||||
|
||||
// defaultPerSource — стеля трапів за секунду з однієї адреси.
|
||||
//
|
||||
// На відміну від syslog, тут ліміт стоїть ЗАВЖДИ, а не лише коли сервер
|
||||
// його попросив. Різниця в тому, що трапи дозволяють підтвердження:
|
||||
// відправник inform-ів, який не отримав відповіді, шле повторно, і
|
||||
// перші секунди після старту зонда — це рівно той момент, коли на нього
|
||||
// вивалюється все, що накопичилось. Нуль тут означав би «без обмежень»
|
||||
// саме тоді, коли вони найпотрібніші.
|
||||
//
|
||||
// Двадцять на секунду — це вчетверо більше, ніж дає порт, що фліпає
|
||||
// найшвидше з бачених. Сервер може підняти або опустити цю стелю
|
||||
// (LogAck.rate_limit_per_source): він бачить картину по всіх зондах.
|
||||
const defaultPerSource = 20
|
||||
|
||||
// Receiver приймає трапи й накопичує їх до відправки на сервер.
|
||||
type Receiver struct {
|
||||
addr string
|
||||
log *slog.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
queue []*npv1.SnmpTrap
|
||||
dropped uint64
|
||||
buckets map[string]*bucket
|
||||
|
||||
perSource atomic.Int64
|
||||
|
||||
resolve atomic.Pointer[func(ip string) string]
|
||||
|
||||
// Скільки трапів прийшло з адрес, які зонд не знайшов серед своїх
|
||||
// хостів. Лічильник, а не прапорець: одна подія від незнайомої
|
||||
// адреси — це, найімовірніше, сусід у спільній підмережі, а тисяча
|
||||
// — це те, що в мережі стоїть кероване залізо, про яке моніторинг
|
||||
// не знає.
|
||||
unknownSource atomic.Uint64
|
||||
|
||||
notify chan struct{}
|
||||
}
|
||||
|
||||
// New створює приймач. Порожня адреса означає DefaultAddr.
|
||||
func New(addr string, log *slog.Logger) *Receiver {
|
||||
if addr == "" {
|
||||
addr = DefaultAddr
|
||||
}
|
||||
r := &Receiver{
|
||||
addr: addr,
|
||||
log: log,
|
||||
buckets: map[string]*bucket{},
|
||||
notify: make(chan struct{}, 1),
|
||||
}
|
||||
r.perSource.Store(defaultPerSource)
|
||||
return r
|
||||
}
|
||||
|
||||
// SetResolver задає спосіб знайти хост за адресою відправника.
|
||||
func (r *Receiver) SetResolver(f func(ip string) string) {
|
||||
r.resolve.Store(&f)
|
||||
}
|
||||
|
||||
// ApplyAck застосовує ліміт, надісланий сервером.
|
||||
//
|
||||
// Нуль не знімає обмеження, а лишає типове. Це відрізняється від
|
||||
// syslog навмисно: там нуль означає «бери все», бо втрачений рядок
|
||||
// журналу нічого не ламає. Тут відсутність стелі означає, що зонд
|
||||
// лягає під штормом — а зонд, який ліг, не бачить уже нічого.
|
||||
func (r *Receiver) ApplyAck(perSourcePerSec uint32) {
|
||||
if perSourcePerSec > 0 {
|
||||
r.perSource.Store(int64(perSourcePerSec))
|
||||
}
|
||||
}
|
||||
|
||||
// Ready повідомляє про появу подій у черзі.
|
||||
func (r *Receiver) Ready() <-chan struct{} { return r.notify }
|
||||
|
||||
// UnknownSources — скільки трапів прийшло від нерозпізнаних адрес.
|
||||
func (r *Receiver) UnknownSources() uint64 { return r.unknownSource.Load() }
|
||||
|
||||
// Drain забирає з черги до limit подій разом із лічильником відкинутих.
|
||||
func (r *Receiver) Drain(limit int) ([]*npv1.SnmpTrap, uint64) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if limit <= 0 || limit > len(r.queue) {
|
||||
limit = len(r.queue)
|
||||
}
|
||||
if limit == 0 {
|
||||
return nil, 0
|
||||
}
|
||||
|
||||
out := r.queue[:limit]
|
||||
r.queue = append([]*npv1.SnmpTrap(nil), r.queue[limit:]...)
|
||||
dropped := r.dropped
|
||||
r.dropped = 0
|
||||
return out, dropped
|
||||
}
|
||||
|
||||
// Requeue повертає невідправлені трапи на початок черги.
|
||||
//
|
||||
// Порядок має значення так само, як у журналі: linkDown і linkUp того
|
||||
// самого порту відрізняються лише часом, і пачка, що повернулась у
|
||||
// хвіст, показала б порт піднятим до того, як він упав.
|
||||
func (r *Receiver) Requeue(entries []*npv1.SnmpTrap) {
|
||||
if len(entries) == 0 {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
room := maxQueue - len(r.queue)
|
||||
if room <= 0 {
|
||||
r.dropped += uint64(len(entries))
|
||||
return
|
||||
}
|
||||
if len(entries) > room {
|
||||
r.dropped += uint64(len(entries) - room)
|
||||
entries = entries[len(entries)-room:]
|
||||
}
|
||||
r.queue = append(entries, r.queue...)
|
||||
}
|
||||
|
||||
// Run слухає порт, доки живий контекст.
|
||||
func (r *Receiver) Run(ctx context.Context) error {
|
||||
tl := gosnmp.NewTrapListener()
|
||||
// Params потрібні розбирачу, а не нам: він читає з них версію за
|
||||
// замовчуванням і логер. Версію пакета він однаково бере з самого
|
||||
// пакета, тож жодного відсіювання за версією тут немає — і не має
|
||||
// бути. Зонд, який приймає лише v2c, мовчки губив би все, що шлють
|
||||
// старі коробки, а саме вони найчастіше й уміють тільки трапи.
|
||||
tl.Params = &gosnmp.GoSNMP{Version: gosnmp.Version2c}
|
||||
tl.WithBufferSize(maxDatagram)
|
||||
tl.OnNewTrap = func(pkt *gosnmp.SnmpPacket, addr *net.UDPAddr) {
|
||||
// Обробник тримає читальну горутину слухача: доки він не
|
||||
// завершився, наступна датаграма не читається, а підтвердження
|
||||
// inform-а не відсилається. Тому тут рівно розбір і черга —
|
||||
// жодних звернень назовні.
|
||||
r.Handle(pkt, addrIP(addr), time.Now())
|
||||
}
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
// Close() закриває сокет: іншого способу перервати ReadFromUDP,
|
||||
// який не знає про контекст, немає.
|
||||
tl.Close()
|
||||
}()
|
||||
|
||||
r.log.Info("приймач трапів слухає", "адреса", r.addr)
|
||||
if err := tl.Listen(r.addr); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handle розбирає й кладе в чергу один трап.
|
||||
//
|
||||
// Окремо від Run, щоб приймач можна було перевірити без сокета.
|
||||
func (r *Receiver) Handle(pkt *gosnmp.SnmpPacket, srcIP string, now time.Time) {
|
||||
if pkt == nil {
|
||||
return
|
||||
}
|
||||
// Ліміт перевіряється ДО розбору: сенс обмежувача в тому, щоб
|
||||
// шторм коштував дешево, а розбір varbind-ів — найдорожча частина
|
||||
// шляху.
|
||||
if !r.allow(srcIP, now) {
|
||||
r.drop()
|
||||
return
|
||||
}
|
||||
|
||||
t := Normalize(pkt, srcIP, now)
|
||||
if t == nil {
|
||||
return
|
||||
}
|
||||
if f := r.resolve.Load(); f != nil {
|
||||
t.DeviceId = (*f)(srcIP)
|
||||
}
|
||||
if t.DeviceId == "" {
|
||||
// Трап від невідомої адреси не викидається.
|
||||
//
|
||||
// Спокуса викинути є: подія, не прив'язана до хоста, не
|
||||
// потрапить у жодне правило й нікого не розбудить. Але саме
|
||||
// вона найчастіше є першим сигналом, що в мережі з'явилось
|
||||
// залізо, якого моніторинг не знає, — новий комутатор, чужий
|
||||
// маршрутизатор у спільному VLAN, ДБЖ, який хтось увімкнув «на
|
||||
// часок». Тиха втрата тут означала б, що продукт бачить рівно
|
||||
// те, що йому вже показали.
|
||||
r.unknownSource.Add(1)
|
||||
}
|
||||
r.push(t)
|
||||
}
|
||||
|
||||
func (r *Receiver) push(t *npv1.SnmpTrap) {
|
||||
r.mu.Lock()
|
||||
if len(r.queue) >= maxQueue {
|
||||
// Викидаємо найстаріше. Під час шторму свіжі трапи пояснюють,
|
||||
// що відбувається зараз, а найстаріші — те, що вже видно з
|
||||
// наступних десяти тисяч.
|
||||
r.queue = r.queue[1:]
|
||||
r.dropped++
|
||||
}
|
||||
r.queue = append(r.queue, t)
|
||||
r.mu.Unlock()
|
||||
|
||||
select {
|
||||
case r.notify <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Receiver) drop() {
|
||||
r.mu.Lock()
|
||||
r.dropped++
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
// --- обмеження частоти ------------------------------------------------
|
||||
|
||||
type bucket struct {
|
||||
tokens float64
|
||||
last time.Time
|
||||
}
|
||||
|
||||
// allow пропускає трап, якщо джерело не перевищило ліміт.
|
||||
//
|
||||
// Ліміт на джерело, а не спільний: один комутатор із битою оптикою
|
||||
// інакше витіснив би з черги всю решту мережі — тобто рівно те, що
|
||||
// потрібно бачити під час аварії.
|
||||
func (r *Receiver) allow(ip string, now time.Time) bool {
|
||||
rate := float64(r.perSource.Load())
|
||||
if rate <= 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
b, ok := r.buckets[ip]
|
||||
if !ok {
|
||||
// Прибирання разом зі створенням: окрема горутина заради мапи,
|
||||
// яка росте на одне джерело, — зайва ціна. Стеля потрібна ще й
|
||||
// тому, що адресу відправника UDP підробити нічого не варте:
|
||||
// без неї мапа стає способом з'їсти пам'ять зонда з мережі.
|
||||
if len(r.buckets) > 4096 {
|
||||
r.buckets = map[string]*bucket{}
|
||||
}
|
||||
r.buckets[ip] = &bucket{tokens: rate - 1, last: now}
|
||||
return true
|
||||
}
|
||||
|
||||
b.tokens += now.Sub(b.last).Seconds() * rate
|
||||
if b.tokens > rate {
|
||||
b.tokens = rate
|
||||
}
|
||||
b.last = now
|
||||
|
||||
if b.tokens < 1 {
|
||||
return false
|
||||
}
|
||||
b.tokens--
|
||||
return true
|
||||
}
|
||||
|
||||
func addrIP(a *net.UDPAddr) string {
|
||||
if a == nil {
|
||||
return ""
|
||||
}
|
||||
return a.IP.String()
|
||||
}
|
||||
438
agent/internal/modules/traps/traps_test.go
Normal file
438
agent/internal/modules/traps/traps_test.go
Normal file
|
|
@ -0,0 +1,438 @@
|
|||
package traps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gosnmp/gosnmp"
|
||||
)
|
||||
|
||||
var now = time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
func quiet() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
// v2c-трап: ідентифікатор події лежить не в заголовку, а у varbind-і
|
||||
// snmpTrapOID.0. Помилка тут означала б, що жодне правило «цей OID»
|
||||
// ніколи не спрацює.
|
||||
func TestNormalizeV2cTrap(t *testing.T) {
|
||||
pkt := &gosnmp.SnmpPacket{
|
||||
Version: gosnmp.Version2c,
|
||||
Community: "public",
|
||||
PDUType: gosnmp.SNMPv2Trap,
|
||||
Variables: []gosnmp.SnmpPDU{
|
||||
{Name: ".1.3.6.1.2.1.1.3.0", Type: gosnmp.TimeTicks, Value: uint(12345)},
|
||||
{Name: ".1.3.6.1.6.3.1.1.4.1.0", Type: gosnmp.ObjectIdentifier, Value: ".1.3.6.1.6.3.1.1.5.3"},
|
||||
{Name: ".1.3.6.1.2.1.2.2.1.1.7", Type: gosnmp.Integer, Value: 7},
|
||||
{Name: ".1.3.6.1.2.1.2.2.1.2.7", Type: gosnmp.OctetString, Value: []byte("GigabitEthernet0/7")},
|
||||
},
|
||||
}
|
||||
|
||||
tr := Normalize(pkt, "10.10.0.5", now)
|
||||
|
||||
if tr.GetTrapOid() != "1.3.6.1.6.3.1.1.5.3" {
|
||||
t.Fatalf("OID трапа: %q", tr.GetTrapOid())
|
||||
}
|
||||
if tr.GetAuthContext() != "public" {
|
||||
t.Fatalf("community: %q", tr.GetAuthContext())
|
||||
}
|
||||
if tr.GetSourceIp() != "10.10.0.5" {
|
||||
t.Fatalf("адреса: %q", tr.GetSourceIp())
|
||||
}
|
||||
if len(tr.GetVarbinds()) != 4 {
|
||||
t.Fatalf("varbind-ів: %d", len(tr.GetVarbinds()))
|
||||
}
|
||||
// Провідна крапка знімається скрізь: інакше умова правила «цей OID»
|
||||
// залежала б від того, з якого поля пакета OID узявся.
|
||||
if got := tr.GetVarbinds()[1].GetValue(); got != "1.3.6.1.6.3.1.1.5.3" {
|
||||
t.Fatalf("OID у значенні лишився з крапкою: %q", got)
|
||||
}
|
||||
if got := tr.GetVarbinds()[2]; got.GetType() != "INTEGER" || got.GetValue() != "7" {
|
||||
t.Fatalf("ifIndex: %s=%s", got.GetType(), got.GetValue())
|
||||
}
|
||||
if got := tr.GetVarbinds()[3].GetValue(); got != "GigabitEthernet0/7" {
|
||||
t.Fatalf("ifDescr: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// v1-трап несе ідентифікатор трьома полями заголовка. Без перекладу за
|
||||
// RFC 3584 «linkDown зі старого світча» і «linkDown із нового» були б
|
||||
// різними подіями, під які треба заводити два правила.
|
||||
func TestNormalizeV1GenericTrap(t *testing.T) {
|
||||
pkt := &gosnmp.SnmpPacket{
|
||||
Version: gosnmp.Version1,
|
||||
Community: "public",
|
||||
PDUType: gosnmp.Trap,
|
||||
SnmpTrap: gosnmp.SnmpTrap{
|
||||
Enterprise: ".1.3.6.1.4.1.9",
|
||||
AgentAddress: "10.10.0.9",
|
||||
GenericTrap: 2, // linkDown
|
||||
SpecificTrap: 0,
|
||||
Timestamp: 999,
|
||||
},
|
||||
Variables: []gosnmp.SnmpPDU{
|
||||
{Name: ".1.3.6.1.2.1.2.2.1.1.3", Type: gosnmp.Integer, Value: 3},
|
||||
},
|
||||
}
|
||||
|
||||
tr := Normalize(pkt, "10.10.0.9", now)
|
||||
|
||||
if tr.GetTrapOid() != "1.3.6.1.6.3.1.1.5.3" {
|
||||
t.Fatalf("linkDown не перекладено в OID v2: %q", tr.GetTrapOid())
|
||||
}
|
||||
// Поля заголовка v1 мають доїхати varbind-ами, інакше вони
|
||||
// зникають безслідно.
|
||||
byOID := map[string]string{}
|
||||
for _, v := range tr.GetVarbinds() {
|
||||
byOID[v.GetOid()] = v.GetValue()
|
||||
}
|
||||
if byOID[oidSysUpTime] != "999" {
|
||||
t.Fatalf("sysUpTime: %q", byOID[oidSysUpTime])
|
||||
}
|
||||
if byOID[oidSnmpTrapEnterprise] != "1.3.6.1.4.1.9" {
|
||||
t.Fatalf("enterprise: %q", byOID[oidSnmpTrapEnterprise])
|
||||
}
|
||||
if byOID[oidSnmpTrapAddress] != "10.10.0.9" {
|
||||
t.Fatalf("agent-address: %q", byOID[oidSnmpTrapAddress])
|
||||
}
|
||||
}
|
||||
|
||||
// generic 6 — «своє», і ідентифікатор складається з enterprise і номера
|
||||
// через ".0.". Без цього роздільника два різні трапи склеїлись би в
|
||||
// один OID.
|
||||
func TestNormalizeV1EnterpriseTrap(t *testing.T) {
|
||||
pkt := &gosnmp.SnmpPacket{
|
||||
Version: gosnmp.Version1,
|
||||
PDUType: gosnmp.Trap,
|
||||
SnmpTrap: gosnmp.SnmpTrap{
|
||||
Enterprise: ".1.3.6.1.4.1.2011.5.25.219",
|
||||
GenericTrap: 6,
|
||||
SpecificTrap: 4,
|
||||
},
|
||||
}
|
||||
if got := Normalize(pkt, "10.0.0.1", now).GetTrapOid(); got != "1.3.6.1.4.1.2011.5.25.219.0.4" {
|
||||
t.Fatalf("enterprise-specific: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Трап без snmpTrapOID.0 існує (дешеві PDU, окремі прошивки ONU). Це не
|
||||
// привід його загубити: подія все одно означає, що щось сталося.
|
||||
func TestNormalizeV2cWithoutTrapOID(t *testing.T) {
|
||||
pkt := &gosnmp.SnmpPacket{
|
||||
Version: gosnmp.Version2c,
|
||||
PDUType: gosnmp.SNMPv2Trap,
|
||||
Variables: []gosnmp.SnmpPDU{
|
||||
{Name: ".1.3.6.1.2.1.1.3.0", Type: gosnmp.TimeTicks, Value: uint(1)},
|
||||
},
|
||||
}
|
||||
tr := Normalize(pkt, "10.0.0.7", now)
|
||||
if tr == nil {
|
||||
t.Fatal("трап без OID викинуто")
|
||||
}
|
||||
if tr.GetTrapOid() != "" {
|
||||
t.Fatalf("вигаданий OID: %q", tr.GetTrapOid())
|
||||
}
|
||||
if len(tr.GetVarbinds()) != 1 {
|
||||
t.Fatalf("varbind-и втрачено: %d", len(tr.GetVarbinds()))
|
||||
}
|
||||
}
|
||||
|
||||
// Бінарне значення не має потрапити в журнал сирими байтами: у
|
||||
// найкращому разі це нечитабельно, у гіршому — ламає JSON дорогою в базу.
|
||||
func TestOctetStringBinaryBecomesHex(t *testing.T) {
|
||||
pkt := &gosnmp.SnmpPacket{
|
||||
PDUType: gosnmp.SNMPv2Trap,
|
||||
Variables: []gosnmp.SnmpPDU{
|
||||
{Name: "1.3.6.1.2.1.2.2.1.6.1", Type: gosnmp.OctetString,
|
||||
Value: []byte{0x00, 0x1b, 0x21, 0x3c, 0x4d, 0x5e}},
|
||||
},
|
||||
}
|
||||
if got := Normalize(pkt, "10.0.0.1", now).GetVarbinds()[0].GetValue(); got != "00:1b:21:3c:4d:5e" {
|
||||
t.Fatalf("MAC-адреса: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Обрізання має бути видно. Мовчазне перетворює «ми показали не все» на
|
||||
// «більше нічого не було».
|
||||
func TestVarbindsAreCappedVisibly(t *testing.T) {
|
||||
pkt := &gosnmp.SnmpPacket{PDUType: gosnmp.SNMPv2Trap}
|
||||
for i := 0; i < maxVarbinds+10; i++ {
|
||||
pkt.Variables = append(pkt.Variables, gosnmp.SnmpPDU{
|
||||
Name: fmt.Sprintf("1.3.6.1.4.1.1.%d", i), Type: gosnmp.Integer, Value: i,
|
||||
})
|
||||
}
|
||||
vbs := Normalize(pkt, "10.0.0.1", now).GetVarbinds()
|
||||
if len(vbs) != maxVarbinds+1 {
|
||||
t.Fatalf("varbind-ів після обрізання: %d", len(vbs))
|
||||
}
|
||||
last := vbs[len(vbs)-1]
|
||||
if last.GetType() != "truncated" || !strings.Contains(last.GetValue(), "10") {
|
||||
t.Fatalf("позначки про обрізання немає: %+v", last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLongValueTruncated(t *testing.T) {
|
||||
long := strings.Repeat("я", maxValueRunes*2)
|
||||
pkt := &gosnmp.SnmpPacket{
|
||||
PDUType: gosnmp.SNMPv2Trap,
|
||||
Variables: []gosnmp.SnmpPDU{
|
||||
{Name: "1.3.6.1.4.1.1.1", Type: gosnmp.OctetString, Value: []byte(long)},
|
||||
},
|
||||
}
|
||||
got := Normalize(pkt, "10.0.0.1", now).GetVarbinds()[0].GetValue()
|
||||
if !strings.HasSuffix(got, "…") {
|
||||
t.Fatalf("обрізання непомітне: %q", got[:20])
|
||||
}
|
||||
if n := len([]rune(got)); n != maxValueRunes+1 {
|
||||
t.Fatalf("довжина після обрізання: %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Черга й ліміти
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
func trapPacket() *gosnmp.SnmpPacket {
|
||||
return &gosnmp.SnmpPacket{
|
||||
Version: gosnmp.Version2c,
|
||||
PDUType: gosnmp.SNMPv2Trap,
|
||||
Variables: []gosnmp.SnmpPDU{
|
||||
{Name: oidSnmpTrapOID, Type: gosnmp.ObjectIdentifier, Value: "1.3.6.1.6.3.1.1.5.3"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Шторм із однієї адреси не має витіснити решту мережі — саме її й
|
||||
// потрібно бачити під час аварії.
|
||||
func TestPerSourceRateLimit(t *testing.T) {
|
||||
r := New(":0", quiet())
|
||||
r.perSource.Store(5)
|
||||
|
||||
for i := 0; i < 20; i++ {
|
||||
r.Handle(trapPacket(), "10.0.0.1", now)
|
||||
}
|
||||
// Друге джерело в ту саму мілісекунду має пройти повністю: ліміт на
|
||||
// джерело, а не спільний.
|
||||
for i := 0; i < 3; i++ {
|
||||
r.Handle(trapPacket(), "10.0.0.2", now)
|
||||
}
|
||||
|
||||
got, dropped := r.Drain(0)
|
||||
if dropped == 0 {
|
||||
t.Fatal("шторм пройшов без жодної відмови")
|
||||
}
|
||||
var fromSecond int
|
||||
for _, tr := range got {
|
||||
if tr.GetSourceIp() == "10.0.0.2" {
|
||||
fromSecond++
|
||||
}
|
||||
}
|
||||
if fromSecond != 3 {
|
||||
t.Fatalf("сусіда витіснив шторм: %d із 3", fromSecond)
|
||||
}
|
||||
}
|
||||
|
||||
// Відро поповнюється часом: перерва має повертати джерелу право слати.
|
||||
func TestRateLimitRefills(t *testing.T) {
|
||||
r := New(":0", quiet())
|
||||
r.perSource.Store(2)
|
||||
|
||||
r.Handle(trapPacket(), "10.0.0.1", now)
|
||||
r.Handle(trapPacket(), "10.0.0.1", now)
|
||||
r.Handle(trapPacket(), "10.0.0.1", now) // третій за ту саму мить — повз
|
||||
|
||||
r.Handle(trapPacket(), "10.0.0.1", now.Add(time.Second))
|
||||
|
||||
got, dropped := r.Drain(0)
|
||||
if len(got) != 3 || dropped != 1 {
|
||||
t.Fatalf("прийнято %d, відкинуто %d", len(got), dropped)
|
||||
}
|
||||
}
|
||||
|
||||
// Черга без стелі означає, що зонд з'їдає пам'ять машини клієнта.
|
||||
func TestQueueOverflowDropsOldest(t *testing.T) {
|
||||
r := New(":0", quiet())
|
||||
r.perSource.Store(0) // ліміт частоти тут не перевіряємо
|
||||
|
||||
for i := 0; i < maxQueue+50; i++ {
|
||||
pkt := trapPacket()
|
||||
pkt.Variables = append(pkt.Variables, gosnmp.SnmpPDU{
|
||||
Name: "1.3.6.1.4.1.1.1", Type: gosnmp.Integer, Value: i,
|
||||
})
|
||||
r.Handle(pkt, "10.0.0.1", now)
|
||||
}
|
||||
|
||||
got, dropped := r.Drain(0)
|
||||
if len(got) != maxQueue {
|
||||
t.Fatalf("черга виросла над стелю: %d", len(got))
|
||||
}
|
||||
if dropped != 50 {
|
||||
t.Fatalf("відкинуто %d замість 50", dropped)
|
||||
}
|
||||
// Лишитись мають СВІЖІ: під час шторму саме вони пояснюють, що
|
||||
// відбувається зараз.
|
||||
if v := got[0].GetVarbinds()[1].GetValue(); v != "50" {
|
||||
t.Fatalf("викинуто не найстаріше: перший у черзі %s", v)
|
||||
}
|
||||
}
|
||||
|
||||
// Трап від адреси, якої немає серед хостів, — окремий стан, а не тиха
|
||||
// втрата: часто це перший сигнал, що в мережі з'явилось незнайоме залізо.
|
||||
func TestUnknownSourceIsKeptAndCounted(t *testing.T) {
|
||||
r := New(":0", quiet())
|
||||
r.SetResolver(func(ip string) string {
|
||||
if ip == "10.0.0.1" {
|
||||
return "11111111-1111-1111-1111-111111111111"
|
||||
}
|
||||
return ""
|
||||
})
|
||||
|
||||
r.Handle(trapPacket(), "10.0.0.1", now)
|
||||
r.Handle(trapPacket(), "10.0.0.250", now)
|
||||
|
||||
got, _ := r.Drain(0)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("трап від невідомої адреси загубився: %d", len(got))
|
||||
}
|
||||
if got[1].GetDeviceId() != "" {
|
||||
t.Fatalf("невідомій адресі приписано хост: %q", got[1].GetDeviceId())
|
||||
}
|
||||
if r.UnknownSources() != 1 {
|
||||
t.Fatalf("лічильник невідомих джерел: %d", r.UnknownSources())
|
||||
}
|
||||
}
|
||||
|
||||
// Requeue кладе невідправлене НА ПОЧАТОК: linkDown і linkUp того самого
|
||||
// порту відрізняються лише часом, і переставлені місцями вони описують
|
||||
// протилежну подію.
|
||||
func TestRequeueKeepsOrder(t *testing.T) {
|
||||
r := New(":0", quiet())
|
||||
r.perSource.Store(0)
|
||||
|
||||
first := trapPacket()
|
||||
first.Variables[0].Value = "1.3.6.1.6.3.1.1.5.3" // linkDown
|
||||
second := trapPacket()
|
||||
second.Variables[0].Value = "1.3.6.1.6.3.1.1.5.4" // linkUp
|
||||
|
||||
r.Handle(first, "10.0.0.1", now)
|
||||
sent, _ := r.Drain(0)
|
||||
r.Handle(second, "10.0.0.1", now)
|
||||
r.Requeue(sent)
|
||||
|
||||
got, _ := r.Drain(0)
|
||||
if len(got) != 2 || got[0].GetTrapOid() != "1.3.6.1.6.3.1.1.5.3" {
|
||||
t.Fatalf("порядок після Requeue: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Мережа
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// Найдорожча помилка приймача трапів: не підтвердити inform.
|
||||
//
|
||||
// Відправник inform-а вважає подію недоставленою, доки не отримає
|
||||
// відповідь, і шле її знову й знову. Тобто мовчазний приймач
|
||||
// перетворює одну подію на нескінченний потік — рівно тоді, коли
|
||||
// мережа й так у біді. Перевіряємо справжнім обміном по UDP, бо
|
||||
// перевірити це «логікою» неможливо: відповідь формує сам слухач.
|
||||
func TestInformIsAcknowledged(t *testing.T) {
|
||||
addr := freeUDPPort(t)
|
||||
|
||||
r := New(addr, quiet())
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- r.Run(ctx) }()
|
||||
|
||||
sender := &gosnmp.GoSNMP{
|
||||
Target: "127.0.0.1",
|
||||
Port: uint16(portOf(t, addr)),
|
||||
Version: gosnmp.Version2c,
|
||||
Community: "public",
|
||||
Timeout: time.Second,
|
||||
Retries: 5,
|
||||
Transport: "udp",
|
||||
}
|
||||
if err := sender.Connect(); err != nil {
|
||||
t.Fatalf("з'єднання: %v", err)
|
||||
}
|
||||
defer sender.Conn.Close()
|
||||
|
||||
// Відповідь на inform приходить у той самий сокет; без неї виклик
|
||||
// поверне таймаут після всіх повторів.
|
||||
resp, err := sender.SendTrap(gosnmp.SnmpTrap{
|
||||
IsInform: true,
|
||||
Variables: []gosnmp.SnmpPDU{
|
||||
{Name: oidSysUpTime, Type: gosnmp.TimeTicks, Value: uint32(1)},
|
||||
{Name: oidSnmpTrapOID, Type: gosnmp.ObjectIdentifier, Value: "1.3.6.1.6.3.1.1.5.4"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("inform не підтверджено: %v", err)
|
||||
}
|
||||
if resp == nil {
|
||||
t.Fatal("порожня відповідь на inform")
|
||||
}
|
||||
|
||||
// І сама подія має лежати в черзі: підтвердити й загубити — гірше,
|
||||
// ніж не підтвердити, бо відправник більше не повторить.
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
got, _ := r.Drain(0)
|
||||
if len(got) > 0 {
|
||||
if got[0].GetTrapOid() != "1.3.6.1.6.3.1.1.5.4" {
|
||||
t.Fatalf("не той трап: %q", got[0].GetTrapOid())
|
||||
}
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("inform підтверджено, але подія в чергу не потрапила")
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("приймач завершився з помилкою: %v", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("приймач не зупинився за контекстом")
|
||||
}
|
||||
}
|
||||
|
||||
// freeUDPPort знаходить вільний порт: слухач приймає адресу рядком і
|
||||
// свого фактичного порту назовні не показує.
|
||||
func freeUDPPort(t *testing.T) string {
|
||||
t.Helper()
|
||||
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("порт: %v", err)
|
||||
}
|
||||
addr := pc.LocalAddr().String()
|
||||
_ = pc.Close()
|
||||
return addr
|
||||
}
|
||||
|
||||
func portOf(t *testing.T, addr string) int {
|
||||
t.Helper()
|
||||
_, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
t.Fatalf("адреса %q: %v", addr, err)
|
||||
}
|
||||
var n int
|
||||
if _, err := fmt.Sscanf(port, "%d", &n); err != nil {
|
||||
t.Fatalf("порт %q: %v", port, err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
242
agent/internal/ncmx/apply.go
Normal file
242
agent/internal/ncmx/apply.go
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
package ncmx
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
|
||||
)
|
||||
|
||||
// Заливка конфігу на пристрій — єдине місце, де зонд ПИШЕ на залізо.
|
||||
//
|
||||
// Транспорт, вхід, пошук запрошення тут ті самі, що в збору конфігу й
|
||||
// виконанні команд: Dial, WaitPrompt, enable, CLI.Run. Другого шляху до
|
||||
// пристрою в зонда немає й не має бути — сесія, відкрита іншим кодом,
|
||||
// означала б другий набір помилок входу, другий спосіб зіпсувати
|
||||
// стенограму й другу відповідь на питання «чому не під'єднались».
|
||||
//
|
||||
// Відрізняється не механіка, а поводження з помилкою.
|
||||
//
|
||||
// Збір конфігу, який упав, — це відсутність результату: нічого не
|
||||
// сталось, спробуємо ще раз уночі. Заливка, яка впала на третій команді
|
||||
// з двадцяти, — це ДВІ виконані команди на живому вузлі. Тому:
|
||||
//
|
||||
// * зупиняємось на першій же помилці (сервер шле continue_on_error =
|
||||
// false), бо доливати рядки в конфіг, який уже пішов не тим шляхом,
|
||||
// означає отримати стан, якого не описує жоден із двох конфігів;
|
||||
// * звіт віддається ЗАВЖДИ, навіть якщо обірвалось усе, — у ньому
|
||||
// видно, що саме встигло виконатись, а це половина відповіді на
|
||||
// питання, у якому стані зараз вузол;
|
||||
// * команда збереження виконується лише після повного успіху:
|
||||
// зберегти напівзалитий конфіг означає зробити його постійним.
|
||||
//
|
||||
// ПАРОЛЬ У СТЕНОГРАМУ НЕ ПОТРАПЛЯЄ. Правило проєкту лишається чинним і
|
||||
// тут: у буфер стенограми пишуться лише байти, які надіслав САМ
|
||||
// пристрій (див. session.go і transport.go). Але заливка додає ризик,
|
||||
// якого не було в збору: пристрій відлунює те, що ми йому шлемо, а
|
||||
// шлемо ми рядки конфігу — зокрема з паролями. Ті байти прийшли від
|
||||
// пристрою, тож у стенограму вони лягають законно, і прибрати їх тут
|
||||
// нічим: зонд не знає, які саме рядки в цієї родини секретні. Тому
|
||||
// маскування зроблено на сервері, за redact_patterns профілю, до
|
||||
// запису в базу (див. store.FinishApply).
|
||||
|
||||
// ApplyResult — те, що зонд повідомляє про заливку.
|
||||
type ApplyResult struct {
|
||||
Outcomes []*npv1.CommandOutcome
|
||||
Committed bool
|
||||
Transcript string
|
||||
Duration time.Duration
|
||||
// Err — причина, з якої послідовність не дійшла до кінця.
|
||||
Err error
|
||||
}
|
||||
|
||||
// applyRejects — як залізо каже «я не зрозумів цей рядок».
|
||||
//
|
||||
// Окремий перелік, а не спільний із cliRejects зі збору, і різниця
|
||||
// принципова. Там перелік застосовується до КОРОТКОГО виводу й слугує
|
||||
// відповіді на питання «це взагалі конфіг?»; тут — до відповіді на
|
||||
// кожен рядок, і ціна помилки інша в обидва боки.
|
||||
//
|
||||
// Пропустити відмову — найгірше: заливка піде далі, лишивши по собі
|
||||
// напівзастосований конфіг, який виглядає застосованим. Тому сюди
|
||||
// додано те, чого в збірному переліку немає: відмови прав доступу й
|
||||
// скарги на контекст («command is not supported in current mode»).
|
||||
//
|
||||
// Прийняти за відмову звичайний рядок — теж погано, але дешевше: ми
|
||||
// зупинимось раніше, ніж треба, і людина побачить, на чому саме, разом
|
||||
// із самим виводом. Тому зразки прив'язані до початку рядка або до
|
||||
// характерних маркерів («%», «Error:»), а не шукаються будь-де.
|
||||
var applyRejects = regexp.MustCompile(`(?im)^\s*(%|\^|Error:|ERROR:)|` +
|
||||
`invalid input|unknown command|invalid command|incomplete command|` +
|
||||
`syntax error|command not found|next possible completions|` +
|
||||
`not supported|permission denied|access denied|authorization failed|` +
|
||||
`fail(ed)? to (set|apply|create|delete)`)
|
||||
|
||||
// Apply виконує завдання заливки конфігу.
|
||||
//
|
||||
// Помилку в ApplyResult.Err ставить і тоді, коли до пристрою взагалі не
|
||||
// дійшли: звіт із порожнім переліком команд і зрозумілою причиною —
|
||||
// теж відповідь, і саме вона потрібна серверу, щоб не вирішити, що
|
||||
// зонд просто зник.
|
||||
func Apply(ctx context.Context, job *npv1.ConfigApplyJob) ApplyResult {
|
||||
start := time.Now()
|
||||
res := ApplyResult{Outcomes: make([]*npv1.CommandOutcome, 0, len(job.GetCommands()))}
|
||||
|
||||
if len(job.GetCommands()) == 0 {
|
||||
res.Err = fmt.Errorf("завдання заливки без жодної команди")
|
||||
return res
|
||||
}
|
||||
|
||||
promptRe, err := compilePrompt(job.GetPromptRegex())
|
||||
if err != nil {
|
||||
res.Err = err
|
||||
return res
|
||||
}
|
||||
|
||||
timeout := job.GetTimeout().AsDuration()
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Minute
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
// Стенограма пишеться завжди, без прапорця в завданні. Заливка —
|
||||
// незворотна дія: питання «а що саме пристрій на це відповів»
|
||||
// ставлять уже після того, як усе сталося, і другого шансу зняти
|
||||
// ту саму сесію не буде.
|
||||
transcript := &bytes.Buffer{}
|
||||
defer func() {
|
||||
res.Transcript = transcript.String()
|
||||
res.Duration = time.Since(start)
|
||||
}()
|
||||
|
||||
cred := job.GetCredential()
|
||||
conn, err := Dial(ctx, transportName(job.GetTransport()),
|
||||
job.GetDevice().GetAddress(), int(cred.GetPort()),
|
||||
cred.GetUsername(), password(cred), privateKey(cred),
|
||||
connectTimeout(timeout), transcript)
|
||||
if err != nil {
|
||||
res.Err = fmt.Errorf("підключення: %w", err)
|
||||
return res
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
cli := NewCLI(conn, Options{PromptRe: promptRe, Transcript: transcript})
|
||||
|
||||
if err := cli.WaitPrompt(ctx); err != nil {
|
||||
res.Err = fmt.Errorf("привітання пристрою: %w", err)
|
||||
return res
|
||||
}
|
||||
if job.GetEnableRequired() {
|
||||
if err := enable(ctx, cli, cred.GetEnablePassword()); err != nil {
|
||||
res.Err = err
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
res.Outcomes, res.Committed, res.Err = applyLoop(ctx, cli, job)
|
||||
return res
|
||||
}
|
||||
|
||||
// applyLoop проганяє рядки конфігу по черзі.
|
||||
//
|
||||
// Окремо від Apply, щоб перевірятись без мережі: усе, що варте
|
||||
// перевірки, відбувається саме тут, а Dial із живим сокетом у тест не
|
||||
// затягнеш. Так само зроблено для масового виконання команд
|
||||
// (runCommandLoop) — і з тієї ж причини.
|
||||
func applyLoop(ctx context.Context, cli *CLI, job *npv1.ConfigApplyJob) (
|
||||
[]*npv1.CommandOutcome, bool, error) {
|
||||
|
||||
outcomes := make([]*npv1.CommandOutcome, 0, len(job.GetCommands())+1)
|
||||
|
||||
for i, cmd := range job.GetCommands() {
|
||||
out, err := cli.Run(ctx, cmd)
|
||||
oc := &npv1.CommandOutcome{
|
||||
Index: uint32(i),
|
||||
Command: cmd,
|
||||
Output: strings.TrimRight(out, "\n"),
|
||||
Success: true,
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
// Не дочекались запрошення або обірвався канал. Найгірший
|
||||
// різновид: ми не знаємо навіть, чи прочитав пристрій цей
|
||||
// рядок. Далі не йдемо в жодному разі.
|
||||
oc.Success = false
|
||||
oc.ErrorLine = err.Error()
|
||||
outcomes = append(outcomes, oc)
|
||||
return outcomes, false, fmt.Errorf("рядок %d (%q): %w", i+1, cmd, err)
|
||||
}
|
||||
|
||||
if line := rejectLine(out); line != "" {
|
||||
oc.Success = false
|
||||
oc.ErrorLine = line
|
||||
outcomes = append(outcomes, oc)
|
||||
if !job.GetContinueOnError() {
|
||||
return outcomes, false, fmt.Errorf("пристрій відхилив рядок %d (%q): %s",
|
||||
i+1, cmd, line)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
outcomes = append(outcomes, oc)
|
||||
}
|
||||
|
||||
// Збереження — лише після повного успіху.
|
||||
//
|
||||
// Незбережений напівзалитий конфіг має одну важливу властивість:
|
||||
// його прибирає перезавантаження вузла. Зберегти його означає
|
||||
// відібрати в людини цю останню можливість — і зробити помилку
|
||||
// постійною.
|
||||
commit := strings.TrimSpace(job.GetCommitCommand())
|
||||
if commit == "" {
|
||||
return outcomes, false, nil
|
||||
}
|
||||
|
||||
out, err := cli.Run(ctx, commit)
|
||||
oc := &npv1.CommandOutcome{
|
||||
Index: uint32(len(outcomes)),
|
||||
Command: commit,
|
||||
Output: strings.TrimRight(out, "\n"),
|
||||
Success: err == nil && rejectLine(out) == "",
|
||||
}
|
||||
switch {
|
||||
case err != nil:
|
||||
oc.ErrorLine = err.Error()
|
||||
case !oc.Success:
|
||||
oc.ErrorLine = rejectLine(out)
|
||||
}
|
||||
outcomes = append(outcomes, oc)
|
||||
|
||||
if !oc.Success {
|
||||
// Конфіг застосований, але не збережений. Це НЕ повний провал і
|
||||
// не повний успіх: до перезавантаження пристрій працює за новим
|
||||
// конфігом, після — за старим. Такий стан має бути видно
|
||||
// окремо, тому committed лишається false, а причина йде в
|
||||
// помилку.
|
||||
return outcomes, false, fmt.Errorf("конфіг залито, але зберегти не вдалося (%q): %s",
|
||||
commit, oc.ErrorLine)
|
||||
}
|
||||
return outcomes, true, nil
|
||||
}
|
||||
|
||||
// rejectLine повертає перший рядок виводу, схожий на відмову.
|
||||
//
|
||||
// Саме рядок, а не «так/ні»: людині в звіті потрібно бачити, ЧИМ
|
||||
// пристрій відповів. «Пристрій відхилив рядок 7» без його власних слів
|
||||
// — це запрошення відкривати стенограму щоразу.
|
||||
func rejectLine(out string) string {
|
||||
if strings.TrimSpace(out) == "" {
|
||||
return ""
|
||||
}
|
||||
for _, ln := range strings.Split(out, "\n") {
|
||||
if applyRejects.MatchString(ln) {
|
||||
return strings.TrimSpace(ln)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
147
agent/internal/ncmx/apply_test.go
Normal file
147
agent/internal/ncmx/apply_test.go
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
package ncmx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
|
||||
)
|
||||
|
||||
// Перевірки заливки конфігу.
|
||||
//
|
||||
// Головне, що тут перевіряється, — поводження з ВІДМОВОЮ пристрою.
|
||||
// Заливка, яка не помітила відмови й пішла далі, лишає по собі
|
||||
// напівзастосований конфіг, який виглядає застосованим: жодна помилка
|
||||
// нікуди не записалась, звіт зелений, а вузол не той. Це найдорожчий
|
||||
// різновид збою в усій системі, і ловиться він рівно тут.
|
||||
|
||||
func applyJob(commands []string, commit string) *npv1.ConfigApplyJob {
|
||||
return &npv1.ConfigApplyJob{
|
||||
RollbackId: "rb-1",
|
||||
Commands: commands,
|
||||
CommitCommand: commit,
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyRunsAllLinesAndCommits(t *testing.T) {
|
||||
d := newDevice("\nsw1(config)#", "", map[string]string{
|
||||
"configure terminal": "",
|
||||
"vlan 10": "",
|
||||
"end": "",
|
||||
"write memory": "Building configuration...\r\nOK\r\n",
|
||||
})
|
||||
cli := cliFor(d, `[>#]\s*$`)
|
||||
ctx := context.Background()
|
||||
_ = cli.WaitPrompt(ctx)
|
||||
|
||||
outcomes, committed, err := applyLoop(ctx, cli,
|
||||
applyJob([]string{"configure terminal", "vlan 10", "end"}, "write memory"))
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("несподівана помилка: %v", err)
|
||||
}
|
||||
if !committed {
|
||||
t.Error("команда збереження відпрацювала, а committed лишився false")
|
||||
}
|
||||
if len(outcomes) != 4 {
|
||||
t.Fatalf("очікували 4 результати (3 рядки + збереження), отримали %d", len(outcomes))
|
||||
}
|
||||
for _, oc := range outcomes {
|
||||
if !oc.GetSuccess() {
|
||||
t.Errorf("рядок %q позначено невдалим: %s", oc.GetCommand(), oc.GetErrorLine())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Відмову пристрою видно лише у виводі: коду помилки в CLI немає.
|
||||
// Пропустити її означає долити решту конфігу поверх того, що не
|
||||
// застосувалось.
|
||||
func TestApplyStopsOnDeviceRejection(t *testing.T) {
|
||||
d := newDevice("\nsw1(config)#", "", map[string]string{
|
||||
"configure terminal": "",
|
||||
"vlan 10": "% Invalid input detected at '^' marker.\r\n",
|
||||
"name office": "",
|
||||
})
|
||||
cli := cliFor(d, `[>#]\s*$`)
|
||||
ctx := context.Background()
|
||||
_ = cli.WaitPrompt(ctx)
|
||||
|
||||
outcomes, committed, err := applyLoop(ctx, cli,
|
||||
applyJob([]string{"configure terminal", "vlan 10", "name office"}, "write memory"))
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("відмову пристрою не помічено")
|
||||
}
|
||||
if committed {
|
||||
t.Error("напівзалитий конфіг не має зберігатись")
|
||||
}
|
||||
if len(outcomes) != 2 {
|
||||
t.Fatalf("послідовність мала спинитись на другому рядку, отримали %d", len(outcomes))
|
||||
}
|
||||
if outcomes[1].GetSuccess() {
|
||||
t.Error("відхилений рядок позначено успішним")
|
||||
}
|
||||
if !strings.Contains(outcomes[1].GetErrorLine(), "Invalid input") {
|
||||
t.Errorf("у звіті немає слів самого пристрою: %q", outcomes[1].GetErrorLine())
|
||||
}
|
||||
}
|
||||
|
||||
// Невдале збереження — це НЕ провал заливки: конфіг уже на пристрої й
|
||||
// працює. Але й не успіх: перезавантаження його прибере. Стан має бути
|
||||
// видно окремо, тому committed лишається false, а помилка — непорожня.
|
||||
func TestApplyReportsFailedCommitSeparately(t *testing.T) {
|
||||
d := newDevice("\nsw1(config)#", "", map[string]string{
|
||||
"configure terminal": "",
|
||||
"vlan 10": "",
|
||||
"write memory": "%% Error: startup-config is locked\r\n",
|
||||
})
|
||||
cli := cliFor(d, `[>#]\s*$`)
|
||||
ctx := context.Background()
|
||||
_ = cli.WaitPrompt(ctx)
|
||||
|
||||
outcomes, committed, err := applyLoop(ctx, cli,
|
||||
applyJob([]string{"configure terminal", "vlan 10"}, "write memory"))
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("невдале збереження мало дати помилку")
|
||||
}
|
||||
if committed {
|
||||
t.Error("committed має лишитись false")
|
||||
}
|
||||
// Рядки конфігу при цьому успішні: вони справді виконались.
|
||||
if !outcomes[0].GetSuccess() || !outcomes[1].GetSuccess() {
|
||||
t.Error("рядки конфігу помилково позначено невдалими")
|
||||
}
|
||||
if outcomes[len(outcomes)-1].GetSuccess() {
|
||||
t.Error("невдале збереження позначено успішним")
|
||||
}
|
||||
}
|
||||
|
||||
// Порожній вивід — нормальна відповідь на конфігураційний рядок:
|
||||
// більшість платформ на успішний `vlan 10` не пише нічого.
|
||||
func TestApplyAcceptsSilentSuccess(t *testing.T) {
|
||||
d := newDevice("\nsw1(config)#", "", map[string]string{"vlan 10": ""})
|
||||
cli := cliFor(d, `[>#]\s*$`)
|
||||
ctx := context.Background()
|
||||
_ = cli.WaitPrompt(ctx)
|
||||
|
||||
outcomes, _, err := applyLoop(ctx, cli, applyJob([]string{"vlan 10"}, ""))
|
||||
if err != nil {
|
||||
t.Fatalf("мовчазний успіх прийнято за помилку: %v", err)
|
||||
}
|
||||
if len(outcomes) != 1 || !outcomes[0].GetSuccess() {
|
||||
t.Fatalf("отримали %+v", outcomes)
|
||||
}
|
||||
}
|
||||
|
||||
// Слово «invalid» усередині законного рядка не має зупиняти заливку:
|
||||
// зразки відмов прив'язані до маркерів, а не шукаються будь-де в тексті.
|
||||
func TestApplyDoesNotMistakeConfigTextForRejection(t *testing.T) {
|
||||
if line := rejectLine("ip access-list extended INVALID-SRC"); line != "" {
|
||||
t.Errorf("законний рядок прийнято за відмову: %q", line)
|
||||
}
|
||||
if line := rejectLine("% Invalid input detected"); line == "" {
|
||||
t.Error("справжню відмову не помічено")
|
||||
}
|
||||
}
|
||||
|
|
@ -57,7 +57,8 @@ func Collect(ctx context.Context, job *npv1.ConfigJob) (Result, error) {
|
|||
|
||||
conn, err := Dial(ctx, transportName(job.GetTransport()),
|
||||
job.GetDevice().GetAddress(), int(job.GetPort()),
|
||||
cred.GetUsername(), password(cred), connectTimeout(timeout), transcript)
|
||||
cred.GetUsername(), password(cred), privateKey(cred),
|
||||
connectTimeout(timeout), transcript)
|
||||
if err != nil {
|
||||
return withTranscript(Result{}, transcript), err
|
||||
}
|
||||
|
|
@ -96,9 +97,8 @@ func Collect(ctx context.Context, job *npv1.ConfigJob) (Result, error) {
|
|||
if err != nil {
|
||||
return withTranscript(Result{}, transcript), fmt.Errorf("команда %q: %w", last, err)
|
||||
}
|
||||
if strings.TrimSpace(body) == "" {
|
||||
return withTranscript(Result{}, transcript),
|
||||
fmt.Errorf("команда %q повернула порожній вивід", last)
|
||||
if err := looksLikeConfig(last, body); err != nil {
|
||||
return withTranscript(Result{}, transcript), err
|
||||
}
|
||||
|
||||
b := []byte(body)
|
||||
|
|
@ -187,6 +187,17 @@ func password(c *npv1.Credential) string {
|
|||
return ""
|
||||
}
|
||||
|
||||
// privateKey — SSH-ключ, якщо доступ заведений із ключем, а не паролем.
|
||||
func privateKey(c *npv1.Credential) []byte {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
if k, ok := c.GetSecret().(*npv1.Credential_PrivateKey); ok {
|
||||
return k.PrivateKey
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func transportName(t npv1.Transport) string {
|
||||
switch t {
|
||||
case npv1.Transport_TRANSPORT_TELNET:
|
||||
|
|
@ -217,3 +228,63 @@ func withTranscript(r Result, b *bytes.Buffer) Result {
|
|||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// Скільки змістовних рядків мусить мати те, що ми називаємо конфігом.
|
||||
//
|
||||
// Порогу вистачає одного, і він грубий навмисне: конфігурація будь-якої
|
||||
// коробки — це десятки рядків щонайменше, а все, що коротше, — це
|
||||
// пристрій відповідає щось інше. Тонше налаштування тут було б
|
||||
// самообманом: точного числа не існує, а видимість точності заважала б
|
||||
// помітити, що поріг узагалі спрацював.
|
||||
const minConfigLines = 5
|
||||
|
||||
// cliRejects — як залізо каже «я не зрозумів команду».
|
||||
//
|
||||
// Кожен виробник каже по-своєму, і жоден не робить це кодом помилки:
|
||||
// для CLI відмова — це просто ще один рядок виводу. Зразки шукаються
|
||||
// лише в короткому виводі (див. looksLikeConfig): у справжньому конфізі
|
||||
// на тридцять тисяч рядків слова «invalid» чи «unknown» трапляються в
|
||||
// іменах ACL і в банерах, і глушити збір через них не можна.
|
||||
var cliRejects = regexp.MustCompile(`(?i)next possible completions|` +
|
||||
`invalid input|unknown command|invalid command|incomplete command|` +
|
||||
`available commands|command not found|syntax error|% *bad|permission denied`)
|
||||
|
||||
// looksLikeConfig відсіює відповіді, які конфігом не є.
|
||||
//
|
||||
// Навіщо окрема перевірка, якщо порожній вивід ми й так ловили. Бо
|
||||
// пристрій рідко мовчить: на невідому команду D-Link відповідає
|
||||
// «Next possible completions», Cisco — «% Invalid input», і кожна з цих
|
||||
// відповідей непорожня. Далі вона лягала в сховище як версія конфігу,
|
||||
// завдання позначалось успішним, і людина дізнавалась про це через
|
||||
// півроку — коли конфіг знадобився.
|
||||
//
|
||||
// Гучна помилка тут коштує одного дзвінка. Тиха — усього архіву.
|
||||
func looksLikeConfig(cmd, body string) error {
|
||||
trimmed := strings.TrimSpace(body)
|
||||
if trimmed == "" {
|
||||
return fmt.Errorf("команда %q повернула порожній вивід", cmd)
|
||||
}
|
||||
|
||||
var lines int
|
||||
for _, ln := range strings.Split(trimmed, "\n") {
|
||||
if strings.TrimSpace(ln) != "" {
|
||||
lines++
|
||||
}
|
||||
}
|
||||
if lines >= minConfigLines && !cliRejects.MatchString(trimmed) {
|
||||
return nil
|
||||
}
|
||||
// Зразок відмови в довгому виводі нічого не означає — там це просто
|
||||
// слово всередині конфігу.
|
||||
if lines >= minConfigLines {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Показуємо, що саме сказав пристрій: без цього людині лишається
|
||||
// здогадуватись, чим команда з профілю не підійшла її залізу.
|
||||
said := trimmed
|
||||
if len(said) > 300 {
|
||||
said = said[:300] + "…"
|
||||
}
|
||||
return fmt.Errorf("команда %q не дала конфігу — пристрій відповів: %s", cmd, said)
|
||||
}
|
||||
|
|
|
|||
144
agent/internal/ncmx/commands.go
Normal file
144
agent/internal/ncmx/commands.go
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
package ncmx
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
|
||||
)
|
||||
|
||||
// Виконання довільних команд — той самий сеанс, що й збір конфігу, з
|
||||
// однією відмінністю: тут цікавий вивід КОЖНОЇ команди, а не лише
|
||||
// останньої.
|
||||
//
|
||||
// Через це Collect тут не годиться, хоч і робить майже те саме: у ньому
|
||||
// вивід підготовчих команд свідомо відкидається, а результат проходить
|
||||
// перевірку «схоже на конфіг». Обидва рішення правильні для бекапу й
|
||||
// обидва хибні тут: `display cpu` не схожий на конфіг і ніколи не
|
||||
// пройде looksLikeConfig, а вивід підготовчої команди — єдине місце, де
|
||||
// видно, що консоль так і лишилась посторінковою.
|
||||
|
||||
// Outcome — результат однієї команди.
|
||||
type Outcome struct {
|
||||
Command string `json:"command"`
|
||||
Output string `json:"output"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// CommandReport — тіло, яке їде на сервер замість тексту конфігу.
|
||||
//
|
||||
// JSON, а не суцільний текст із роздільниками: питання до результату
|
||||
// завжди адресне («що відповіла ОЦЯ команда»), і різати спільний потік
|
||||
// назад на команди означало б удруге робити роботу, яку ми вже зробили
|
||||
// тут — до того ж навгад, бо роздільник може трапитись і у виводі.
|
||||
type CommandReport struct {
|
||||
Outcomes []Outcome `json:"outcomes"`
|
||||
// Чому послідовність обірвалась. Порожнє — усі команди відпрацювали.
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// RunCommands виконує всі команди завдання й повертає вивід кожної.
|
||||
//
|
||||
// Помилку повертає лише тоді, коли розповідати нічого: не під'єднались,
|
||||
// не дочекались привітання. Усе, що сталося після входу, — це вміст
|
||||
// звіту, а не відмова: половина виводу теж відповідь, і саме в ній
|
||||
// зазвичай видно, чому впала друга половина.
|
||||
func RunCommands(ctx context.Context, job *npv1.ConfigJob) (Result, error) {
|
||||
start := time.Now()
|
||||
|
||||
if len(job.GetCommands()) == 0 {
|
||||
return Result{}, fmt.Errorf("завдання без жодної команди")
|
||||
}
|
||||
|
||||
promptRe, err := compilePrompt(job.GetPromptRegex())
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
timeout := job.GetTimeout().AsDuration()
|
||||
if timeout <= 0 {
|
||||
timeout = 2 * time.Minute
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
cred := job.GetCredential()
|
||||
|
||||
var transcript *bytes.Buffer
|
||||
if job.GetCaptureTranscript() {
|
||||
transcript = &bytes.Buffer{}
|
||||
}
|
||||
|
||||
conn, err := Dial(ctx, transportName(job.GetTransport()),
|
||||
job.GetDevice().GetAddress(), int(job.GetPort()),
|
||||
cred.GetUsername(), password(cred), privateKey(cred),
|
||||
connectTimeout(timeout), transcript)
|
||||
if err != nil {
|
||||
return withTranscript(Result{}, transcript), err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
cli := NewCLI(conn, Options{
|
||||
PromptRe: promptRe,
|
||||
MaxBytes: int(job.GetMaxBytes()),
|
||||
Transcript: transcript,
|
||||
})
|
||||
|
||||
if err := cli.WaitPrompt(ctx); err != nil {
|
||||
return withTranscript(Result{}, transcript),
|
||||
fmt.Errorf("привітання пристрою: %w", err)
|
||||
}
|
||||
|
||||
if job.GetEnableRequired() {
|
||||
if err := enable(ctx, cli, cred.GetEnablePassword()); err != nil {
|
||||
return withTranscript(Result{}, transcript), err
|
||||
}
|
||||
}
|
||||
|
||||
rep := runCommandLoop(ctx, cli, job.GetCommands())
|
||||
|
||||
body, err := json.Marshal(rep)
|
||||
if err != nil {
|
||||
return withTranscript(Result{}, transcript), err
|
||||
}
|
||||
sum := sha256.Sum256(body)
|
||||
|
||||
res := Result{
|
||||
Body: body,
|
||||
SHA256: sum[:],
|
||||
LineCount: len(rep.Outcomes),
|
||||
Duration: time.Since(start),
|
||||
}
|
||||
return withTranscript(res, transcript), nil
|
||||
}
|
||||
|
||||
// runCommandLoop проганяє команди по черзі й збирає вивід кожної.
|
||||
//
|
||||
// Окремо від RunCommands, щоб перевірятись без мережі: усе, що варте
|
||||
// перевірки, відбувається саме тут, а Dial із живим сокетом у тест не
|
||||
// затягнеш.
|
||||
func runCommandLoop(ctx context.Context, cli *CLI, commands []string) CommandReport {
|
||||
rep := CommandReport{Outcomes: make([]Outcome, 0, len(commands))}
|
||||
|
||||
for _, cmd := range commands {
|
||||
out, err := cli.Run(ctx, cmd)
|
||||
oc := Outcome{Command: cmd, Output: strings.TrimRight(out, "\n")}
|
||||
if err != nil {
|
||||
oc.Error = err.Error()
|
||||
rep.Outcomes = append(rep.Outcomes, oc)
|
||||
// Далі не йдемо. Не дочекались запрошення — означає, що
|
||||
// пристрій ще щось пише або чекає на щось інше; наступна
|
||||
// команда пішла б у невідомо який контекст, а її вивід
|
||||
// приписався б не тій команді.
|
||||
rep.Error = fmt.Sprintf("команда %q: %v", cmd, err)
|
||||
break
|
||||
}
|
||||
rep.Outcomes = append(rep.Outcomes, oc)
|
||||
}
|
||||
return rep
|
||||
}
|
||||
107
agent/internal/ncmx/commands_test.go
Normal file
107
agent/internal/ncmx/commands_test.go
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
package ncmx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Вивід кожної команди має лишатись при своїй команді.
|
||||
//
|
||||
// Це і є вся суть масового виконання: збір конфігу вивід попередніх
|
||||
// команд відкидає, і якби тут використали його, оператор отримав би
|
||||
// відповідь лише на останню.
|
||||
func TestCommandLoopKeepsOutputPerCommand(t *testing.T) {
|
||||
d := newDevice("\nsw1#", "Welcome\r\n", map[string]string{
|
||||
"display version": "VRP (R) software, Version 5.170\r\n",
|
||||
"display cpu": "CPU Usage : 7%\r\n",
|
||||
})
|
||||
cli := cliFor(d, `[>#]\s*$`)
|
||||
ctx := context.Background()
|
||||
if err := cli.WaitPrompt(ctx); err != nil {
|
||||
t.Fatalf("привітання: %v", err)
|
||||
}
|
||||
|
||||
rep := runCommandLoop(ctx, cli, []string{"display version", "display cpu"})
|
||||
|
||||
if rep.Error != "" {
|
||||
t.Fatalf("несподівана помилка: %s", rep.Error)
|
||||
}
|
||||
if len(rep.Outcomes) != 2 {
|
||||
t.Fatalf("очікували 2 результати, отримали %d", len(rep.Outcomes))
|
||||
}
|
||||
if rep.Outcomes[0].Command != "display version" ||
|
||||
!strings.Contains(rep.Outcomes[0].Output, "Version 5.170") {
|
||||
t.Fatalf("перша команда: %+v", rep.Outcomes[0])
|
||||
}
|
||||
if rep.Outcomes[1].Command != "display cpu" ||
|
||||
!strings.Contains(rep.Outcomes[1].Output, "CPU Usage") {
|
||||
t.Fatalf("друга команда: %+v", rep.Outcomes[1])
|
||||
}
|
||||
}
|
||||
|
||||
// Порожня відповідь — це відповідь.
|
||||
//
|
||||
// На відміну від збору конфігу, де порожній вивід означає провал:
|
||||
// «undo terminal monitor» нічого не друкує й відпрацьовує правильно.
|
||||
func TestCommandLoopAcceptsEmptyOutput(t *testing.T) {
|
||||
d := newDevice("\nsw1#", "", map[string]string{
|
||||
"screen-length 0 temporary": "",
|
||||
})
|
||||
cli := cliFor(d, `[>#]\s*$`)
|
||||
ctx := context.Background()
|
||||
_ = cli.WaitPrompt(ctx)
|
||||
|
||||
rep := runCommandLoop(ctx, cli, []string{"screen-length 0 temporary"})
|
||||
|
||||
if rep.Error != "" {
|
||||
t.Fatalf("порожній вивід не мав бути помилкою: %s", rep.Error)
|
||||
}
|
||||
if len(rep.Outcomes) != 1 || rep.Outcomes[0].Output != "" {
|
||||
t.Fatalf("отримали %+v", rep.Outcomes)
|
||||
}
|
||||
}
|
||||
|
||||
// Збій посеред послідовності зупиняє її, але вже зібране лишається.
|
||||
//
|
||||
// Половина виводу — половина відповіді, і саме в ній зазвичай видно,
|
||||
// чому не стало другої половини. Продовжувати не можна: сесія лишилась
|
||||
// у невідомому стані, і вивід наступної команди приписався б не їй.
|
||||
func TestCommandLoopStopsAtFirstFailureAndKeepsWhatItGot(t *testing.T) {
|
||||
// Друга команда впирається в ліміт розміру: пристрій сипле більше,
|
||||
// ніж дозволено, і сесія лишається на середині чужого виводу.
|
||||
d := newDevice("\nsw1#", "", map[string]string{
|
||||
"display version": "5.170\r\n",
|
||||
"display cpu": strings.Repeat("x", 500) + "\r\n",
|
||||
})
|
||||
d.chunkLen = 3 // дрібними шматками, щоб ліміт спрацював до промпту
|
||||
|
||||
cli := NewCLI(d, Options{
|
||||
PromptRe: promptFor(`[>#]\s*$`),
|
||||
MaxBytes: 64,
|
||||
IdleTimeout: time.Second,
|
||||
})
|
||||
ctx := context.Background()
|
||||
_ = cli.WaitPrompt(ctx)
|
||||
|
||||
rep := runCommandLoop(ctx, cli, []string{"display version", "display cpu"})
|
||||
|
||||
if rep.Error == "" {
|
||||
t.Fatal("збій мав потрапити в звіт прогону")
|
||||
}
|
||||
if len(rep.Outcomes) != 2 {
|
||||
t.Fatalf("зібране до збою мало лишитись, отримали %d", len(rep.Outcomes))
|
||||
}
|
||||
if rep.Outcomes[0].Error != "" {
|
||||
t.Fatalf("перша команда мала відпрацювати: %+v", rep.Outcomes[0])
|
||||
}
|
||||
if rep.Outcomes[1].Error == "" {
|
||||
t.Fatalf("друга команда мала нести помилку: %+v", rep.Outcomes[1])
|
||||
}
|
||||
}
|
||||
|
||||
func promptFor(pattern string) *regexp.Regexp {
|
||||
return regexp.MustCompile(`(?m)` + pattern)
|
||||
}
|
||||
|
|
@ -21,14 +21,14 @@ import (
|
|||
// запису від пристрою лишається тільки «i/o timeout» без жодної підказки,
|
||||
// чого він чекав.
|
||||
func Dial(ctx context.Context, transport, host string, port int,
|
||||
user, password string, timeout time.Duration, tr *bytes.Buffer) (Conn, error) {
|
||||
user, password string, key []byte, timeout time.Duration, tr *bytes.Buffer) (Conn, error) {
|
||||
|
||||
switch transport {
|
||||
case "ssh", "":
|
||||
if port == 0 {
|
||||
port = 22
|
||||
}
|
||||
return dialSSH(ctx, host, port, user, password, timeout)
|
||||
return dialSSH(ctx, host, port, user, password, key, timeout)
|
||||
case "telnet":
|
||||
if port == 0 {
|
||||
port = 23
|
||||
|
|
@ -51,11 +51,33 @@ type sshConn struct {
|
|||
}
|
||||
|
||||
func dialSSH(ctx context.Context, host string, port int,
|
||||
user, password string, timeout time.Duration) (Conn, error) {
|
||||
user, password string, key []byte, timeout time.Duration) (Conn, error) {
|
||||
|
||||
cfg := &ssh.ClientConfig{
|
||||
User: user,
|
||||
Auth: []ssh.AuthMethod{
|
||||
// Ключ першим, якщо він є.
|
||||
//
|
||||
// На магістральному залізі вхід за паролем часто просто вимкнений —
|
||||
// і тоді сервер відхиляє і password, і keyboard-interactive, а
|
||||
// go-ssh каже «no supported methods remain», не пояснюючи, що саме
|
||||
// від нас хотіли. Поле private_key було в протоколі від початку,
|
||||
// але його ніхто не читав: транспорт брав лише пароль.
|
||||
var auth []ssh.AuthMethod
|
||||
if len(key) > 0 {
|
||||
signer, err := ssh.ParsePrivateKey(key)
|
||||
if err != nil {
|
||||
// Пароль може бути парольною фразою до ключа — пробуємо і
|
||||
// так, перш ніж здаватись.
|
||||
if password == "" {
|
||||
return nil, fmt.Errorf("розбір SSH-ключа: %w", err)
|
||||
}
|
||||
signer, err = ssh.ParsePrivateKeyWithPassphrase(key, []byte(password))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("розбір SSH-ключа: %w", err)
|
||||
}
|
||||
}
|
||||
auth = append(auth, ssh.PublicKeys(signer))
|
||||
}
|
||||
if password != "" {
|
||||
auth = append(auth,
|
||||
ssh.Password(password),
|
||||
// Частина старих платформ не вміє «password», лише
|
||||
// keyboard-interactive з єдиним запитом.
|
||||
|
|
@ -66,7 +88,15 @@ func dialSSH(ctx context.Context, host string, port int,
|
|||
}
|
||||
return ans, nil
|
||||
}),
|
||||
},
|
||||
)
|
||||
}
|
||||
if len(auth) == 0 {
|
||||
return nil, fmt.Errorf("для SSH не задано ні пароля, ні ключа")
|
||||
}
|
||||
|
||||
cfg := &ssh.ClientConfig{
|
||||
User: user,
|
||||
Auth: auth,
|
||||
// Ключі мережевого обладнання не звіряються.
|
||||
//
|
||||
// Це свідоме рішення, а не недогляд. Зонд стоїть усередині
|
||||
|
|
|
|||
|
|
@ -463,7 +463,10 @@ func (s *Scheduler) execute(ctx context.Context, e *entry) {
|
|||
|
||||
s.sink.Add(task.DeviceID, module.ModuleKey(task.CheckType), res)
|
||||
s.sink.AddCheckResult(cr)
|
||||
if len(res.Neighbors) > 0 || len(res.InterfaceRecords) > 0 {
|
||||
// Devices теж рахуються: у режимі самого лише розпізнавання звіт
|
||||
// не містить ні сусідів, ні портів — тільки системну групу, заради
|
||||
// якої чек і заведено. Без цієї умови вона нікуди не їхала.
|
||||
if len(res.Neighbors) > 0 || len(res.InterfaceRecords) > 0 || len(res.Devices) > 0 {
|
||||
s.onDisco(res)
|
||||
}
|
||||
s.onStatus(&npv1.TaskStatusUpdate{
|
||||
|
|
|
|||
90
agent/internal/session/apply_jobs.go
Normal file
90
agent/internal/session/apply_jobs.go
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
package session
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/netpulse/netpulse/agent/internal/ncmx"
|
||||
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
|
||||
"google.golang.org/protobuf/types/known/durationpb"
|
||||
)
|
||||
|
||||
// runApplyJob заливає конфіг на пристрій і звітує про результат.
|
||||
//
|
||||
// Виконується в окремій горутині — з тієї ж причини, що й збір конфігу:
|
||||
// заливка триває хвилини, і тримати на ній контрольний цикл означало б
|
||||
// не відповідати на ping і бути визнаним мертвим саме тоді, коли зонд
|
||||
// робить найнебезпечнішу роботу.
|
||||
//
|
||||
// Жодної власної логіки погодження тут немає й не буде. Завдання, що
|
||||
// приїхало в межах живої автентифікованої сесії, виконується — усе
|
||||
// рішення про те, чи можна його виконувати, ухвалено на сервері
|
||||
// (ncm.rollbacks: awaiting_approval → approved). Зонд, який намагався б
|
||||
// вирішувати це вдруге, мав би власну копію політики — і вона розійшлася
|
||||
// б із серверною першого ж дня.
|
||||
func (s *Session) runApplyJob(ctx context.Context, out chan<- *npv1.ControlUp,
|
||||
job *npv1.ConfigApplyJob) {
|
||||
|
||||
log := s.log.With("rollback", job.GetRollbackId(),
|
||||
"device", job.GetDevice().GetName(),
|
||||
"рядків", len(job.GetCommands()))
|
||||
|
||||
// Warn, а не Info, і до початку роботи. Запис на живе залізо — те,
|
||||
// що шукають у журналі зонда після аварії, і рядок «почали» має
|
||||
// стояти навіть тоді, коли після нього не буде жодного іншого.
|
||||
log.Warn("заливка конфігу почалась")
|
||||
|
||||
res := ncmx.Apply(ctx, job)
|
||||
|
||||
result := &npv1.ConfigApplyResult{
|
||||
RollbackId: job.GetRollbackId(),
|
||||
Success: res.Err == nil,
|
||||
Outcomes: res.Outcomes,
|
||||
Committed: res.Committed,
|
||||
Transcript: res.Transcript,
|
||||
Duration: durationpb.New(res.Duration),
|
||||
}
|
||||
if res.Err != nil {
|
||||
result.Error = &npv1.Error{Code: "apply_failed", Message: res.Err.Error()}
|
||||
log.Error("заливка конфігу не завершилась",
|
||||
"помилка", res.Err, "виконано_рядків", len(res.Outcomes))
|
||||
} else {
|
||||
log.Warn("заливка конфігу завершена",
|
||||
"рядків", len(res.Outcomes), "збережено", res.Committed,
|
||||
"тривалість", res.Duration.Round(time.Millisecond))
|
||||
}
|
||||
|
||||
s.sendApplyResult(ctx, out, result)
|
||||
}
|
||||
|
||||
// sendApplyResult віддає звіт, чекаючи місця в черзі.
|
||||
//
|
||||
// Тут свідомо НЕ enqueue. Той кидає повідомлення, коли черга забита, і
|
||||
// для heartbeat це правильно: сервер помітить пропажу за таймаутом.
|
||||
// Звіт про заливку загубити не можна за жодних умов — сервер, який його
|
||||
// не отримав, знає лише, що на пристрій щось пішло, і не знає, чим
|
||||
// скінчилось.
|
||||
//
|
||||
// Якщо ж канал усе-таки помер разом із сесією, звіт справді зникне — і
|
||||
// саме на цей випадок на сервері стоїть прибиральник, який переводить
|
||||
// таке завдання не у відмову, а в контрольний збір: піти й подивитись,
|
||||
// що реально стало на пристрої. Здогадуватись тут нема про що, пристрій
|
||||
// поруч.
|
||||
func (s *Session) sendApplyResult(ctx context.Context, out chan<- *npv1.ControlUp,
|
||||
result *npv1.ConfigApplyResult) {
|
||||
|
||||
msg := &npv1.ControlUp{
|
||||
Seq: s.seq.Add(1),
|
||||
Payload: &npv1.ControlUp_ConfigApplyResult{ConfigApplyResult: result},
|
||||
}
|
||||
|
||||
select {
|
||||
case out <- msg:
|
||||
case <-ctx.Done():
|
||||
s.log.Error("звіт про заливку не поїхав: сесія обірвалась",
|
||||
"rollback", result.GetRollbackId())
|
||||
case <-time.After(30 * time.Second):
|
||||
s.log.Error("звіт про заливку не поїхав: контрольний канал забитий",
|
||||
"rollback", result.GetRollbackId())
|
||||
}
|
||||
}
|
||||
53
agent/internal/session/command_jobs.go
Normal file
53
agent/internal/session/command_jobs.go
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
package session
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/netpulse/netpulse/agent/internal/ncmx"
|
||||
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
|
||||
)
|
||||
|
||||
// commandConfigType — значення config_type, яким сервер позначає
|
||||
// «виконати команди», а не «зняти конфіг».
|
||||
//
|
||||
// Поле вже було й уже возило вид зрізу ('running', 'startup'), тож нове
|
||||
// значення обійшлося без змін у .proto. Той самий рядок знає сервер
|
||||
// (store.CommandConfigType); спільної залежності між ними немає навмисно
|
||||
// — агент і сервер оновлюються нарізно, і константа в кожного своя
|
||||
// рівно тому, що змінювати її не можна жодному з двох.
|
||||
const commandConfigType = "command"
|
||||
|
||||
// runCommandJob виконує довільні команди й віддає вивід тим самим
|
||||
// стрімом, що й конфіг.
|
||||
//
|
||||
// Окрема функція, а не гілка всередині runConfigJob, бо відрізняється
|
||||
// поводження з невдачею. Збір конфігу без конфігу — порожній результат,
|
||||
// і його треба відправити як відмову. Прогін команд без частини виводу
|
||||
// — це все ще результат: половина відповіді краще за нічого, і саме в
|
||||
// ній зазвичай видно, чому не стало другої половини.
|
||||
func (s *Session) runCommandJob(ctx context.Context, job *npv1.ConfigJob) {
|
||||
log := s.log.With("job_id", job.GetJobId(),
|
||||
"device", job.GetDevice().GetName(),
|
||||
"команд", len(job.GetCommands()))
|
||||
|
||||
log.Info("виконання команд почалось")
|
||||
|
||||
res, err := ncmx.RunCommands(ctx, job)
|
||||
if err != nil {
|
||||
// Сюди доходить лише те, після чого немає що показувати: не
|
||||
// під'єднались, не дочекались привітання. Стенограма при цьому
|
||||
// найцінніша — у ній видно, на чому саме стало.
|
||||
log.Error("виконання команд", "помилка", err)
|
||||
s.uploadFailure(ctx, job, err, res.Transcript)
|
||||
return
|
||||
}
|
||||
|
||||
log.Info("команди виконано",
|
||||
"байтів", len(res.Body),
|
||||
"тривалість", res.Duration.Round(time.Millisecond))
|
||||
|
||||
if err := s.uploadConfig(ctx, job, res); err != nil {
|
||||
log.Error("вивантаження результату команд", "помилка", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -34,6 +34,29 @@ func (s *Session) runConfigJob(ctx context.Context, job *npv1.ConfigJob) {
|
|||
"device", job.GetDevice().GetName(),
|
||||
"config_type", job.GetConfigType())
|
||||
|
||||
// Масове виконання команд їде тим самим завданням і тим самим
|
||||
// стрімом — інакше довелося б заводити другий шлях сервер→зонд
|
||||
// заради того, що вже працює. Різниця лише в тому, що робити з
|
||||
// виводом, і про неї каже config_type.
|
||||
//
|
||||
// Гілка тут, а не в ncmx: транспорт, вхід і пошук запрошення в обох
|
||||
// випадках однакові, і розводити їх нижче означало б розводити й це.
|
||||
if job.GetConfigType() == commandConfigType {
|
||||
s.runCommandJob(ctx, job)
|
||||
return
|
||||
}
|
||||
|
||||
// Конфіг-файли самої машини — теж це саме завдання й те саме
|
||||
// вивантаження, лише замість сесії до пристрою читається власний
|
||||
// диск. Розвилка за наявністю local_files, а не за config_type:
|
||||
// config_type тут означає назву зрізу в архіві, і навантажувати
|
||||
// його ще й способом збору означало б, що перейменування зрізу
|
||||
// тихо змінює те, звідки беруться дані.
|
||||
if job.GetLocalFiles() != nil {
|
||||
s.runLocalFilesJob(ctx, job)
|
||||
return
|
||||
}
|
||||
|
||||
log.Info("збір конфігу почався")
|
||||
|
||||
res, err := ncmx.Collect(ctx, job)
|
||||
|
|
@ -53,6 +76,17 @@ func (s *Session) runConfigJob(ctx context.Context, job *npv1.ConfigJob) {
|
|||
}
|
||||
|
||||
func (s *Session) uploadConfig(ctx context.Context, job *npv1.ConfigJob, res ncmx.Result) error {
|
||||
return s.uploadConfigAs(ctx, job, res, "")
|
||||
}
|
||||
|
||||
// uploadConfigAs — те саме вивантаження, але з відбитком машини.
|
||||
//
|
||||
// Відбиток заповнює лише збір локальних файлів: для комутатора питання
|
||||
// «з якої машини це знято» не має сенсу — знято з пристрою, а зонд лише
|
||||
// набирав. Для сервера воно головне, бо зонд і є той сервер.
|
||||
func (s *Session) uploadConfigAs(ctx context.Context, job *npv1.ConfigJob,
|
||||
res ncmx.Result, machineID string) error {
|
||||
|
||||
conn := s.client.Load()
|
||||
if conn == nil {
|
||||
return errNoConnection
|
||||
|
|
@ -84,6 +118,7 @@ func (s *Session) uploadConfig(ctx context.Context, job *npv1.ConfigJob, res ncm
|
|||
ConfigType: job.GetConfigType(),
|
||||
CollectedAt: timestamppb.Now(),
|
||||
Encoding: "gzip",
|
||||
MachineId: machineID,
|
||||
}},
|
||||
}); err != nil {
|
||||
return err
|
||||
|
|
|
|||
73
agent/internal/session/localfiles_jobs.go
Normal file
73
agent/internal/session/localfiles_jobs.go
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
package session
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/netpulse/netpulse/agent/internal/localfiles"
|
||||
"github.com/netpulse/netpulse/agent/internal/ncmx"
|
||||
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
|
||||
)
|
||||
|
||||
// runLocalFilesJob збирає конфіг-файли машини, на якій працює зонд.
|
||||
//
|
||||
// Гілка стоїть поруч із бекапом комутатора й закінчується тим самим
|
||||
// вивантаженням навмисно. Розклад (ncm.device_policies), кнопка «зібрати
|
||||
// зараз», черга ncm.jobs, звірка з попередньою версією, коміт у Git,
|
||||
// ретеншен і дзеркало — усе це вже є й працює. Другий шлях для файлів
|
||||
// сервера означав би другий розклад, другу чергу й другу історію
|
||||
// помилок; різниця між «зайти по SSH» і «прочитати власний диск» цього
|
||||
// не варта.
|
||||
func (s *Session) runLocalFilesJob(ctx context.Context, job *npv1.ConfigJob) {
|
||||
log := s.log.With("job_id", job.GetJobId(),
|
||||
"device", job.GetDevice().GetName(), "config_type", job.GetConfigType())
|
||||
|
||||
mod := s.cfg.FileCfg
|
||||
if mod == nil {
|
||||
// Зонд без модуля не мовчить: сервер запропонував збір, бо
|
||||
// вважав його можливим, і має дізнатись, що помилився.
|
||||
s.uploadFailure(ctx, job,
|
||||
errors.New("цей зонд зібраний без модуля filecfg"), "")
|
||||
return
|
||||
}
|
||||
|
||||
log.Info("збір конфіг-файлів машини почався", "політика", mod.PolicyPath())
|
||||
|
||||
res, err := mod.Collect(ctx, localfiles.Limits{
|
||||
MaxFileBytes: int64(job.GetLocalFiles().GetMaxFileBytes()),
|
||||
MaxFiles: int(job.GetLocalFiles().GetMaxFiles()),
|
||||
MaxTotalBytes: int64(job.GetLocalFiles().GetMaxTotalBytes()),
|
||||
})
|
||||
if err != nil {
|
||||
// Відсутня політика — не поломка, а стан: цю машину ніхто не
|
||||
// дозволяв читати. Тому в журнал попередженням, а не помилкою,
|
||||
// але на сервер — усе одно відмовою з поясненням: інакше
|
||||
// завдання висітиме «в роботі» до прибиральника.
|
||||
if errors.Is(err, localfiles.ErrNoPolicy) {
|
||||
log.Warn("збір конфіг-файлів не дозволено на цій машині", "причина", err)
|
||||
} else {
|
||||
log.Error("збір конфіг-файлів", "помилка", err)
|
||||
}
|
||||
s.uploadFailure(ctx, job, err, res.Report)
|
||||
return
|
||||
}
|
||||
|
||||
log.Info("конфіг-файли зібрано",
|
||||
"файлів", len(res.Files), "пропущено", len(res.Skips),
|
||||
"байтів", len(res.Body),
|
||||
"тривалість", res.Duration.Round(time.Millisecond))
|
||||
|
||||
if err := s.uploadConfigAs(ctx, job, ncmx.Result{
|
||||
Body: res.Body,
|
||||
SHA256: res.SHA256,
|
||||
LineCount: res.LineCount,
|
||||
Duration: res.Duration,
|
||||
// Протокол збору їде транскриптом: він потрібен рівно тоді,
|
||||
// коли зібралось не те, що очікували, — а перелік пропусків із
|
||||
// причинами і є відповідь на «чому».
|
||||
Transcript: res.Report,
|
||||
}, localfiles.Machine()); err != nil {
|
||||
log.Error("вивантаження конфіг-файлів", "помилка", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -28,9 +28,18 @@ const logFlush = 500 * time.Millisecond
|
|||
// Окремий стрім, а не контрольний канал: сплеск логів під час аварії не
|
||||
// має заважати heartbeat і командам. Саме тому в контракті StreamLogs
|
||||
// існує окремо від Control.
|
||||
// Syslog і трапи їдуть ОДНИМ стрімом, а не двома.
|
||||
//
|
||||
// Спокуса завести другий є: приймачі різні, порти різні, розбір різний.
|
||||
// Але за межами зонда це та сама подія з мережі — вона лягає в сусідні
|
||||
// таблиці, звіряється з тими самими подієвими правилами й приїжджає в
|
||||
// той самий момент аварії. Другий стрім означав би другий комплект
|
||||
// підтверджень, лімітів і черг переповнення — і два різні місця, у
|
||||
// яких по-різному вирішено, що робити з пачкою, яку не вдалося
|
||||
// відправити.
|
||||
func (s *Session) logsLoop(ctx context.Context, client npv1.AgentServiceClient) error {
|
||||
rec := s.cfg.Syslog
|
||||
if rec == nil {
|
||||
sys, trp := s.cfg.Syslog, s.cfg.Traps
|
||||
if sys == nil && trp == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -55,11 +64,29 @@ func (s *Session) logsLoop(ctx context.Context, client npv1.AgentServiceClient)
|
|||
continue
|
||||
}
|
||||
// Ліміти задає сервер: він бачить картину по всіх зондах і
|
||||
// краще знає, що вважати шумом.
|
||||
rec.ApplyAck(ack.GetMinSeverity(), ack.GetRateLimitPerSource())
|
||||
// краще знає, що вважати шумом. Стеля на джерело спільна
|
||||
// для обох приймачів — шумить не протокол, а пристрій.
|
||||
if sys != nil {
|
||||
sys.ApplyAck(ack.GetMinSeverity(), ack.GetRateLimitPerSource())
|
||||
}
|
||||
if trp != nil {
|
||||
trp.ApplyAck(ack.GetRateLimitPerSource())
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Один із приймачів може бути вимкнений, тому канали готовності
|
||||
// беремо через nil-заглушку: читання з nil-каналу блокується
|
||||
// назавжди, і саме це в select потрібно — гілка, яка ніколи не
|
||||
// спрацює, замість гілки, якої немає.
|
||||
var sysReady, trapReady <-chan struct{}
|
||||
if sys != nil {
|
||||
sysReady = sys.Ready()
|
||||
}
|
||||
if trp != nil {
|
||||
trapReady = trp.Ready()
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(logFlush)
|
||||
defer ticker.Stop()
|
||||
|
||||
|
|
@ -69,14 +96,33 @@ func (s *Session) logsLoop(ctx context.Context, client npv1.AgentServiceClient)
|
|||
return nil
|
||||
case err := <-ackErr:
|
||||
return err
|
||||
case <-rec.Ready():
|
||||
case <-sysReady:
|
||||
// Подія з'явилась — але не летимо одразу: даємо тіку
|
||||
// зібрати сусідів у ту саму пачку.
|
||||
case <-trapReady:
|
||||
case <-ticker.C:
|
||||
}
|
||||
|
||||
entries, dropped := rec.Drain(maxLogBatch)
|
||||
if len(entries) == 0 && dropped == 0 {
|
||||
var (
|
||||
entries []*npv1.SyslogEntry
|
||||
traps []*npv1.SnmpTrap
|
||||
dropped uint64
|
||||
)
|
||||
if sys != nil {
|
||||
var d uint64
|
||||
entries, d = sys.Drain(maxLogBatch)
|
||||
dropped += d
|
||||
}
|
||||
if trp != nil {
|
||||
// Половина пачки на трапи — не арифметика, а пріоритет:
|
||||
// трапів за секунду на порядок менше, ніж рядків журналу,
|
||||
// і стеля тут потрібна лише на випадок шторму. Витіснити
|
||||
// журнал вони не мають.
|
||||
var d uint64
|
||||
traps, d = trp.Drain(maxLogBatch / 2)
|
||||
dropped += d
|
||||
}
|
||||
if len(entries) == 0 && len(traps) == 0 && dropped == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
@ -84,13 +130,19 @@ func (s *Session) logsLoop(ctx context.Context, client npv1.AgentServiceClient)
|
|||
BatchId: s.nextBatch.Add(1),
|
||||
AgentId: s.cfg.AgentID,
|
||||
Syslog: entries,
|
||||
Traps: traps,
|
||||
Dropped: dropped,
|
||||
}
|
||||
if err := stream.Send(batch); err != nil {
|
||||
// Невідправлене повертаємо в чергу: наступна сесія
|
||||
// доставить. Порядок зберігається — журнал читають
|
||||
// хронологічно.
|
||||
rec.Requeue(entries)
|
||||
if sys != nil {
|
||||
sys.Requeue(entries)
|
||||
}
|
||||
if trp != nil {
|
||||
trp.Requeue(traps)
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,9 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/netpulse/netpulse/agent/internal/module"
|
||||
"github.com/netpulse/netpulse/agent/internal/modules/filecfg"
|
||||
"github.com/netpulse/netpulse/agent/internal/modules/syslog"
|
||||
"github.com/netpulse/netpulse/agent/internal/modules/traps"
|
||||
"github.com/netpulse/netpulse/agent/internal/scheduler"
|
||||
"github.com/netpulse/netpulse/agent/internal/telemetry"
|
||||
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
|
||||
|
|
@ -58,6 +60,16 @@ type Config struct {
|
|||
// сервера не відкривається взагалі: тримати порожній канал заради
|
||||
// вимкненої можливості немає сенсу.
|
||||
Syslog *syslog.Receiver
|
||||
|
||||
// Приймач SNMP-трапів. Порожній — зонд трапи не приймає. Окремо від
|
||||
// Syslog, бо це окремий порт і окремий дозвіл у фаєрволі клієнта:
|
||||
// вмикати обидва там, де просили лише один, означало б відкрити
|
||||
// порт, про який ніхто не домовлявся.
|
||||
Traps *traps.Receiver
|
||||
|
||||
// Збір конфіг-файлів власної машини. Порожній — зонд такі завдання
|
||||
// відхиляє з поясненням.
|
||||
FileCfg *filecfg.Module
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
|
|
@ -124,6 +136,9 @@ func New(cfg Config) *Session {
|
|||
if cfg.Syslog != nil {
|
||||
cfg.Syslog.SetResolver(s.resolveDeviceByIP)
|
||||
}
|
||||
if cfg.Traps != nil {
|
||||
cfg.Traps.SetResolver(s.resolveDeviceByIP)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
|
|
@ -304,7 +319,7 @@ func (s *Session) runOnce(ctx context.Context) error {
|
|||
spawn("status", func() error { return s.statusLoop(sctx, out) })
|
||||
spawn("telemetry", func() error { return s.telemetryLoop(sctx, client, welcome) })
|
||||
spawn("discovery", func() error { return s.discoveryLoop(sctx, client) })
|
||||
if s.cfg.Syslog != nil {
|
||||
if s.cfg.Syslog != nil || s.cfg.Traps != nil {
|
||||
spawn("logs", func() error { return s.logsLoop(sctx, client) })
|
||||
}
|
||||
|
||||
|
|
@ -484,7 +499,11 @@ func (s *Session) controlLoop(ctx context.Context, ctrl npv1.AgentService_Contro
|
|||
if s.cfg.Scheduler == nil {
|
||||
continue
|
||||
}
|
||||
n := s.cfg.Scheduler.TriggerNow(p.DiscoveryRequest.GetDeviceIds(), "topo.")
|
||||
// Префікс — ключ модуля з крапкою, а не «topo.»: рядок
|
||||
// «topology.identify» на «topo.» не починається, і поштовх
|
||||
// не зрушував нічого. Помилку не помічали, бо до появи
|
||||
// кнопки «Розпізнати зараз» DiscoveryRequest не слав ніхто.
|
||||
n := s.cfg.Scheduler.TriggerNow(p.DiscoveryRequest.GetDeviceIds(), "topology.")
|
||||
s.log.Info("сервер попросив запустити автовиявлення",
|
||||
"run_id", p.DiscoveryRequest.GetRunId(), "задач_зрушено", n)
|
||||
|
||||
|
|
@ -496,6 +515,19 @@ func (s *Session) controlLoop(ctx context.Context, ctrl npv1.AgentService_Contro
|
|||
s.runConfigJob(ctx, job)
|
||||
}()
|
||||
|
||||
case *npv1.ControlDown_ConfigApplyJob:
|
||||
// Заливка конфігу на пристрій. Окрема гілка, а не ще одне
|
||||
// значення config_type у ConfigJob, як зроблено для
|
||||
// масових команд: там різниця була лише в тому, що робити
|
||||
// з виводом, а тут інша сама природа завдання — ми пишемо
|
||||
// на залізо, і звіт їде назад іншим повідомленням.
|
||||
apply := p.ConfigApplyJob
|
||||
s.jobs.Add(1)
|
||||
go func() {
|
||||
defer s.jobs.Done()
|
||||
s.runApplyJob(ctx, out, apply)
|
||||
}()
|
||||
|
||||
case *npv1.ControlDown_Directive:
|
||||
if stop := s.applyDirective(p.Directive); stop {
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -1,17 +1,35 @@
|
|||
# Каталог команд збору конфігу
|
||||
# Каталог команд роботи з конфігом
|
||||
|
||||
`catalog.json` — джерело істини про те, як зняти конфіг із кожної
|
||||
підтримуваної платформи. 148 платформ, 67 вендорів.
|
||||
підтримуваної платформи **і як залити його назад**. 148 платформ,
|
||||
67 вендорів.
|
||||
|
||||
Міграція `server/migrations/0014_ncm_profiles.sql` **породжується** з
|
||||
каталогу, а не правиться руками: два описи одного й того самого
|
||||
розійшлися б із першою ж правкою, і невідомо було б, який справжній.
|
||||
SQL із каталогу **породжується**, а не правиться руками: два описи
|
||||
одного й того самого розійшлися б із першою ж правкою, і невідомо було
|
||||
б, який справжній.
|
||||
|
||||
```bash
|
||||
python build.py # перезібрати міграцію
|
||||
python build.py --check # звірити, чи вона актуальна
|
||||
python build.py # перезібрати згенероване
|
||||
python build.py --check # звірити, чи воно актуальне
|
||||
```
|
||||
|
||||
Генератор пише два файли, і це не примха:
|
||||
|
||||
| файл | що несе |
|
||||
| --- | --- |
|
||||
| `server/migrations/0014_ncm_profiles.sql` | самі профілі: команди **збору** |
|
||||
| `apply.generated.sql` | поля **заливки** (`apply_*`) для наявних профілів |
|
||||
|
||||
`apply.generated.sql` — **не міграція**. Колонки `apply_*` заводить
|
||||
міграція 0060, тобто пізніше за 0014: вставка в 0014 не може згадати
|
||||
колонку, якої на той момент ще немає. До того ж 0014 уже накочена на
|
||||
стендах, а `netpulse-migrate` звіряє sha256 застосованих файлів і
|
||||
зупиняє накат на розбіжності — зміна тексту 0014 зупинила б оновлення
|
||||
всім. Тому поля заливки їдуть окремо: скопіюй `apply.generated.sql` у
|
||||
нову міграцію під номером, який призначила людина. Номер генератор не
|
||||
вигадує свідомо — над одним каталогом номерів працюють паралельні
|
||||
гілки.
|
||||
|
||||
## Будова
|
||||
|
||||
```jsonc
|
||||
|
|
@ -36,9 +54,22 @@ python build.py --check # звірити, чи вона актуальна
|
|||
"startup": "show startup-config", // необовʼязково
|
||||
"prepare": ["undo terminal monitor"], // необовʼязково: підготовка консолі
|
||||
"strip_lines": 3, // скільки службових рядків відкинути
|
||||
"alternatives": ["show config current_config"] // для інших моделей родини
|
||||
"alternatives": ["show config current_config"], // для інших моделей родини
|
||||
"apply": { // як ЗАЛИВАТИ конфіг; без блоку — заливка вимкнена
|
||||
"comment": ["чому саме так"], // їде коментарем над UPDATE
|
||||
"enter": ["configure terminal"], // чим увійти в режим конфігурації
|
||||
"exit": ["end"], // чим із нього вийти
|
||||
"commit": "write memory", // чим зберегти, щоб пережило перезавантаження
|
||||
"negate": "no ", // префікс заперечення рядка; null — родина не вміє
|
||||
"block_exit": "exit", // чим вийти з вкладеного контексту
|
||||
"supported": true, // чи є заливка взагалі
|
||||
"note": "…" // що сказати людині, коли заливки немає
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"apply_external": { // заливка для профілів, заведених
|
||||
"zte-zxan": { "…": "…" } // не каталогом, а руками (0028, 0034/0043)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -47,9 +78,37 @@ python build.py --check # звірити, чи вона актуальна
|
|||
1. Додай запис у `profiles`. Мінімум — `key`, `name`, `vendor`,
|
||||
`family`, `config`.
|
||||
2. Якщо CLI не схожий на жодну наявну родину — заведи нову у `families`.
|
||||
3. `python build.py`.
|
||||
4. Накоти міграцію (`ON CONFLICT DO NOTHING`, тож повторний накат
|
||||
безпечний).
|
||||
3. Якщо заливка на цій платформі перевірена — додай блок `apply`.
|
||||
Не перевірена — не додавай: див. «Про заливку».
|
||||
4. `python build.py`.
|
||||
5. Перенеси нові рядки у **нову** міграцію. 0014 чіпати не можна: вона
|
||||
вже накочена, а runner звіряє sha256 і зупиниться на зміненому
|
||||
файлі. Так уже робили для zte-zxan (0028) і dlink-me (0034, 0043).
|
||||
`INSERT` іде з `ON CONFLICT DO NOTHING`, `UPDATE` задає всі поля
|
||||
явно — повторний накат безпечний в обох випадках.
|
||||
|
||||
## Про заливку
|
||||
|
||||
Блок `apply` описує, як віддати конфіг пристрою назад: увійти в режим
|
||||
конфігурації, прибрати зайвий рядок, зберегти. Живе він у каталозі
|
||||
поруч із командами збору, а не в разовій міграції, з однієї причини:
|
||||
профіль, доданий без полів заливки, виглядав би робочим, а відкат для
|
||||
нього мовчки не спрацював би — і побачили б це рівно тоді, коли відкат
|
||||
знадобився.
|
||||
|
||||
Заливка задається **профілем, а не родиною**. Родина описує поведінку
|
||||
консолі (пейджер, запрошення), і збігається вона не з синтаксисом
|
||||
конфігурації: `zte-zxr10` стоїть у родині `huawei` через запрошення, а
|
||||
конфігурується як Cisco. Успадкувати заливку від родини означало б
|
||||
відправити на пристрій чужі команди.
|
||||
|
||||
**Профіль без блоку `apply` лишається без заливки, і це нормально.**
|
||||
`apply_supported = false` — чесна відповідь «не вміємо», з якої
|
||||
інтерфейс робить вимкнену кнопку з поясненням. Вигадана команда гірша
|
||||
за відсутню: вона пише на живе залізо. Тому коли заливки немає з
|
||||
розібраної причини — заповнюй `note`: порожнє поле перетворює свідоме
|
||||
рішення на схоже на недогляд (див. `mikrotik-routeros`,
|
||||
`juniper-junos`).
|
||||
|
||||
## Родини
|
||||
|
||||
|
|
|
|||
132
db/profiles/apply.generated.sql
Normal file
132
db/profiles/apply.generated.sql
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
-- =====================================================================
|
||||
-- NetPulse :: поля заливки конфігу для вбудованих профілів
|
||||
--
|
||||
-- ФАЙЛ ЗГЕНЕРОВАНО. Джерело — db/profiles/catalog.json (блоки
|
||||
-- `apply` у профілях і секція `apply_external`). Правити тут
|
||||
-- безглуздо: наступна збірка перезапише.
|
||||
--
|
||||
-- ЦЕ НЕ МІГРАЦІЯ і сама вона нікуди не накочується. Номер міграції
|
||||
-- призначає людина, а не генератор: над одним каталогом номерів
|
||||
-- працюють паралельні гілки, і вигаданий номер зіткнувся б із чужим.
|
||||
-- Щоб зміни доїхали до бази, скопіюй цей текст у нову міграцію.
|
||||
--
|
||||
-- Чому не в 0014, звідки беруться самі профілі: колонки apply_*
|
||||
-- заводить 0060, тобто ПІЗНІШЕ, — вставка в 0014 не може згадати
|
||||
-- колонку, якої ще немає. І 0014 уже накочена на стендах, а runner
|
||||
-- звіряє sha256 застосованих файлів: зміна її тексту зупинила б
|
||||
-- накат усім.
|
||||
--
|
||||
-- Повторний накат безпечний: UPDATE задає всі поля явно, тож
|
||||
-- результат залежить лише від каталогу, а не від того, що вже
|
||||
-- стояло в рядку.
|
||||
-- =====================================================================
|
||||
|
||||
-- Cisco IOS. Класика, з якої списані всі інші: `configure terminal`,
|
||||
-- заперечення через `no `, вихід із контексту `exit`, збереження
|
||||
-- `write memory` (а не `copy run start`, який на частині версій
|
||||
-- перепитує ім'я файлу й підвисає на очікуванні Enter).
|
||||
UPDATE ncm.profiles SET
|
||||
apply_enter = '["configure terminal"]'::jsonb,
|
||||
apply_exit = '["end"]'::jsonb,
|
||||
apply_commit = 'write memory',
|
||||
apply_negate = 'no ',
|
||||
apply_block_exit = 'exit',
|
||||
apply_supported = true,
|
||||
apply_note = NULL
|
||||
WHERE tenant_id IS NULL AND key = 'cisco-ios';
|
||||
|
||||
-- Juniper JUNOS — теж свідомо без відкату, і теж через формат архіву.
|
||||
--
|
||||
-- Профіль знімає `show configuration | display omit`, тобто ієрархію у
|
||||
-- фігурних дужках. Це не набір команд: віддати такий текст рядками в
|
||||
-- CLI неможливо. Правильний шлях на JUNOS — `load override terminal` із
|
||||
-- вставкою всього файлу, а він не вкладається в модель «команда →
|
||||
-- запрошення → наступна команда», на якій побудований увесь наш CLI.
|
||||
--
|
||||
-- Альтернатива існує: профіль, що знімає `show configuration |
|
||||
-- display set`, дав би рядки `set …` із заперечником `delete `. Це
|
||||
-- окрема робота — інший профіль збору й переливання архіву, — і робити
|
||||
-- її мовчки, підмінивши формат історії, не можна.
|
||||
UPDATE ncm.profiles SET
|
||||
apply_enter = '[]'::jsonb,
|
||||
apply_exit = '[]'::jsonb,
|
||||
apply_commit = NULL,
|
||||
apply_negate = NULL,
|
||||
apply_block_exit = 'exit',
|
||||
apply_supported = false,
|
||||
apply_note = 'JUNOS зберігається ієрархією у фігурних дужках — це не набір команд. Для відкату потрібен профіль зі збором «show configuration | display set» (рядки set …, заперечення delete …); наявний архів у такому вигляді немає.'
|
||||
WHERE tenant_id IS NULL AND key = 'juniper-junos';
|
||||
|
||||
-- MikroTik RouterOS — свідомо БЕЗ відкату, і причина не в бракові часу.
|
||||
--
|
||||
-- Вивід `export` виглядає як набір команд, але команди в ньому —
|
||||
-- `add …`. Повторне виконання `add` не повертає рядок на місце, а
|
||||
-- створює ДРУГИЙ такий самий запис: другу адресу на інтерфейсі, друге
|
||||
-- правило фаєрвола. Прибирання ж робиться через `remove [find …]` —
|
||||
-- тобто через пошук за критерієм, якого в рядку експорту немає.
|
||||
--
|
||||
-- Тобто механічний відкат на RouterOS не «поки не зроблений», а дає
|
||||
-- гарантовано хибний результат. Правильний шлях — `/system backup` або
|
||||
-- `/import` файлом, і це інша функція з іншим транспортом.
|
||||
UPDATE ncm.profiles SET
|
||||
apply_enter = '[]'::jsonb,
|
||||
apply_exit = '[]'::jsonb,
|
||||
apply_commit = NULL,
|
||||
apply_negate = NULL,
|
||||
apply_block_exit = 'exit',
|
||||
apply_supported = false,
|
||||
apply_note = 'RouterOS: рядки експорту — це add, і повторне виконання не повертає запис, а створює дубль; прибирання потребує remove [find …]. Автоматичний відкат тут дав би гарантовано хибний результат — потрібне відновлення з /system backup або /import файлом.'
|
||||
WHERE tenant_id IS NULL AND key = 'mikrotik-routeros';
|
||||
|
||||
-- ZTE ZXR10 — CLI родини Cisco з тим самим `configure terminal`/`no `.
|
||||
-- Збереження коротше: `write`.
|
||||
--
|
||||
-- Родина в каталозі стоїть huawei — але вона описує поведінку
|
||||
-- консолі (пейджер, запрошення), а не синтаксис конфігурації.
|
||||
-- Саме тому заливка задана профілем, а не родиною: успадкувати
|
||||
-- її від huawei означало б відправити на пристрій чужі команди.
|
||||
UPDATE ncm.profiles SET
|
||||
apply_enter = '["configure terminal"]'::jsonb,
|
||||
apply_exit = '["end"]'::jsonb,
|
||||
apply_commit = 'write',
|
||||
apply_negate = 'no ',
|
||||
apply_block_exit = 'exit',
|
||||
apply_supported = true,
|
||||
apply_note = NULL
|
||||
WHERE tenant_id IS NULL AND key = 'zte-zxr10';
|
||||
|
||||
-- ZTE ZXAN (OLT C300/C320/C600) — той самий CLI, що й ZXR10. Профіль
|
||||
-- заведено окремо в 0028 через запрошення, а не через синтаксис.
|
||||
UPDATE ncm.profiles SET
|
||||
apply_enter = '["configure terminal"]'::jsonb,
|
||||
apply_exit = '["end"]'::jsonb,
|
||||
apply_commit = 'write',
|
||||
apply_negate = 'no ',
|
||||
apply_block_exit = 'exit',
|
||||
apply_supported = true,
|
||||
apply_note = NULL
|
||||
WHERE tenant_id IS NULL AND key = 'zte-zxan';
|
||||
|
||||
-- D-Link DES/DGS (профіль dlink-me, спільний для xStack і Smart /ME —
|
||||
-- див. 0043). Тут два свідомі відступи від класики.
|
||||
--
|
||||
-- Режиму конфігурації немає: команди виконуються просто із запрошення,
|
||||
-- тому apply_enter і apply_exit порожні, а вкладених контекстів не
|
||||
-- буває — кожен рядок конфігу самодостатній (`create vlan v10 tag 10`).
|
||||
--
|
||||
-- Заперечення немає ЗОВСІМ, і це не пропуск. У D-Link немає універсального
|
||||
-- `no`: створене прибирається `delete`, налаштоване переписується
|
||||
-- `config`, увімкнене вимикається `disable`. Вивести з рядка конфігу
|
||||
-- потрібне дієслово автоматично неможливо — `create vlan v10 tag 10`
|
||||
-- прибирається як `delete vlan v10`, і жодне механічне правило цього не
|
||||
-- дасть. Тому рядки на прибирання йдуть людині переліком, а система
|
||||
-- заливає лише те, що додає й переписує.
|
||||
UPDATE ncm.profiles SET
|
||||
apply_enter = '[]'::jsonb,
|
||||
apply_exit = '[]'::jsonb,
|
||||
apply_commit = 'save',
|
||||
apply_negate = NULL,
|
||||
apply_block_exit = '',
|
||||
apply_supported = true,
|
||||
apply_note = 'D-Link не має універсального заперечення рядка: створене прибирається delete, налаштоване переписується config. Тому зайві рядки система показує переліком «прибрати вручну», а заливає лише додане й змінене.'
|
||||
WHERE tenant_id IS NULL AND key = 'dlink-me';
|
||||
|
|
@ -7,10 +7,26 @@
|
|||
самого розійшлися б із першою ж правкою, і невідомо було б, який із них
|
||||
справжній.
|
||||
|
||||
Каталог описує ДВІ речі про кожну платформу, і виходять вони в різні
|
||||
файли:
|
||||
|
||||
* як конфіг ЗНЯТИ (`config`, `prepare`, родина) — у міграцію
|
||||
0014_ncm_profiles.sql, тобто в самі рядки ncm.profiles;
|
||||
* як конфіг ЗАЛИТИ (блок `apply`) — у apply.generated.sql, окремим
|
||||
набором UPDATE.
|
||||
|
||||
Розділені вони не з естетики. Колонки ncm.profiles.apply_* заводить
|
||||
міграція 0060, яка йде ПІСЛЯ 0014: вставка в 0014 не може згадати
|
||||
колонку, якої на той момент ще немає. До того ж 0014 уже накочена на
|
||||
стендах, а runner міграцій звіряє sha256 застосованих файлів і зупиняє
|
||||
накат на розбіжності — будь-яка зміна тексту 0014 зупинила б оновлення
|
||||
всім. Тому 0014 лишається байт-у-байт таким, як є, а поля заливки
|
||||
їдуть окремим файлом.
|
||||
|
||||
Використання:
|
||||
|
||||
python build.py # перезбирає ../migrations/0014_ncm_profiles.sql
|
||||
python build.py --check # лише звіряє, чи міграція актуальна
|
||||
python build.py # перезбирає обидва файли
|
||||
python build.py --check # лише звіряє, чи вони актуальні
|
||||
"""
|
||||
import json
|
||||
import pathlib
|
||||
|
|
@ -19,13 +35,48 @@ import sys
|
|||
|
||||
HERE = pathlib.Path(__file__).parent
|
||||
CATALOG = HERE / "catalog.json"
|
||||
MIGRATION = HERE.parent / "migrations" / "0014_ncm_profiles.sql"
|
||||
# Міграції переїхали в server/migrations: netpulse-migrate вшиває їх
|
||||
# через //go:embed, а embed не бачить нічого за межами свого модуля
|
||||
# (див. db/README-migrations.md). Шлях на db/migrations, що лишався тут
|
||||
# після переїзду, вів у неіснуючий каталог: --check завжди звітував
|
||||
# «розійшлася», а звичайний запуск падав на записі.
|
||||
MIGRATION = HERE.parent.parent / "server" / "migrations" / "0014_ncm_profiles.sql"
|
||||
# Не міграція, а заготовка для неї: номер міграції призначає людина,
|
||||
# генератор не має права його вигадувати — паралельні гілки розберуть
|
||||
# ті самі номери й зіткнуться.
|
||||
APPLY_SQL = HERE / "apply.generated.sql"
|
||||
|
||||
# Значення за замовчуванням для полів заливки.
|
||||
#
|
||||
# Це не вибір генератора, а DEFAULT відповідних колонок із 0060: профіль
|
||||
# без блоку `apply` має лишитись рівно таким, яким його зробив ALTER
|
||||
# TABLE. Розбіжність тут означала б, що перезапуск генератора мовчки
|
||||
# міняє поведінку вже налаштованих профілів.
|
||||
APPLY_DEFAULTS = {
|
||||
"enter": [],
|
||||
"exit": [],
|
||||
"commit": None,
|
||||
"negate": None,
|
||||
"block_exit": "exit",
|
||||
"supported": False,
|
||||
"note": None,
|
||||
}
|
||||
|
||||
|
||||
def sql_str(v: str) -> str:
|
||||
return "'" + v.replace("'", "''") + "'"
|
||||
|
||||
|
||||
def sql_opt(v) -> str:
|
||||
"""NULL, а не порожній рядок: для apply_negate це різні речі.
|
||||
|
||||
Порожній рядок означав би «заперечувати рядок префіксом ''», тобто
|
||||
відправити на пристрій команду без заперечника — рівно навпаки до
|
||||
задуманого. NULL чесно означає «родина не вміє прибрати рядок».
|
||||
"""
|
||||
return "NULL" if v is None else sql_str(v)
|
||||
|
||||
|
||||
def sql_json(obj) -> str:
|
||||
return sql_str(json.dumps(obj, ensure_ascii=False))
|
||||
|
||||
|
|
@ -100,21 +151,118 @@ def render(catalog: dict) -> str:
|
|||
return "\n".join(lines)
|
||||
|
||||
|
||||
def apply_entries(catalog: dict) -> list:
|
||||
"""Пари (ключ профілю, блок заливки) в порядку каталогу.
|
||||
|
||||
Два джерела, бо профілі теж із двох. Ті, що описані каталогом,
|
||||
несуть `apply` поруч із командою збору — інакше нова платформа
|
||||
з'явилась би без полів заливки, і відкат для неї мовчки не працював
|
||||
би. Ті, що заведені окремими міграціями (zte-zxan — 0028, dlink-me —
|
||||
0034/0043), у каталозі профілю не мають, але їхня заливка описана
|
||||
тут же, у `apply_external`: тримати частину відповідей у каталозі, а
|
||||
частину — в разовій міграції означало б знову два джерела істини.
|
||||
"""
|
||||
out = []
|
||||
for p in catalog["profiles"]:
|
||||
if "apply" in p:
|
||||
out.append((p["key"], p["apply"]))
|
||||
for key, ap in catalog.get("apply_external", {}).items():
|
||||
out.append((key, ap))
|
||||
return out
|
||||
|
||||
|
||||
def render_apply(catalog: dict) -> str:
|
||||
"""UPDATE-и, що переносять блоки `apply` каталогу в ncm.profiles."""
|
||||
lines = [
|
||||
"-- =====================================================================",
|
||||
"-- NetPulse :: поля заливки конфігу для вбудованих профілів",
|
||||
"--",
|
||||
"-- ФАЙЛ ЗГЕНЕРОВАНО. Джерело — db/profiles/catalog.json (блоки",
|
||||
"-- `apply` у профілях і секція `apply_external`). Правити тут",
|
||||
"-- безглуздо: наступна збірка перезапише.",
|
||||
"--",
|
||||
"-- ЦЕ НЕ МІГРАЦІЯ і сама вона нікуди не накочується. Номер міграції",
|
||||
"-- призначає людина, а не генератор: над одним каталогом номерів",
|
||||
"-- працюють паралельні гілки, і вигаданий номер зіткнувся б із чужим.",
|
||||
"-- Щоб зміни доїхали до бази, скопіюй цей текст у нову міграцію.",
|
||||
"--",
|
||||
"-- Чому не в 0014, звідки беруться самі профілі: колонки apply_*",
|
||||
"-- заводить 0060, тобто ПІЗНІШЕ, — вставка в 0014 не може згадати",
|
||||
"-- колонку, якої ще немає. І 0014 уже накочена на стендах, а runner",
|
||||
"-- звіряє sha256 застосованих файлів: зміна її тексту зупинила б",
|
||||
"-- накат усім.",
|
||||
"--",
|
||||
"-- Повторний накат безпечний: UPDATE задає всі поля явно, тож",
|
||||
"-- результат залежить лише від каталогу, а не від того, що вже",
|
||||
"-- стояло в рядку.",
|
||||
"-- =====================================================================",
|
||||
"",
|
||||
]
|
||||
|
||||
for key, ap in apply_entries(catalog):
|
||||
# Друкарська помилка в назві поля інакше пройшла б мовчки: `.get`
|
||||
# підставив би замовчування, і профіль поїхав би на пристрій із
|
||||
# чужими командами замість описаних.
|
||||
unknown = set(ap) - set(APPLY_DEFAULTS) - {"comment"}
|
||||
if unknown:
|
||||
raise SystemExit(f"{key}: невідомі поля заливки: {sorted(unknown)}")
|
||||
|
||||
def field(name):
|
||||
return ap.get(name, APPLY_DEFAULTS[name])
|
||||
|
||||
# Причина рішення їде поруч із рішенням. Порожнє місце над
|
||||
# `apply_supported = false` читалось би як недогляд, хоча за ним
|
||||
# стоїть розбір родини.
|
||||
comment = ap.get("comment") or []
|
||||
for line in [comment] if isinstance(comment, str) else comment:
|
||||
lines.append(("-- " + line).rstrip())
|
||||
lines += [
|
||||
"UPDATE ncm.profiles SET",
|
||||
" apply_enter = {}::jsonb,".format(sql_json(field("enter"))),
|
||||
" apply_exit = {}::jsonb,".format(sql_json(field("exit"))),
|
||||
" apply_commit = {},".format(sql_opt(field("commit"))),
|
||||
" apply_negate = {},".format(sql_opt(field("negate"))),
|
||||
" apply_block_exit = {},".format(sql_str(field("block_exit"))),
|
||||
" apply_supported = {},".format(
|
||||
"true" if field("supported") else "false"),
|
||||
" apply_note = {}".format(sql_opt(field("note"))),
|
||||
"WHERE tenant_id IS NULL AND key = {};".format(sql_str(key)),
|
||||
"",
|
||||
]
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main():
|
||||
catalog = json.loads(CATALOG.read_text(encoding="utf-8"))
|
||||
sql = render(catalog)
|
||||
outputs = [(MIGRATION, render(catalog)), (APPLY_SQL, render_apply(catalog))]
|
||||
|
||||
if "--check" in sys.argv:
|
||||
current = MIGRATION.read_text(encoding="utf-8") if MIGRATION.exists() else ""
|
||||
if current != sql:
|
||||
print("міграція розійшлася з каталогом — виконай build.py", file=sys.stderr)
|
||||
stale = []
|
||||
for path, sql in outputs:
|
||||
current = path.read_text(encoding="utf-8") if path.exists() else ""
|
||||
if current != sql:
|
||||
stale.append(path.name)
|
||||
if stale:
|
||||
print("розійшлося з каталогом: " + ", ".join(stale) +
|
||||
" — виконай build.py", file=sys.stderr)
|
||||
return 1
|
||||
print("міграція актуальна")
|
||||
print("згенероване актуальне")
|
||||
return 0
|
||||
|
||||
MIGRATION.write_text(sql, encoding="utf-8")
|
||||
for path, sql in outputs:
|
||||
# Пишемо байтами, а не текстом: у текстовому режимі Python на
|
||||
# Windows сам перекладає \n у \r\n. Для згенерованої 0014 це не
|
||||
# косметика — runner міграцій звіряє sha256 файлу, вшитого в
|
||||
# бінарник, а CRLF-версія дає ІНШУ суму, ніж LF-версія з
|
||||
# репозиторію (.gitattributes: «у репозиторії — завжди LF»).
|
||||
# Збірка на Windows після запуску генератора інакше зупиняла б
|
||||
# накат на стенді з «міграцію 0014 змінено після застосування».
|
||||
path.write_bytes(sql.encode("utf-8"))
|
||||
vendors = len({p["vendor"] for p in catalog["profiles"]})
|
||||
print(f"{MIGRATION.name}: {len(catalog['profiles'])} платформ, {vendors} вендорів")
|
||||
print(f"{APPLY_SQL.name}: профілів із описаною заливкою: "
|
||||
f"{len(apply_entries(catalog))}")
|
||||
return 0
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -151,6 +151,44 @@
|
|||
"vyatta": "juniper",
|
||||
"zte": "huawei"
|
||||
},
|
||||
"apply_external": {
|
||||
"zte-zxan": {
|
||||
"comment": [
|
||||
"ZTE ZXAN (OLT C300/C320/C600) — той самий CLI, що й ZXR10. Профіль",
|
||||
"заведено окремо в 0028 через запрошення, а не через синтаксис."
|
||||
],
|
||||
"enter": ["configure terminal"],
|
||||
"exit": ["end"],
|
||||
"commit": "write",
|
||||
"negate": "no ",
|
||||
"supported": true
|
||||
},
|
||||
"dlink-me": {
|
||||
"comment": [
|
||||
"D-Link DES/DGS (профіль dlink-me, спільний для xStack і Smart /ME —",
|
||||
"див. 0043). Тут два свідомі відступи від класики.",
|
||||
"",
|
||||
"Режиму конфігурації немає: команди виконуються просто із запрошення,",
|
||||
"тому apply_enter і apply_exit порожні, а вкладених контекстів не",
|
||||
"буває — кожен рядок конфігу самодостатній (`create vlan v10 tag 10`).",
|
||||
"",
|
||||
"Заперечення немає ЗОВСІМ, і це не пропуск. У D-Link немає універсального",
|
||||
"`no`: створене прибирається `delete`, налаштоване переписується",
|
||||
"`config`, увімкнене вимикається `disable`. Вивести з рядка конфігу",
|
||||
"потрібне дієслово автоматично неможливо — `create vlan v10 tag 10`",
|
||||
"прибирається як `delete vlan v10`, і жодне механічне правило цього не",
|
||||
"дасть. Тому рядки на прибирання йдуть людині переліком, а система",
|
||||
"заливає лише те, що додає й переписує."
|
||||
],
|
||||
"enter": [],
|
||||
"exit": [],
|
||||
"commit": "save",
|
||||
"negate": null,
|
||||
"block_exit": "",
|
||||
"supported": true,
|
||||
"note": "D-Link не має універсального заперечення рядка: створене прибирається delete, налаштоване переписується config. Тому зайві рядки система показує переліком «прибрати вручну», а заливає лише додане й змінене."
|
||||
}
|
||||
},
|
||||
"profiles": [
|
||||
{
|
||||
"key": "3com-4500",
|
||||
|
|
@ -437,7 +475,20 @@
|
|||
"family": "cisco",
|
||||
"config": "show running-config",
|
||||
"startup": "show startup-config",
|
||||
"strip_lines": 3
|
||||
"strip_lines": 3,
|
||||
"apply": {
|
||||
"comment": [
|
||||
"Cisco IOS. Класика, з якої списані всі інші: `configure terminal`,",
|
||||
"заперечення через `no `, вихід із контексту `exit`, збереження",
|
||||
"`write memory` (а не `copy run start`, який на частині версій",
|
||||
"перепитує ім'я файлу й підвисає на очікуванні Enter)."
|
||||
],
|
||||
"enter": ["configure terminal"],
|
||||
"exit": ["end"],
|
||||
"commit": "write memory",
|
||||
"negate": "no ",
|
||||
"supported": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "cisco-iosxr",
|
||||
|
|
@ -904,7 +955,25 @@
|
|||
"name": "Juniper JUNOS",
|
||||
"vendor": "juniper",
|
||||
"family": "juniper",
|
||||
"config": "show configuration | display omit"
|
||||
"config": "show configuration | display omit",
|
||||
"apply": {
|
||||
"comment": [
|
||||
"Juniper JUNOS — теж свідомо без відкату, і теж через формат архіву.",
|
||||
"",
|
||||
"Профіль знімає `show configuration | display omit`, тобто ієрархію у",
|
||||
"фігурних дужках. Це не набір команд: віддати такий текст рядками в",
|
||||
"CLI неможливо. Правильний шлях на JUNOS — `load override terminal` із",
|
||||
"вставкою всього файлу, а він не вкладається в модель «команда →",
|
||||
"запрошення → наступна команда», на якій побудований увесь наш CLI.",
|
||||
"",
|
||||
"Альтернатива існує: профіль, що знімає `show configuration |",
|
||||
"display set`, дав би рядки `set …` із заперечником `delete `. Це",
|
||||
"окрема робота — інший профіль збору й переливання архіву, — і робити",
|
||||
"її мовчки, підмінивши формат історії, не можна."
|
||||
],
|
||||
"supported": false,
|
||||
"note": "JUNOS зберігається ієрархією у фігурних дужках — це не набір команд. Для відкату потрібен профіль зі збором «show configuration | display set» (рядки set …, заперечення delete …); наявний архів у такому вигляді немає."
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "juniper-junose",
|
||||
|
|
@ -967,7 +1036,24 @@
|
|||
"name": "MikroTik RouterOS",
|
||||
"vendor": "mikrotik",
|
||||
"family": "mikrotik",
|
||||
"config": "export"
|
||||
"config": "export",
|
||||
"apply": {
|
||||
"comment": [
|
||||
"MikroTik RouterOS — свідомо БЕЗ відкату, і причина не в бракові часу.",
|
||||
"",
|
||||
"Вивід `export` виглядає як набір команд, але команди в ньому —",
|
||||
"`add …`. Повторне виконання `add` не повертає рядок на місце, а",
|
||||
"створює ДРУГИЙ такий самий запис: другу адресу на інтерфейсі, друге",
|
||||
"правило фаєрвола. Прибирання ж робиться через `remove [find …]` —",
|
||||
"тобто через пошук за критерієм, якого в рядку експорту немає.",
|
||||
"",
|
||||
"Тобто механічний відкат на RouterOS не «поки не зроблений», а дає",
|
||||
"гарантовано хибний результат. Правильний шлях — `/system backup` або",
|
||||
"`/import` файлом, і це інша функція з іншим транспортом."
|
||||
],
|
||||
"supported": false,
|
||||
"note": "RouterOS: рядки експорту — це add, і повторне виконання не повертає запис, а створює дубль; прибирання потребує remove [find …]. Автоматичний відкат тут дав би гарантовано хибний результат — потрібне відновлення з /system backup або /import файлом."
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "nag-snr",
|
||||
|
|
@ -1295,7 +1381,23 @@
|
|||
"vendor": "zte",
|
||||
"family": "huawei",
|
||||
"config": "show running-config",
|
||||
"strip_lines": 1
|
||||
"strip_lines": 1,
|
||||
"apply": {
|
||||
"comment": [
|
||||
"ZTE ZXR10 — CLI родини Cisco з тим самим `configure terminal`/`no `.",
|
||||
"Збереження коротше: `write`.",
|
||||
"",
|
||||
"Родина в каталозі стоїть huawei — але вона описує поведінку",
|
||||
"консолі (пейджер, запрошення), а не синтаксис конфігурації.",
|
||||
"Саме тому заливка задана профілем, а не родиною: успадкувати",
|
||||
"її від huawei означало б відправити на пристрій чужі команди."
|
||||
],
|
||||
"enter": ["configure terminal"],
|
||||
"exit": ["end"],
|
||||
"commit": "write",
|
||||
"negate": "no ",
|
||||
"supported": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "zyxel-dslam",
|
||||
|
|
|
|||
|
|
@ -8,10 +8,51 @@ NETPULSE_DOMAIN=netpulse.example.com
|
|||
# продовженням сертифіката. Порожньо — ACME без контакту.
|
||||
ACME_EMAIL=admin@example.com
|
||||
|
||||
# Пароль ролі netpulse у PostgreSQL.
|
||||
# openssl rand -base64 24
|
||||
# Пароль ролі netpulse у PostgreSQL — власника схеми. Ним ходять
|
||||
# міграції та утиліти netpulse-user і netpulse-secret.
|
||||
# openssl rand -hex 24
|
||||
POSTGRES_PASSWORD=
|
||||
|
||||
# --- ролі бази й Row Level Security -----------------------------------
|
||||
#
|
||||
# Два паролі нижче — це вимикач другого рубежу ізоляції.
|
||||
#
|
||||
# Заповнені: API й колектор ходять у базу роллю netpulse_app БЕЗ
|
||||
# BYPASSRLS, тобто під політиками RLS, а фонові такти — окремою роллю
|
||||
# netpulse_worker. Забутий предикат tenant_id у коді дає порожній
|
||||
# результат замість чужих даних.
|
||||
#
|
||||
# Порожні: застосунок ходить роллю власника (суперкористувач), і жодна
|
||||
# з політик RLS не діє. Ізоляцію кабінетів тримає лише те, що кожен
|
||||
# запит у коді фільтрує tenant_id руками.
|
||||
#
|
||||
# НОВА ІНСТАЛЯЦІЯ: заповнити обидва, більше нічого не робити.
|
||||
# netpulse-migrate побачить порожню базу, накотить схему й сам видасть
|
||||
# ці паролі ролям — RLS працює з першого запуску. Установник заповнює
|
||||
# ці рядки за вас.
|
||||
#
|
||||
# ІНСТАЛЯЦІЯ, СТАРША ЗА 0063: не чіпати цих рядків, поки не пройдено
|
||||
# deploy/RLS-EXISTING-INSTALL.md. На непорожній базі мігратор паролів не
|
||||
# видає навмисно — там паролі видає людина, з перевіркою після кожного
|
||||
# кроку й зі шляхом назад.
|
||||
#
|
||||
# Обидва або жодного. Заповнений NETPULSE_APP_PASSWORD без
|
||||
# NETPULSE_WORKER_PASSWORD — найгірший стан із можливих: інтерфейс
|
||||
# працює, а бекапи, алерти й події мовчки не відбуваються. Мігратор
|
||||
# такої пари не приймає й зупиняє запуск.
|
||||
#
|
||||
# ТІЛЬКИ HEX, і це не стиль, а вимога. Пароль їде всередині DSN
|
||||
# postgres://роль:пароль@db, тому не кожен символ лишається собою:
|
||||
# `/` (звичайний символ base64) обриває пароль і перетворює його хвіст
|
||||
# на ім'я бази, а `%` читається як початок %XX і мовчки робить пароль
|
||||
# іншим рядком. У журналі це «database ... does not exist» або
|
||||
# «authentication failed», а на вигляд — «RLS усе зламав».
|
||||
# openssl rand -hex 24
|
||||
NETPULSE_APP_PASSWORD=
|
||||
|
||||
# openssl rand -hex 24
|
||||
NETPULSE_WORKER_PASSWORD=
|
||||
|
||||
# Ключ шифрування секретів (паролі SSH, SNMP-community).
|
||||
# Формат: <id>=<32 байти hex>. Id потрібен для зміни ключа: старий
|
||||
# лишається в списку, щоб розшифрувати вже записане.
|
||||
|
|
|
|||
|
|
@ -60,7 +60,19 @@ FROM alpine:3.20
|
|||
# бекапів і тиша сповіщень задаються в часовому поясі тенанта. wget не
|
||||
# ставимо: healthcheck обходиться тим, що вже є в busybox.
|
||||
RUN apk add --no-cache ca-certificates tzdata \
|
||||
&& adduser -D -u 10001 netpulse
|
||||
&& adduser -D -u 10001 netpulse \n && mkdir -p /var/lib/netpulse \n && chown -R netpulse:netpulse /var/lib/netpulse
|
||||
|
||||
# Каталог сховища версій створюється В ОБРАЗІ й одразу під тим
|
||||
# користувачем, від якого працює процес.
|
||||
#
|
||||
# Інакше іменований том, змонтований сюди вперше, успадковує права
|
||||
# каталогу з образу — а він був root:root. Процес під uid 10001 не
|
||||
# міг створити в ньому навіть підкаталог, і кожен коміт конфігу в
|
||||
# git тихо не відбувався: у сховищі лишався контентний хеш замість
|
||||
# SHA коміту, а репозиторій не існував узагалі.
|
||||
#
|
||||
# Помітили аж тоді, коли вперше знадобилось дзеркалення на
|
||||
# зовнішній Git: виявилось, що дзеркалити нема чого.
|
||||
|
||||
COPY --from=build /out/ /usr/local/bin/
|
||||
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ openssl rand -base64 48 # NETPULSE_JWT_SECRET
|
|||
|
||||
```sh
|
||||
docker compose up -d --build
|
||||
docker compose run --rm api netpulse-user \
|
||||
docker compose run --rm --entrypoint netpulse-user cli \
|
||||
-tenant default -login admin -role owner -name "Адміністратор"
|
||||
```
|
||||
|
||||
|
|
@ -168,6 +168,31 @@ docker compose up -d --build
|
|||
Відкат схеми не передбачений: зворотні міграції на даних телеметрії
|
||||
коштують дорожче, ніж відновлення з бекапу.
|
||||
|
||||
## Ізоляція кабінетів (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"
|
||||
```
|
||||
|
||||
## Зміна ключа шифрування
|
||||
|
||||
Ключі перелічуються через кому, новий — першим:
|
||||
|
|
|
|||
419
deploy/RLS-EXISTING-INSTALL.md
Normal file
419
deploy/RLS-EXISTING-INSTALL.md
Normal file
|
|
@ -0,0 +1,419 @@
|
|||
# Увімкнення RLS на інсталяції, старшій за 0063
|
||||
|
||||
> **Цей документ потрібен ЛИШЕ інсталяціям, зробленим до міграції 0063.**
|
||||
> На системі, розгорнутій із цією версією, RLS увімкнено з першого
|
||||
> запуску: `netpulse-migrate` бачить порожню базу, накочує схему й
|
||||
> одразу видає ролям паролі з `.env`. Робити не треба нічого, читати це
|
||||
> теж не треба.
|
||||
>
|
||||
> Як перевірити, що це саме ваш випадок:
|
||||
>
|
||||
> ```sh
|
||||
> docker compose exec -T db psql -U netpulse -d netpulse -c \
|
||||
> "SELECT fresh, applied_was, decided_at FROM public.netpulse_install"
|
||||
> ```
|
||||
>
|
||||
> `fresh = t` — інсталяція народилась із RLS, далі не читайте. `fresh = f`
|
||||
> — база вже працювала, коли її вперше побачив мігратор із підтримкою
|
||||
> ролей; перехід на ній робиться руками, і саме про це документ нижче.
|
||||
> Таблиці немає взагалі — стенд ще не оновлювався; оновіть образи й
|
||||
> накотіть міграції (крок 1), рядок з'явиться.
|
||||
|
||||
Як увімкнути справжню ізоляцію кабінетів на живому стенді, що перевіряти
|
||||
після кожного кроку і як відкотитись, якщо застосунок перестане бачити
|
||||
дані.
|
||||
|
||||
## Чому це не робиться саме
|
||||
|
||||
Мігратор уміє видавати ролям паролі й робить це на кожній новій
|
||||
інсталяції. На вашій він цього навмисно не робить, і ознака, за якою він
|
||||
розрізняє два випадки, — не здогад, а записаний факт: чи була
|
||||
`public.schema_migrations` порожня в ту мить, коли мігратор уперше
|
||||
побачив цю базу. Відповідь пишеться один раз у `public.netpulse_install`
|
||||
і більше не переглядається.
|
||||
|
||||
Причина проста. На порожній базі перемикати нічого: немає ані даних, ані
|
||||
клієнтів, ані стану, у який можна повернутись. На вашій — є все три.
|
||||
Перемикання роллю на живому стенді має вікно, у якому зонди можуть
|
||||
замовкнути, і мусить мати крок, на якому можна зупинитись. Тому воно
|
||||
лишається процедурою, а `docker compose up` його не запускає.
|
||||
|
||||
## Що саме змінюється
|
||||
|
||||
Політики Row Level Security написані в схемі з міграції 0011 і стоять на
|
||||
68 таблицях. Жодна з них ніколи не спрацьовувала: `docker-compose.yml`
|
||||
збирає DSN із ролі `netpulse`, а її створює образ Postgres зі змінної
|
||||
`POSTGRES_USER`, тобто bootstrap-суперкористувачем. Суперкористувач
|
||||
обходить RLS беззастережно.
|
||||
|
||||
Тобто ізоляцію кабінетів у продукті тримає рівно одне: те, що кожен
|
||||
запит у `server/internal/store` дописує `tenant_id = $1` руками. Один
|
||||
забутий предикат — і клієнт бачить чужі хости. Другий рубіж написаний,
|
||||
увімкнений у схемі й вимкнений у житті.
|
||||
|
||||
Перехід дає три ролі:
|
||||
|
||||
| роль | BYPASSRLS | хто ходить |
|
||||
|---|---|---|
|
||||
| `netpulse` | так (суперкористувач) | `migrate`, `netpulse-user`, `netpulse-secret` |
|
||||
| `netpulse_app` | **ні** | `api`, `collector` |
|
||||
| `netpulse_worker` | так | фонові такти всередині `api` і `collector`, `netpulse-gitsync` |
|
||||
|
||||
`netpulse_worker` лишається з BYPASSRLS свідомо: запити-шукачі черг
|
||||
(`ClaimConfigJobs`, `ClaimCommandJobs`, `DuePolicies`, `FetchEvents`) —
|
||||
це одна інструкція `UPDATE … FOR UPDATE SKIP LOCKED … RETURNING
|
||||
tenant_id` на всю інсталяцію. Розкласти її по кабінетах означає замінити
|
||||
один такт на N тактів кожні 5 секунд і завести голодування. Обґрунтування
|
||||
цілком — у коментарі до `0063_rls_enforce.sql`, розділ 1.
|
||||
|
||||
## Чого цей перехід не робить
|
||||
|
||||
- **Не змінює телеметрію.** Гіпертаблиці (`ts.*`, `core.audit_log`,
|
||||
`alr.alerts_history`, `alr.notifications`) під RLS не підпадають — і не
|
||||
можуть, поки ввімкнено стиснення. Їхню ізоляцію й далі тримає предикат
|
||||
у запиті. Це не наслідок переходу, а незмінна властивість TimescaleDB.
|
||||
- **Не рятує від BYPASSRLS у воркера.** Роль воркера бачить усе. Її
|
||||
обмежує не RLS, а те, ким і звідки вона використовується: окремий пул
|
||||
у `store.Store.bg`, окремий пароль, скінченний перелік методів
|
||||
(`grep -rn 's\.bg\.' server/internal/store/`).
|
||||
- **Не переносить володіння об'єктами.** `netpulse` лишається власником
|
||||
усіх 100+ таблиць. Передавати ownership на живій базі — це `ALTER
|
||||
TABLE … OWNER TO` на кожну гіпертаблицю з чанками, тобто довга
|
||||
блокувальна дія на чужих даних заради нуля користі.
|
||||
|
||||
## Передумови
|
||||
|
||||
- Свіжий дамп бази (`## Бекап` у `deploy/README.md`) — знятий **сьогодні**,
|
||||
не «десь був».
|
||||
- Вікно, у якому допустимо, що зонди на кілька хвилин перестануть
|
||||
доповідати. Дані за цей час не губляться: агент тримає їх у себе й
|
||||
дошле, — але алерти в цей проміжок не рахуються.
|
||||
- Доступ до `docker compose exec db psql`.
|
||||
|
||||
Далі всюди мається на увазі, що ви в каталозі з `docker-compose.yml`.
|
||||
|
||||
---
|
||||
|
||||
## Крок 1. Накотити 0063
|
||||
|
||||
```sh
|
||||
git pull
|
||||
docker compose build
|
||||
docker compose run --rm migrate
|
||||
```
|
||||
|
||||
**Це безпечно й нічого не вмикає.** 0063 заводить ролі без пароля
|
||||
(підключитись ними ще не можна), роздає права, закриває політиками шість
|
||||
зв'язкових таблиць і ставить `security_invoker` на два вигляди. Поки
|
||||
застосунок ходить суперкористувачем, жодна з цих змін на нього не діє.
|
||||
|
||||
Міграція сама себе перевіряє: якщо в схемі є таблиця з `tenant_id` без
|
||||
RLS або без політики, або таблиця, до якої `netpulse_app` не має SELECT,
|
||||
вона впаде з переліком таких таблиць. Падіння тут означає «переходити ще
|
||||
рано», а не «щось зламалось».
|
||||
|
||||
На цьому ж запуску мігратор запише `public.netpulse_install` — рядок,
|
||||
який назавжди фіксує, що ця база НЕ була порожньою, коли він її вперше
|
||||
побачив. Саме через нього все подальше лишається ручним: паролі ролям на
|
||||
такій базі він не видасть ні зараз, ні через рік, скільки б рядків не
|
||||
з'явилось у `.env`.
|
||||
|
||||
**Перевірити:**
|
||||
|
||||
```sh
|
||||
docker compose exec -T db psql -U netpulse -d netpulse -c \
|
||||
"SELECT rolname, rolcanlogin, rolbypassrls FROM pg_roles
|
||||
WHERE rolname LIKE 'netpulse%' ORDER BY 1"
|
||||
```
|
||||
|
||||
Очікується рівно це:
|
||||
|
||||
```
|
||||
rolname | rolcanlogin | rolbypassrls
|
||||
-----------------+-------------+--------------
|
||||
netpulse | t | t
|
||||
netpulse_app | t | f
|
||||
netpulse_worker | t | t
|
||||
```
|
||||
|
||||
`rolbypassrls = f` у `netpulse_app` — головний рядок цієї таблиці. Якщо
|
||||
там `t`, далі йти немає сенсу: усе наступне пройде, і не змінить нічого.
|
||||
|
||||
Застосунок на цьому кроці не чіпаємо. Можна зупинитись тут на добу.
|
||||
|
||||
---
|
||||
|
||||
## Крок 2. Видати паролі
|
||||
|
||||
Паролі не лежать у міграції навмисно: у git і в контрольній сумі
|
||||
`public.schema_migrations` вони були б назавжди.
|
||||
|
||||
**Тільки hex.** Пароль ролі їде всередині DSN
|
||||
`postgres://роль:пароль@db:5432/netpulse`, і не кожен символ лишається
|
||||
там собою. `openssl rand -base64` рано чи пізно видасть `/` — скісна
|
||||
риска обриває пароль і перетворює його хвіст на ім'я бази: з'єднання не
|
||||
встановлюється, у журналі стоїть «database … does not exist». Гірший
|
||||
випадок — `%`: розбирач читає його як початок `%XX`, помилки немає, а
|
||||
пароль мовчки стає іншим рядком. Виглядає це не як зіпсований рядок у
|
||||
`.env`, а як «RLS усе зламав», і півдня цього проєкту коштувало саме
|
||||
воно.
|
||||
|
||||
```sh
|
||||
APP_PW=$(openssl rand -hex 24)
|
||||
WRK_PW=$(openssl rand -hex 24)
|
||||
|
||||
docker compose exec -T db psql -U netpulse -d netpulse <<SQL
|
||||
ALTER ROLE netpulse_app PASSWORD '$APP_PW';
|
||||
ALTER ROLE netpulse_worker PASSWORD '$WRK_PW';
|
||||
SQL
|
||||
|
||||
echo "NETPULSE_APP_PASSWORD=$APP_PW"
|
||||
echo "NETPULSE_WORKER_PASSWORD=$WRK_PW"
|
||||
```
|
||||
|
||||
**Перевірити, що ролі підключаються:**
|
||||
|
||||
```sh
|
||||
docker compose exec -T db env PGPASSWORD="$APP_PW" \
|
||||
psql -U netpulse_app -d netpulse -c "SELECT current_user, rolbypassrls
|
||||
FROM pg_roles WHERE rolname = current_user"
|
||||
```
|
||||
|
||||
Застосунок і тут не чіпаємо: ролі є, паролі є, ніхто ними ще не ходить.
|
||||
|
||||
---
|
||||
|
||||
## Крок 3. Перевірити ізоляцію на цій самій базі
|
||||
|
||||
Це той крок, який відрізняє «RLS увімкнено» від «RLS працює». Робиться
|
||||
на бойовій базі, **нічого в неї не пишучи**: `SET ROLE` перемикає
|
||||
діючого користувача в межах однієї сесії, а `ROLLBACK` наприкінці не
|
||||
лишає слідів.
|
||||
|
||||
Підставте реальні id двох різних кабінетів:
|
||||
|
||||
```sh
|
||||
docker compose exec -T db psql -U netpulse -d netpulse <<'SQL'
|
||||
BEGIN;
|
||||
SET ROLE netpulse_app;
|
||||
|
||||
SET LOCAL app.tenant_id = 'ПЕРШИЙ-КАБІНЕТ-UUID';
|
||||
SELECT 'бачить своїх' AS q, count(*) FROM inv.devices;
|
||||
SELECT 'бачить чужих' AS q, count(*) FROM inv.devices
|
||||
WHERE tenant_id <> 'ПЕРШИЙ-КАБІНЕТ-UUID';
|
||||
SELECT 'зв''язки: доступи' AS q, count(*) FROM inv.device_credentials;
|
||||
SELECT 'вигляд лінків' AS q, count(*) FROM topo.link_live;
|
||||
|
||||
SET LOCAL app.tenant_id = '';
|
||||
SELECT 'без контексту' AS q, count(*) FROM inv.devices;
|
||||
|
||||
RESET ROLE;
|
||||
ROLLBACK;
|
||||
SQL
|
||||
```
|
||||
|
||||
Очікується: «бачить своїх» — реальна кількість хостів кабінету, «бачить
|
||||
чужих» — **0**, «без контексту» — **0**. Кількість у зв'язках і у вигляді
|
||||
`topo.link_live` має відповідати цьому ж кабінету, а не всій інсталяції.
|
||||
|
||||
Якщо «бачить чужих» більше нуля — зупиніться. Далі йти не можна: це
|
||||
означає, що якась таблиця лишилась без політики, і перехід дасть хибне
|
||||
відчуття захисту замість захисту.
|
||||
|
||||
Той самий сценарій у вигляді тесту, який ганяється на одноразовій базі:
|
||||
`server/internal/store/rls_isolation_test.go`.
|
||||
|
||||
---
|
||||
|
||||
## Крок 4. Перемкнути застосунок
|
||||
|
||||
У `.env` дописати два рядки (пароль ролі-власника лишається на місці —
|
||||
ним ходять міграції й утиліти):
|
||||
|
||||
```sh
|
||||
NETPULSE_APP_PASSWORD=<APP_PW з кроку 2>
|
||||
NETPULSE_WORKER_PASSWORD=<WRK_PW з кроку 2>
|
||||
```
|
||||
|
||||
Обидва — разом. `NETPULSE_APP_PASSWORD` без `NETPULSE_WORKER_PASSWORD`
|
||||
дає найгірший з можливих станів: інтерфейс працює, а фонові такти
|
||||
мовчки нічого не знаходять — бекапи не запускаються, алерти не
|
||||
розсилаються, події не доходять до браузера, і жодної помилки в журналі
|
||||
при цьому немає. Тепер цю пару перевіряє мігратор і зупиняє запуск, але
|
||||
покладатись на це не варто: він рятує від забутого рядка, а не від
|
||||
неправильного пароля.
|
||||
|
||||
Окремої змінної `NETPULSE_APP_USER` більше немає. Раніше вона була, і
|
||||
будь-яка з двох половин без другої давала DSN, який не встановлюється:
|
||||
нова роль зі старим паролем або стара роль з новим. Тепер ім'я ролі
|
||||
випливає з наявності пароля. Якщо `NETPULSE_APP_USER` лишився у вашому
|
||||
`.env` — його просто ігнорують, видаляти не обов'язково.
|
||||
|
||||
```sh
|
||||
docker compose up -d api collector
|
||||
```
|
||||
|
||||
`api` і `collector` залежать від `migrate`, тож перед ними ще раз
|
||||
відпрацює мігратор. Схему він не змінить (вона актуальна), паролів
|
||||
ролям не видасть (база непорожня), але зайде обома DSN і перевірить, що
|
||||
`netpulse_app` заходить і не має BYPASSRLS, а `netpulse_worker` заходить
|
||||
і має. Якщо крок 2 пропущено, ви побачите це тут, а не за годину в
|
||||
журналі колектора.
|
||||
|
||||
**Перевірити протягом перших п'яти хвилин:**
|
||||
|
||||
1. **Інтерфейс.** Увійти й відкрити перелік хостів. Порожній перелік у
|
||||
непорожньому кабінеті — ознака, що щось лишилось без політики.
|
||||
|
||||
```sh
|
||||
curl -sf https://$NETPULSE_DOMAIN/healthz
|
||||
```
|
||||
|
||||
2. **Зонди.** Це ламається першим, якщо ламається:
|
||||
|
||||
```sh
|
||||
docker compose exec -T db psql -U netpulse -d netpulse -c \
|
||||
"SELECT status, count(*), max(last_heartbeat_at) FROM core.agents GROUP BY 1"
|
||||
```
|
||||
|
||||
`max(last_heartbeat_at)` має бути свіжішим за хвилину. Якщо він
|
||||
застиг на моменті перезапуску — агенти не автентифікуються, і це
|
||||
видно ще й у журналі колектора:
|
||||
|
||||
```sh
|
||||
docker compose logs --since 5m collector | grep -i unauth
|
||||
```
|
||||
|
||||
3. **Телеметрія доходить:**
|
||||
|
||||
```sh
|
||||
docker compose exec -T db psql -U netpulse -d netpulse -c \
|
||||
"SELECT max(ts) FROM ts.icmp_samples"
|
||||
```
|
||||
|
||||
4. **Фонові такти живі.** Черга завдань не має рости монотонно:
|
||||
|
||||
```sh
|
||||
docker compose exec -T db psql -U netpulse -d netpulse -c \
|
||||
"SELECT status, count(*) FROM ncm.jobs GROUP BY 1"
|
||||
```
|
||||
|
||||
Повторити через п'ять хвилин. Якщо `queued` росте, а `running` і
|
||||
`done` стоять — воркер не бачить черги, тобто `NETPULSE_WORKER_PASSWORD`
|
||||
не доїхало.
|
||||
|
||||
5. **Події доходять у браузер.** Відкрита сторінка має оновлювати статуси
|
||||
без перезавантаження. Непрямо:
|
||||
|
||||
```sh
|
||||
docker compose exec -T db psql -U netpulse -d netpulse -c \
|
||||
"SELECT count(*) FROM core.event_outbox WHERE published_at IS NULL"
|
||||
```
|
||||
|
||||
Число має коливатись, а не тільки зростати.
|
||||
|
||||
**Перевірити протягом першої доби:**
|
||||
|
||||
6. **Бекап конфігів відпрацював за розкладом:**
|
||||
|
||||
```sh
|
||||
docker compose exec -T db psql -U netpulse -d netpulse -c \
|
||||
"SELECT max(created_at) FROM ncm.configs"
|
||||
```
|
||||
|
||||
7. **Алерти рахуються.** Погасити тестовий хост і переконатись, що алерт
|
||||
з'явився й прийшов у канал.
|
||||
|
||||
8. **Журнал аудиту показує імена акторів, а не порожні клітинки.** Це
|
||||
єдине місце, де помилка виглядає правдоподібно: перелік подій
|
||||
лишається, а колонка «хто» стає порожньою.
|
||||
|
||||
---
|
||||
|
||||
## Крок 5. Утиліти командного рядка
|
||||
|
||||
`netpulse-user` (заводить кабінети й людей) і `netpulse-secret` (кладе
|
||||
паролі в `core.secrets`) роблять рівно те, чого роль під RLS робити не
|
||||
має. Після переходу їм потрібен DSN власника — для цього в
|
||||
`docker-compose.yml` є окрема служба `cli`:
|
||||
|
||||
```sh
|
||||
docker compose run --rm --entrypoint netpulse-user cli \
|
||||
-tenant default -login admin -role owner
|
||||
```
|
||||
|
||||
Без неї вони не впадуть з помилкою, а мовчки нічого не знайдуть.
|
||||
|
||||
`netpulse-gitsync`, навпаки, ходить `NETPULSE_DSN_WORKER` і працює як є —
|
||||
перелік кабінетів він бере крос-тенантним запитом.
|
||||
|
||||
---
|
||||
|
||||
## Відкат
|
||||
|
||||
Відкат — не міграція. 0063 нічого не ламає й лишається накоченим; назад
|
||||
повертається тільки те, якою роллю ходить застосунок.
|
||||
|
||||
**Швидкий (30 секунд, без втрати даних):**
|
||||
|
||||
```sh
|
||||
# У .env закоментувати або прибрати два рядки:
|
||||
# NETPULSE_APP_PASSWORD, NETPULSE_WORKER_PASSWORD
|
||||
docker compose up -d api collector
|
||||
```
|
||||
|
||||
DSN згортається до `netpulse` — тобто до стану «до переходу», разом із
|
||||
BYPASSRLS. Перевірка та сама, що на кроці 4, пункти 1–4.
|
||||
|
||||
Прибирати треба обидва рядки, і саме тому їх лишилось два, а не три:
|
||||
одна змінна вирішує і роль, і пароль, тому «прибрав половину» більше не
|
||||
є станом, у який можна потрапити.
|
||||
|
||||
Це працює, бо порожній `NETPULSE_APP_PASSWORD` збирає старий DSN, а
|
||||
порожній `NETPULSE_WORKER_PASSWORD` лишає `NETPULSE_DSN_WORKER`
|
||||
порожнім — і `store.UseWorkerDSN` тоді просто не відкриває другий пул,
|
||||
а фонові запити йдуть основним. Жодного коду вимикати не треба.
|
||||
|
||||
**Якщо відкат не допоміг** — значить справа не в ролях, і схема тут ні
|
||||
до чого: 0063 не змінює жодної таблиці з даними. Дивіться, що ще
|
||||
поїхало разом із цим релізом.
|
||||
|
||||
**Схему назад не котять.** Зворотних міграцій у проєкті немає навмисно
|
||||
(`deploy/README.md`, `## Оновлення`), і 0063 тут не виняток. Якщо треба
|
||||
прибрати саме її наслідки — це три команди, і жодна не чіпає даних:
|
||||
|
||||
```sql
|
||||
ALTER ROLE netpulse_app NOLOGIN;
|
||||
ALTER ROLE netpulse_worker NOLOGIN;
|
||||
ALTER VIEW topo.link_live SET (security_invoker = false);
|
||||
```
|
||||
|
||||
Політики на зв'язкових таблицях лишати можна: під суперкористувачем
|
||||
вони не діють.
|
||||
|
||||
---
|
||||
|
||||
## Що зробити потім
|
||||
|
||||
1. **Звузити права `netpulse_worker`.** Зараз він має `SELECT, INSERT,
|
||||
UPDATE, DELETE` на все — успадковано з 0011. Звужувати наосліп, за
|
||||
читанням коду, — спосіб зупинити бекапи через півтори доби на
|
||||
таблиці, про яку забули. Правильний порядок: дати стенду відпрацювати
|
||||
тиждень, зняти фактичний перелік і звузити за ним.
|
||||
|
||||
```sql
|
||||
-- увімкнути на добу, потім зняти перелік
|
||||
ALTER SYSTEM SET pg_stat_statements.track = 'all';
|
||||
SELECT calls, query FROM pg_stat_statements
|
||||
WHERE userid = 'netpulse_worker'::regrole ORDER BY calls DESC;
|
||||
```
|
||||
|
||||
2. **Прибрати другий рубіж там, де він більше не потрібен?** Ні. Явний
|
||||
`tenant_id = $1` у запитах лишається: на гіпертаблицях він єдиний, а
|
||||
на решті — те, що робить план запиту передбачуваним (політика додає
|
||||
умову, індекс використовує предикат).
|
||||
|
||||
3. **Стежити за новими таблицями.** Перевірка в 0063 разова — вона
|
||||
спрацювала на момент накочування. Наступна таблиця з `tenant_id` без
|
||||
політики знову з'явиться мовчки. Найдешевше — повторити ту саму
|
||||
перевірку в наступній міграції, що додає таблиці.
|
||||
104
deploy/act-runner.config.yml
Normal file
104
deploy/act-runner.config.yml
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
# Налаштування раннера Forgejo Actions для NetPulse.
|
||||
#
|
||||
# Читається демоном як `forgejo-runner daemon --config`. Підключається
|
||||
# у deploy/docker-compose.ci.yml.
|
||||
#
|
||||
# Головне, заради чого цей файл узагалі існує, — два рядки:
|
||||
# `capacity: 1` і `valid_volumes`. Решта — замовчування з поясненням,
|
||||
# чому їх не змінено.
|
||||
|
||||
log:
|
||||
level: info
|
||||
|
||||
runner:
|
||||
# Файл реєстрації. Лежить в іменованому томі, тобто переживає
|
||||
# перестворення контейнера: реєструватись заново після кожного
|
||||
# `docker compose pull` не треба.
|
||||
file: /data/.runner
|
||||
|
||||
# ОДНА робота за раз. Не «щоб було просто», а тому що на цій машині
|
||||
# 4 ГБ памʼяті й на ній же живе бойовий стек. Дві паралельні роботи —
|
||||
# це `go build` і `vite build` одночасно, тобто ядро вибирає, кого
|
||||
# вбити, і вибір робить не на нашу користь. CI, який кладе продукт,
|
||||
# гірший за відсутній CI.
|
||||
capacity: 1
|
||||
|
||||
# Стеля на роботу. Довший прогін означає, що щось зависло (мережа до
|
||||
# реєстру образів, база, яка не піднялась), а не що збірка велика:
|
||||
# найдовша робота тут — server, і вона вкладається в десяток хвилин.
|
||||
timeout: 1h
|
||||
|
||||
# Пауза між опитуваннями Forgejo. Дві секунди — замовчування; частіше
|
||||
# немає сенсу, рідше — довше чекати відповіді на пуш.
|
||||
fetch_timeout: 5s
|
||||
fetch_interval: 2s
|
||||
|
||||
# Мітки визначають, що можна писати в `runs-on`. Усі роботи в
|
||||
# .forgejo/workflows/ci.yml пишуть `runs-on: docker` і задають свій
|
||||
# образ через `container:`, тож образ у мітці — лише запасний варіант
|
||||
# для робіт без `container`. node:22-alpine узято тому, що це єдиний
|
||||
# із наших образів, у якому є node: якщо колись зʼявиться робота з
|
||||
# JS-дією, вона хоча б стартує.
|
||||
#
|
||||
# УВАГА: мітки з цього файлу застосовуються при РЕЄСТРАЦІЇ. Якщо
|
||||
# раннер уже зареєстровано, зміна цього списку нічого не змінить,
|
||||
# доки не перереєструвати (див. deploy/docker-compose.ci.yml).
|
||||
labels:
|
||||
- "docker:docker://node:22-alpine"
|
||||
|
||||
cache:
|
||||
# Вбудований кеш-сервер вимкнено НАВМИСНО.
|
||||
#
|
||||
# Він потрібен лише для actions/cache, а її тут немає: кеші Go й npm
|
||||
# їдуть іменованими томами (див. valid_volumes). Різниця не в смаку —
|
||||
# у диску. Кеш-сервер тримає вміст ЩЕ РАЗ, у вигляді tar-архівів
|
||||
# поруч, і пакує/розпаковує їх на кожному прогоні. На машині з ~7 ГБ
|
||||
# вільного це друга копія того самого й зайві хвилини на прогін.
|
||||
enabled: false
|
||||
|
||||
container:
|
||||
# Порожньо — act створює власну мережу під кожну роботу. Саме завдяки
|
||||
# їй контейнер роботи бачить сервіс `db` за іменем `db`.
|
||||
network: ""
|
||||
|
||||
# Docker-in-Docker не потрібен: ми нічого не збираємо в образи.
|
||||
privileged: false
|
||||
|
||||
# Стеля памʼяті й ядер на КОНТЕЙНЕР РОБОТИ.
|
||||
#
|
||||
# Це свідомий вибір, кому вмирати першим. Без стелі найбільший
|
||||
# споживач памʼяті на машині — компілятор Go — і ядро при нестачі
|
||||
# вбиває не його, а те, що поруч, тобто API або базу стенду. Зі
|
||||
# стелею перевищення вбиває саму роботу: CI червоніє, продукт живий.
|
||||
# Червоний CI видно й можна полагодити; перезапущений уночі API —
|
||||
# ні.
|
||||
#
|
||||
# --cpus=2 заразом задає GOMAXPROCS: Go 1.25 читає обмеження cgroup
|
||||
# сам, окремо його виставляти не треба.
|
||||
options: "--memory=1600m --cpus=2"
|
||||
|
||||
# Батьківський каталог робочих каталогів.
|
||||
workdir_parent: workspace
|
||||
|
||||
# Томи, які роботам дозволено монтувати. Порожній список означає
|
||||
# «жодних», і тоді .forgejo/workflows/ci.yml не запуститься взагалі:
|
||||
# там кожна робота монтує свій кеш.
|
||||
#
|
||||
# Перелічено поіменно, а не '**': раннер ходить у докер із правами
|
||||
# root, і дозвіл монтувати будь-що перетворює будь-який workflow із
|
||||
# будь-якої гілки на доступ до всього диска машини — включно з
|
||||
# даними бойової бази.
|
||||
valid_volumes:
|
||||
- netpulse-ci-gomod
|
||||
- netpulse-ci-gobuild
|
||||
- netpulse-ci-npm
|
||||
|
||||
# Порожньо — береться DOCKER_HOST із оточення (сокет, прокинутий у
|
||||
# compose).
|
||||
docker_host: ""
|
||||
|
||||
# Не тягнути образ, якщо він уже є. Образи тут прибиті до версій
|
||||
# (alpine:3.21, golang:1.25-alpine, node:22-alpine, timescaledb з
|
||||
# тим самим тегом, що в бойовому compose), тож перевіряти реєстр на
|
||||
# кожному прогоні — зайвий трафік і зайва точка відмови.
|
||||
force_pull: false
|
||||
146
deploy/docker-compose.ci.yml
Normal file
146
deploy/docker-compose.ci.yml
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
# Раннер Forgejo Actions для NetPulse — окремим стеком.
|
||||
#
|
||||
# Окремий файл, а не сервіс у docker-compose.yml, свідомо: CI не має
|
||||
# ані підніматись, ані падати разом із продуктом. `docker compose down`
|
||||
# на стенді не повинен забирати з собою раннер, а перезапуск раннера не
|
||||
# повинен торкатись бази.
|
||||
#
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# РЕЄСТРАЦІЯ (робиться один раз, руками)
|
||||
#
|
||||
# 1. У Forgejo: Налаштування → Actions → Runners → Create registration
|
||||
# token. Рівень вибирає, що раннер бачитиме:
|
||||
# репозиторій — лише netpulse (для цієї задачі досить);
|
||||
# організація/інстанс — усі репозиторії, включно з майбутніми.
|
||||
# Раннер має root-доступ до докера машини, тому ширший рівень
|
||||
# означає, що будь-який workflow у будь-якому репозиторії отримує
|
||||
# цей доступ. Беріть найвужчий, який покриває потребу.
|
||||
#
|
||||
# 2. Реєстрація (виконати В КАТАЛОЗІ repo/deploy):
|
||||
#
|
||||
# docker compose -f docker-compose.ci.yml run --rm runner \
|
||||
# forgejo-runner register --no-interactive \
|
||||
# --instance https://git.zotac.keenetic.link \
|
||||
# --token <REGISTRATION_TOKEN> \
|
||||
# --name netpulse-ci \
|
||||
# --labels docker:docker://node:22-alpine
|
||||
#
|
||||
# Результат — файл .runner у томі runner-data. Він переживає
|
||||
# перестворення контейнера; повторно реєструватись не треба.
|
||||
#
|
||||
# 3. Запуск:
|
||||
#
|
||||
# docker compose -f docker-compose.ci.yml up -d
|
||||
# docker compose -f docker-compose.ci.yml logs -f runner
|
||||
#
|
||||
# У Forgejo раннер має зʼявитись у списку зі станом «idle».
|
||||
#
|
||||
# ЯКЩО FORGEJO НА ЦІЙ САМІЙ МАШИНІ. Публічне імʼя git.zotac.keenetic.link
|
||||
# з контейнера може не резолвитись у себе (розворот NAT на роутері
|
||||
# роблять не всі). Ознака — раннер не реєструється або мовчить після
|
||||
# старту. Лікується одним рядком у сервісі runner:
|
||||
#
|
||||
# extra_hosts:
|
||||
# - "git.zotac.keenetic.link:host-gateway"
|
||||
#
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# ЧОГО ЦЕЙ ФАЙЛ КОШТУЄ
|
||||
#
|
||||
# Памʼять у спокої: раннер ~40 МБ + прибиральник ~5 МБ.
|
||||
# Памʼять під час прогону: одна робота за раз (capacity: 1), стеля
|
||||
# контейнера роботи — 1600 МБ (deploy/act-runner.config.yml), плюс
|
||||
# контейнер Postgres у роботі server (~400 МБ із TS_TUNE_MEMORY).
|
||||
# Тобто пік ~2 ГБ понад те, що вже їсть бойовий стек.
|
||||
# Диск: образи ~1.1 ГБ (alpine 8 МБ, node:22-alpine ~180 МБ,
|
||||
# golang:1.25-alpine ~350 МБ, timescaledb ~450 МБ, сам раннер
|
||||
# ~120 МБ) + кеші зі стелями 1200/900/400 МБ, які тримають самі
|
||||
# роботи наприкінці кожного прогону.
|
||||
# Разом у найгіршому випадку ~3.6 ГБ диска.
|
||||
#
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
name: netpulse-ci
|
||||
|
||||
services:
|
||||
runner:
|
||||
# ВЕРСІЮ ЗВІРТЕ ЗІ СВОЇМ FORGEJO. Раннер і сервер сумісні не в
|
||||
# будь-яких парах, а який саме Forgejo стоїть у вас — звідси не
|
||||
# видно. Це єдиний рядок у цьому файлі, який неможливо перевірити
|
||||
# без доступу до стенду.
|
||||
image: ${FORGEJO_RUNNER_IMAGE:-code.forgejo.org/forgejo/runner:6}
|
||||
container_name: netpulse-ci-runner
|
||||
restart: unless-stopped
|
||||
command: ["forgejo-runner", "daemon", "--config", "/etc/forgejo/runner.yml"]
|
||||
working_dir: /data
|
||||
environment:
|
||||
DOCKER_HOST: unix:///var/run/docker.sock
|
||||
TZ: ${TZ:-Europe/Kyiv}
|
||||
volumes:
|
||||
# Сокет докера, а не docker-in-docker. DinD означав би ще один
|
||||
# демон, власний пул образів (тобто ДРУГУ копію golang, node і
|
||||
# timescaledb) і власний шар зберігання — на машині з ~7 ГБ це
|
||||
# не поміщається. Ціна вибору названа чесно: усе, що потрапить у
|
||||
# workflow, виконується з правами root на цьому докері.
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- runner-data:/data
|
||||
- ./act-runner.config.yml:/etc/forgejo/runner.yml:ro
|
||||
# Сам демон нічого важкого не робить — уся робота в дочірніх
|
||||
# контейнерах, і їхню стелю задає container.options у конфізі.
|
||||
mem_limit: 256m
|
||||
|
||||
# Прибиральник. Половина сенсу цього стеку.
|
||||
#
|
||||
# act лишає по собі зупинені контейнери робіт і сервісів, а зміна
|
||||
# тега образу лишає «висячий» попередній. Саме по собі це не
|
||||
# зникає, і на диску, якого ~7 ГБ, воно закінчується не роками, а
|
||||
# тижнями. CI, який забив диск, зупинить продукт, а не покращить.
|
||||
#
|
||||
# Кешів Go й npm цей сервіс НЕ чіпає навмисно: їх підрізають самі
|
||||
# роботи, останнім кроком. У той момент точно ніхто не компілює, тож
|
||||
# чистка не може вирвати файл з-під `go build`. Прибиральник, який
|
||||
# ходить за розкладом, такої гарантії не має.
|
||||
gc:
|
||||
image: docker:28-cli
|
||||
container_name: netpulse-ci-gc
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
# Томи кешів підключені лише щоб їх БАЧИТИ: compose створює
|
||||
# іменований том тоді, коли його хтось монтує, а розмір у логу
|
||||
# прибиральника — єдине місце, де за ним можна стежити, не
|
||||
# заходячи на машину.
|
||||
- gomod:/caches/gomod:ro
|
||||
- gobuild:/caches/gobuild:ro
|
||||
- npm:/caches/npm:ro
|
||||
entrypoint: ["/bin/sh", "-c"]
|
||||
command: |
|
||||
while :; do
|
||||
# БЕЗ -a і БЕЗ --volumes. `prune -a` знесла б образи бойового
|
||||
# стеку, які просто зараз не запущені (наприклад, під час
|
||||
# оновлення), а `--volumes` — дані. Тут прибирається лише те,
|
||||
# що вже нічим не тримається: зупинені контейнери, підвішені
|
||||
# образи, порожні мережі, кеш складання.
|
||||
docker system prune -f --filter 'until=72h' >/dev/null 2>&1 || true
|
||||
free=$$(df -Pm /caches/gobuild 2>/dev/null | awk 'NR==2{print $$4}')
|
||||
echo "$$(date -Iseconds) CI-gc: вільно $${free:-?} МБ; кеші:" \
|
||||
"$$(du -sh /caches/gomod /caches/gobuild /caches/npm 2>/dev/null | tr '\n' ' ')"
|
||||
sleep 21600
|
||||
done
|
||||
mem_limit: 64m
|
||||
|
||||
volumes:
|
||||
# Реєстрація раннера (.runner) — без неї доведеться реєструватись
|
||||
# заново після кожного перестворення контейнера.
|
||||
runner-data:
|
||||
|
||||
# Кеші збірок. Імена задані явно, БЕЗ префікса проєкту: у
|
||||
# .forgejo/workflows/ci.yml вони пишуться саме так, а контейнери робіт
|
||||
# створює act напряму, а не compose, тож жодного префікса він не
|
||||
# додасть. Ці ж імена мають бути в container.valid_volumes конфігу
|
||||
# раннера, інакше роботи впадуть на монтуванні.
|
||||
gomod:
|
||||
name: netpulse-ci-gomod
|
||||
gobuild:
|
||||
name: netpulse-ci-gobuild
|
||||
npm:
|
||||
name: netpulse-ci-npm
|
||||
88
deploy/files.conf.example
Normal file
88
deploy/files.conf.example
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
# NetPulse — які конфіг-файли цієї машини вільно збирати.
|
||||
#
|
||||
# Покласти в /etc/netpulse/files.conf (шлях змінюється прапорцем
|
||||
# -files-policy або змінною NETPULSE_FILES_POLICY).
|
||||
#
|
||||
# ЦЕЙ ФАЙЛ — ЄДИНЕ, ЩО ВИРІШУЄ, ЩО БУДЕ ПРОЧИТАНО.
|
||||
#
|
||||
# Сервер NetPulse не може ані задати перелік, ані його прочитати: у
|
||||
# протоколі немає поля зі шляхом до файлу. Інакше будь-хто з правом на
|
||||
# збір конфігів отримав би читання довільного файлу на ВСІХ машинах, де
|
||||
# стоїть зонд. Тому перелік редагує той, хто вже має права на цій
|
||||
# машині, — і тільки він.
|
||||
#
|
||||
# Немає файлу — немає збору. Типове значення тут заборона; «якщо не
|
||||
# задано, беремо все» не існує навмисно.
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Правила запису
|
||||
# ---------------------------------------------------------------------
|
||||
#
|
||||
# * один зразок шляху на рядок, абсолютний;
|
||||
# * маски «*», «?», «[...]» діють у МЕЖАХ одного рівня — «*» не
|
||||
# переходить через «/», рекурсивних масок немає взагалі;
|
||||
# * перші два рівні мають бути без масок: «/etc/*» відкриває надто
|
||||
# багато, щоб це можна було написати випадково й не помітити;
|
||||
# * «..» заборонені.
|
||||
|
||||
/etc/hostname
|
||||
/etc/hosts
|
||||
/etc/resolv.conf
|
||||
/etc/timezone
|
||||
|
||||
/etc/network/interfaces
|
||||
/etc/network/interfaces.d/*.cfg
|
||||
|
||||
/etc/nginx/nginx.conf
|
||||
/etc/nginx/conf.d/*.conf
|
||||
/etc/nginx/sites-enabled/*
|
||||
|
||||
/etc/postfix/main.cf
|
||||
/etc/postfix/master.cf
|
||||
|
||||
/etc/ssh/sshd_config
|
||||
/etc/ssh/sshd_config.d/*.conf
|
||||
|
||||
/etc/fstab
|
||||
/etc/crontab
|
||||
/etc/cron.d/*
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Директиви (необов'язкові)
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
# Стеля на один файл, у байтах. Типово 1 МіБ.
|
||||
#!max-file-bytes 1048576
|
||||
|
||||
# Скільки файлів щонайбільше в наборі. Типово 64.
|
||||
#!max-files 64
|
||||
|
||||
# Стеля на весь набір разом. Типово 8 МіБ.
|
||||
#!max-total-bytes 8388608
|
||||
|
||||
# Дозволити файли, закриті для читання «іншими» (права без біта 0004).
|
||||
#
|
||||
# Типово вимкнено, і це один із найдешевших запобіжників: /etc/shadow
|
||||
# має 0640, приватні ключі — 0600, а nginx.conf, interfaces і main.cf —
|
||||
# 0644. Тобто одне правило відрізає рівно той клас файлів, який тут не
|
||||
# потрібен, не зачепивши жодного справжнього конфігу.
|
||||
#
|
||||
# Вмикати лише свідомо й лише разом із вузьким переліком вище.
|
||||
#!allow-nonpublic
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Чого не буде зібрано за жодних умов
|
||||
# ---------------------------------------------------------------------
|
||||
#
|
||||
# Вкомпільований у зонд чорний перелік перекриває цей файл завжди:
|
||||
#
|
||||
# /etc/shadow, /etc/gshadow і будь-який файл із такими іменами;
|
||||
# /proc, /sys, /dev, /run/secrets;
|
||||
# усе під .ssh, .gnupg, .git, */private/;
|
||||
# *.key, *.pem, *.p12, *_key, .env, .netrc, .pgpass, .my.cnf та подібні;
|
||||
# /etc/netpulse і /var/lib/netpulse — власне посвідчення зонда.
|
||||
#
|
||||
# Крім того зонд не піде за символьним посиланням, ціль якого не
|
||||
# дозволена цим файлом сама по собі, і не збереже двійковий чи порожній
|
||||
# файл. Про кожен пропуск він пише причину — вона видно в стенограмі
|
||||
# завдання збору.
|
||||
|
|
@ -4,15 +4,62 @@
|
|||
# Швидкий старт:
|
||||
# cp deploy/.env.example .env # і заповнити секрети
|
||||
# docker compose up -d
|
||||
# docker compose run --rm api netpulse-user -tenant default -login admin -role owner
|
||||
# docker compose run --rm --entrypoint netpulse-user cli \
|
||||
# -tenant default -login admin -role owner
|
||||
#
|
||||
# netpulse-user і netpulse-secret заводять кабінети, людей і секрети —
|
||||
# тобто роблять рівно те, чого роль під RLS робити не має. Тому для них
|
||||
# є окрема служба cli з DSN власника: під роллю застосунку вони не
|
||||
# падають з помилкою, а мовчки нічого не знаходять.
|
||||
#
|
||||
# Тільки стенд для розробки (БД і кеш, решта — з go run):
|
||||
# docker compose up -d db cache
|
||||
|
||||
name: netpulse
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# DSN трьох ролей
|
||||
# ---------------------------------------------------------------------
|
||||
#
|
||||
# Три ролі, три різні відповіді на «що цій ролі вільно бачити» (0063).
|
||||
#
|
||||
# netpulse власник схеми. Ним ходять migrate і cli.
|
||||
# netpulse_app API і колектор. Без BYPASSRLS — під політиками RLS.
|
||||
# netpulse_worker фонові такти поверх усіх кабінетів. З BYPASSRLS.
|
||||
#
|
||||
# Заповнені NETPULSE_APP_PASSWORD і NETPULSE_WORKER_PASSWORD означають
|
||||
# «застосунок ходить під RLS». На ЧИСТІЙ базі їх пише установник, а
|
||||
# netpulse-migrate сам видає ці паролі ролям одразу після накочування
|
||||
# схеми — тобто нова інсталяція працює під політиками з першої секунди,
|
||||
# і слова «cutover» клієнт не бачить взагалі.
|
||||
#
|
||||
# Порожні означають «як до 0063»: DSN згортається до ролі власника,
|
||||
# DSN воркера лишається порожнім, store.UseWorkerDSN другого пулу не
|
||||
# відкриває. Саме на цьому тримається безпека оновлення наявного стенду:
|
||||
# у його .env цих рядків немає, вирази нижче дають той самий DSN, що й
|
||||
# раніше, а мігратор на непорожній базі паролів ролям не видає взагалі.
|
||||
# Перехід для таких інсталяцій — deploy/RLS-EXISTING-INSTALL.md.
|
||||
#
|
||||
# Ім'я ролі більше НЕ окрема змінна, і це виправлення, а не спрощення.
|
||||
# Раніше їх було дві — NETPULSE_APP_USER і NETPULSE_APP_PASSWORD, — і
|
||||
# будь-яка половина без другої давала DSN, який не встановлюється: нова
|
||||
# роль зі старим паролем або стара роль з новим. Виглядає це не як
|
||||
# помилка конфігурації, а як «RLS усе зламав». Тепер прапорець один: є
|
||||
# пароль — є й роль netpulse_app, немає пароля — немає й ролі.
|
||||
#
|
||||
# Вираз читається так. Перша частина додає «netpulse_app:» лише тоді,
|
||||
# коли пароль заданий. Друга віддає або сам пароль (коли заданий), або
|
||||
# цілу пару «netpulse:<пароль власника>» (коли ні). Обидві частини
|
||||
# дивляться на ОДНУ змінну, тому проміжного стану не буває.
|
||||
x-app-dsn: &app-dsn postgres://${NETPULSE_APP_PASSWORD:+netpulse_app:}${NETPULSE_APP_PASSWORD:-netpulse:$POSTGRES_PASSWORD}@db:5432/netpulse?sslmode=disable
|
||||
|
||||
x-worker-dsn: &worker-dsn ${NETPULSE_WORKER_PASSWORD:+postgres://netpulse_worker:${NETPULSE_WORKER_PASSWORD}@db:5432/netpulse?sslmode=disable}
|
||||
|
||||
x-owner-dsn: &owner-dsn postgres://netpulse:${POSTGRES_PASSWORD:?потрібен POSTGRES_PASSWORD}@db:5432/netpulse?sslmode=disable
|
||||
|
||||
x-server-env: &server-env
|
||||
NETPULSE_DSN: postgres://netpulse:${POSTGRES_PASSWORD:?потрібен POSTGRES_PASSWORD}@db:5432/netpulse?sslmode=disable
|
||||
NETPULSE_DSN: *app-dsn
|
||||
NETPULSE_DSN_WORKER: *worker-dsn
|
||||
NETPULSE_DEK: ${NETPULSE_DEK:?потрібен NETPULSE_DEK — див. deploy/README.md}
|
||||
NETPULSE_LOG_LEVEL: ${NETPULSE_LOG_LEVEL:-info}
|
||||
TZ: ${TZ:-Europe/Kyiv}
|
||||
|
|
@ -40,7 +87,11 @@ services:
|
|||
- -c
|
||||
- shared_buffers=${PG_SHARED_BUFFERS:-512MB}
|
||||
- -c
|
||||
- timescaledb.max_background_workers=8
|
||||
# Вісім фонових робітників TimescaleDB — розумно на чотирьох
|
||||
# ядрах і забагато на двох: вони конкурують за ті самі ядра з
|
||||
# самими запитами, і стиснення чанків починає заважати опитуванню.
|
||||
# Установник підставляє число за фактичною кількістю ядер.
|
||||
- timescaledb.max_background_workers=${PG_BG_WORKERS:-8}
|
||||
volumes:
|
||||
- db-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
|
|
@ -56,6 +107,13 @@ services:
|
|||
# у контейнерній віртуалізації (LXC, частина VPS) ядро відмовляє, і
|
||||
# контейнер не стартує взагалі з «error setting rlimit type 8».
|
||||
# Швидкість того не варта — обмеження знято.
|
||||
#
|
||||
# Стеля пам'яті задається явно, бо без неї Dragonfly бере її з
|
||||
# доступної на машині — і рахує ту саму пам'ять, яку вже порахував
|
||||
# PG_SHARED_BUFFERS. На машині з 4 ГБ два незалежні розрахунки
|
||||
# чверті одного пирога закінчуються тим, що ядро вбиває когось
|
||||
# третього, найімовірніше API. Установник задає обидві цифри разом.
|
||||
command: ["--maxmemory=${DRAGONFLY_MAXMEMORY:-512mb}"]
|
||||
volumes:
|
||||
- cache-data:/data
|
||||
restart: unless-stopped
|
||||
|
|
@ -74,7 +132,57 @@ services:
|
|||
COMMIT: ${NETPULSE_COMMIT:-none}
|
||||
image: netpulse/server:${NETPULSE_VERSION:-dev}
|
||||
entrypoint: ["netpulse-migrate"]
|
||||
environment: *server-env
|
||||
environment:
|
||||
<<: *server-env
|
||||
# Схему котить власник, а не netpulse_app: міграції створюють
|
||||
# об'єкти, правлять самі політики й іноді переливають дані поверх
|
||||
# кабінетів. Роль під RLS не змогла б жодного з трьох, а 0063 ще й
|
||||
# перевіряє права netpulse_app — тобто мусить їх бачити.
|
||||
NETPULSE_DSN: *owner-dsn
|
||||
# А цими двома мігратор схему не котить — він ними ЗАХОДИТЬ.
|
||||
#
|
||||
# По-перше, на чистій базі саме звідси беруться паролі ролей:
|
||||
# мігратор витягує пароль із самого DSN, тому в базу лягає рівно
|
||||
# той рядок, який потім надішле pgx. Розійтись вони не можуть за
|
||||
# побудовою — це той самий рядок і той самий розбирач.
|
||||
#
|
||||
# По-друге, перед тим як пустити api й collector, мігратор кожним
|
||||
# з цих DSN підключається й дивиться, під якою роллю опинився.
|
||||
# Саме цієї перевірки бракувало 0063: там перевіряли, що не видно
|
||||
# чужого, а зламався вхід — тобто «чи видно своє».
|
||||
NETPULSE_DSN_APP: *app-dsn
|
||||
NETPULSE_DSN_WORKER: *worker-dsn
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
restart: "no"
|
||||
|
||||
# Утиліти командного рядка під роллю власника: netpulse-user (кабінети
|
||||
# й люди), netpulse-secret (секрети в core.secrets).
|
||||
#
|
||||
# Окрема служба, а не `run --rm api`, бо DSN тут інший. Обидві утиліти
|
||||
# роблять рівно те, чого роль під RLS робити не має: заводять кабінет
|
||||
# (INSERT у core.tenants під політикою tenant_self неможливий за
|
||||
# означенням) і додають людину в чужі за контекстом рядки. Під
|
||||
# netpulse_app вони не падають, а мовчки нічого не знаходять — тобто
|
||||
# на новій інсталяції перша ж команда «створити адміністратора»
|
||||
# виглядала б як успіх, а адміністратора не було б.
|
||||
#
|
||||
# profiles: службу не піднімає ні `up`, ні `up -d`; `docker compose run`
|
||||
# вмикає її профіль сам.
|
||||
#
|
||||
# docker compose run --rm --entrypoint netpulse-user cli \
|
||||
# -tenant default -login admin -role owner
|
||||
cli:
|
||||
build: *server-build
|
||||
image: netpulse/server:${NETPULSE_VERSION:-dev}
|
||||
profiles: ["cli"]
|
||||
environment:
|
||||
<<: *server-env
|
||||
NETPULSE_DSN: *owner-dsn
|
||||
NETPULSE_DSN_WORKER: ""
|
||||
volumes:
|
||||
- git-data:/var/lib/netpulse
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
|
@ -171,13 +279,25 @@ services:
|
|||
command:
|
||||
- -server=collector:9443
|
||||
- -insecure
|
||||
- -modules=icmp,snmp,topology,ncm
|
||||
- -modules=icmp,snmp,topology,ncm,traps
|
||||
- -traps-listen=${NETPULSE_TRAPS_LISTEN:-:162}
|
||||
environment:
|
||||
NETPULSE_ENROLL: ${NETPULSE_ENROLL:-}
|
||||
NETPULSE_NAME: ${NETPULSE_AGENT_NAME:-локальний зонд}
|
||||
TZ: ${TZ:-Europe/Kyiv}
|
||||
# Порт трапів назовні. Прив'язка задається змінною, а не зашита:
|
||||
# 162/udp без автентифікації приймає будь-кого, хто знає адресу, і
|
||||
# на машині з публічним інтерфейсом «усі інтерфейси» — це рішення, а
|
||||
# не типове значення. Порожній NETPULSE_TRAPS_BIND лишає службу
|
||||
# всередині мережі docker, і зовні вона недосяжна.
|
||||
ports:
|
||||
- "${NETPULSE_TRAPS_BIND:-127.0.0.1}:162:162/udp"
|
||||
cap_add:
|
||||
- NET_RAW
|
||||
# 162 нижче 1024, а процес усередині — не root. Без цього зонд
|
||||
# порт не займе; він про це кричить у журнал, але приймати трапи
|
||||
# все одно не буде.
|
||||
- NET_BIND_SERVICE
|
||||
volumes:
|
||||
- agent-identity:/var/lib/netpulse
|
||||
depends_on:
|
||||
|
|
|
|||
|
|
@ -59,8 +59,18 @@ type ConfigJob struct {
|
|||
MaxBytes uint64 `protobuf:"varint,12,opt,name=max_bytes,json=maxBytes,proto3" json:"max_bytes,omitempty"`
|
||||
// Записувати повний транскрипт сесії (для діагностики prompt_regex).
|
||||
CaptureTranscript bool `protobuf:"varint,13,opt,name=capture_transcript,json=captureTranscript,proto3" json:"capture_transcript,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
// Заповнене поле перемикає завдання з «зайди по SSH на пристрій» на
|
||||
// «прочитай конфіг-файли машини, де ти сам стоїш». Тоді transport,
|
||||
// credential, commands і prompt_regex не діють: сесії до пристрою
|
||||
// немає взагалі.
|
||||
//
|
||||
// Окреме поле, а не ще одне значення config_type: config_type
|
||||
// потрапляє в архів як назва зрізу, і навантажувати його ще й
|
||||
// роллю «яким способом збирати» означало б, що зміна назви зрізу
|
||||
// тихо змінює спосіб збору.
|
||||
LocalFiles *LocalFilesJob `protobuf:"bytes,14,opt,name=local_files,json=localFiles,proto3" json:"local_files,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *ConfigJob) Reset() {
|
||||
|
|
@ -184,6 +194,94 @@ func (x *ConfigJob) GetCaptureTranscript() bool {
|
|||
return false
|
||||
}
|
||||
|
||||
func (x *ConfigJob) GetLocalFiles() *LocalFilesJob {
|
||||
if x != nil {
|
||||
return x.LocalFiles
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LocalFilesJob — прохання зібрати локальні конфіг-файли.
|
||||
//
|
||||
// НАЙВАЖЛИВІШЕ В ЦЬОМУ ПОВІДОМЛЕННІ — ЧОГО В НЬОМУ НЕМАЄ.
|
||||
//
|
||||
// Тут немає й не буде поля зі шляхом, маскою чи каталогом. Зонд стоїть
|
||||
// на сервері й читає файли його правами; поле «які саме файли читати»,
|
||||
// кероване з кабінету, означало б віддалене читання довільного файлу на
|
||||
// КОЖНІЙ машині клієнта для будь-кого з правом ncm:write — /etc/shadow,
|
||||
// приватні ключі, .env із паролем бази, і одразу з усіх серверів.
|
||||
//
|
||||
// Тому перелік файлів живе ВИКЛЮЧНО в локальному конфізі зонда
|
||||
// (agent/internal/localfiles), який редагує адміністратор тієї самої
|
||||
// машини. Сервер може попросити зібрати — і не може сказати, що саме.
|
||||
// Наслідок: щоб дістати цим шляхом /etc/shadow, треба вже мати root на
|
||||
// тому сервері; а маючи його, NetPulse для цього не потрібен.
|
||||
//
|
||||
// Поля нижче вміють лише ЗВУЖУВАТИ дозволене машиною: зонд бере мінімум
|
||||
// зі свого й серверного значення. Нуль означає «не звужую».
|
||||
type LocalFilesJob struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// Стеля на один файл.
|
||||
MaxFileBytes uint64 `protobuf:"varint,1,opt,name=max_file_bytes,json=maxFileBytes,proto3" json:"max_file_bytes,omitempty"`
|
||||
// Скільки файлів щонайбільше взяти в один набір.
|
||||
MaxFiles uint32 `protobuf:"varint,2,opt,name=max_files,json=maxFiles,proto3" json:"max_files,omitempty"`
|
||||
// Стеля на весь набір разом.
|
||||
MaxTotalBytes uint64 `protobuf:"varint,3,opt,name=max_total_bytes,json=maxTotalBytes,proto3" json:"max_total_bytes,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *LocalFilesJob) Reset() {
|
||||
*x = LocalFilesJob{}
|
||||
mi := &file_netpulse_v1_ncm_proto_msgTypes[1]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *LocalFilesJob) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*LocalFilesJob) ProtoMessage() {}
|
||||
|
||||
func (x *LocalFilesJob) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_netpulse_v1_ncm_proto_msgTypes[1]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use LocalFilesJob.ProtoReflect.Descriptor instead.
|
||||
func (*LocalFilesJob) Descriptor() ([]byte, []int) {
|
||||
return file_netpulse_v1_ncm_proto_rawDescGZIP(), []int{1}
|
||||
}
|
||||
|
||||
func (x *LocalFilesJob) GetMaxFileBytes() uint64 {
|
||||
if x != nil {
|
||||
return x.MaxFileBytes
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *LocalFilesJob) GetMaxFiles() uint32 {
|
||||
if x != nil {
|
||||
return x.MaxFiles
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *LocalFilesJob) GetMaxTotalBytes() uint64 {
|
||||
if x != nil {
|
||||
return x.MaxTotalBytes
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type ConfigUpload struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// Types that are valid to be assigned to Part:
|
||||
|
|
@ -198,7 +296,7 @@ type ConfigUpload struct {
|
|||
|
||||
func (x *ConfigUpload) Reset() {
|
||||
*x = ConfigUpload{}
|
||||
mi := &file_netpulse_v1_ncm_proto_msgTypes[1]
|
||||
mi := &file_netpulse_v1_ncm_proto_msgTypes[2]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
|
@ -210,7 +308,7 @@ func (x *ConfigUpload) String() string {
|
|||
func (*ConfigUpload) ProtoMessage() {}
|
||||
|
||||
func (x *ConfigUpload) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_netpulse_v1_ncm_proto_msgTypes[1]
|
||||
mi := &file_netpulse_v1_ncm_proto_msgTypes[2]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
|
|
@ -223,7 +321,7 @@ func (x *ConfigUpload) ProtoReflect() protoreflect.Message {
|
|||
|
||||
// Deprecated: Use ConfigUpload.ProtoReflect.Descriptor instead.
|
||||
func (*ConfigUpload) Descriptor() ([]byte, []int) {
|
||||
return file_netpulse_v1_ncm_proto_rawDescGZIP(), []int{1}
|
||||
return file_netpulse_v1_ncm_proto_rawDescGZIP(), []int{2}
|
||||
}
|
||||
|
||||
func (x *ConfigUpload) GetPart() isConfigUpload_Part {
|
||||
|
|
@ -291,14 +389,27 @@ type ConfigHeader struct {
|
|||
CollectedAt *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=collected_at,json=collectedAt,proto3" json:"collected_at,omitempty"`
|
||||
// gzip | none — агент стискає, бо конфіги добре жмуться,
|
||||
// а канал може бути вузьким.
|
||||
Encoding string `protobuf:"bytes,6,opt,name=encoding,proto3" json:"encoding,omitempty"`
|
||||
Encoding string `protobuf:"bytes,6,opt,name=encoding,proto3" json:"encoding,omitempty"`
|
||||
// Відбиток МАШИНИ, з якої знято набір локальних файлів: ім'я хоста
|
||||
// плюс machine-id. Заповнюється лише збором локальних файлів.
|
||||
//
|
||||
// Потрібен через те, що зонд переносний, а картка хоста — ні.
|
||||
// Посвідчення зонда — це файл; його копіюють разом із контейнером або
|
||||
// переставляють на іншу машину. Без відбитка історія сервера А
|
||||
// мовчки продовжилась би файлами сервера Б у тій самій гілці Git —
|
||||
// найгірший різновид помилки в архіві: він виглядає справним.
|
||||
//
|
||||
// Сервер закріплює відбиток за карткою при першому зборі й далі
|
||||
// відмовляє в прийомі, якщо він змінився, доки людина не підтвердить
|
||||
// прив'язку заново.
|
||||
MachineId string `protobuf:"bytes,7,opt,name=machine_id,json=machineId,proto3" json:"machine_id,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *ConfigHeader) Reset() {
|
||||
*x = ConfigHeader{}
|
||||
mi := &file_netpulse_v1_ncm_proto_msgTypes[2]
|
||||
mi := &file_netpulse_v1_ncm_proto_msgTypes[3]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
|
@ -310,7 +421,7 @@ func (x *ConfigHeader) String() string {
|
|||
func (*ConfigHeader) ProtoMessage() {}
|
||||
|
||||
func (x *ConfigHeader) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_netpulse_v1_ncm_proto_msgTypes[2]
|
||||
mi := &file_netpulse_v1_ncm_proto_msgTypes[3]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
|
|
@ -323,7 +434,7 @@ func (x *ConfigHeader) ProtoReflect() protoreflect.Message {
|
|||
|
||||
// Deprecated: Use ConfigHeader.ProtoReflect.Descriptor instead.
|
||||
func (*ConfigHeader) Descriptor() ([]byte, []int) {
|
||||
return file_netpulse_v1_ncm_proto_rawDescGZIP(), []int{2}
|
||||
return file_netpulse_v1_ncm_proto_rawDescGZIP(), []int{3}
|
||||
}
|
||||
|
||||
func (x *ConfigHeader) GetJobId() string {
|
||||
|
|
@ -368,6 +479,13 @@ func (x *ConfigHeader) GetEncoding() string {
|
|||
return ""
|
||||
}
|
||||
|
||||
func (x *ConfigHeader) GetMachineId() string {
|
||||
if x != nil {
|
||||
return x.MachineId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type ConfigChunk struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// Номер чанка з 0; сервер збирає в порядку зростання.
|
||||
|
|
@ -379,7 +497,7 @@ type ConfigChunk struct {
|
|||
|
||||
func (x *ConfigChunk) Reset() {
|
||||
*x = ConfigChunk{}
|
||||
mi := &file_netpulse_v1_ncm_proto_msgTypes[3]
|
||||
mi := &file_netpulse_v1_ncm_proto_msgTypes[4]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
|
@ -391,7 +509,7 @@ func (x *ConfigChunk) String() string {
|
|||
func (*ConfigChunk) ProtoMessage() {}
|
||||
|
||||
func (x *ConfigChunk) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_netpulse_v1_ncm_proto_msgTypes[3]
|
||||
mi := &file_netpulse_v1_ncm_proto_msgTypes[4]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
|
|
@ -404,7 +522,7 @@ func (x *ConfigChunk) ProtoReflect() protoreflect.Message {
|
|||
|
||||
// Deprecated: Use ConfigChunk.ProtoReflect.Descriptor instead.
|
||||
func (*ConfigChunk) Descriptor() ([]byte, []int) {
|
||||
return file_netpulse_v1_ncm_proto_rawDescGZIP(), []int{3}
|
||||
return file_netpulse_v1_ncm_proto_rawDescGZIP(), []int{4}
|
||||
}
|
||||
|
||||
func (x *ConfigChunk) GetSequence() uint32 {
|
||||
|
|
@ -439,7 +557,7 @@ type ConfigTrailer struct {
|
|||
|
||||
func (x *ConfigTrailer) Reset() {
|
||||
*x = ConfigTrailer{}
|
||||
mi := &file_netpulse_v1_ncm_proto_msgTypes[4]
|
||||
mi := &file_netpulse_v1_ncm_proto_msgTypes[5]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
|
@ -451,7 +569,7 @@ func (x *ConfigTrailer) String() string {
|
|||
func (*ConfigTrailer) ProtoMessage() {}
|
||||
|
||||
func (x *ConfigTrailer) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_netpulse_v1_ncm_proto_msgTypes[4]
|
||||
mi := &file_netpulse_v1_ncm_proto_msgTypes[5]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
|
|
@ -464,7 +582,7 @@ func (x *ConfigTrailer) ProtoReflect() protoreflect.Message {
|
|||
|
||||
// Deprecated: Use ConfigTrailer.ProtoReflect.Descriptor instead.
|
||||
func (*ConfigTrailer) Descriptor() ([]byte, []int) {
|
||||
return file_netpulse_v1_ncm_proto_rawDescGZIP(), []int{4}
|
||||
return file_netpulse_v1_ncm_proto_rawDescGZIP(), []int{5}
|
||||
}
|
||||
|
||||
func (x *ConfigTrailer) GetSuccess() bool {
|
||||
|
|
@ -540,7 +658,7 @@ type ConfigReceipt struct {
|
|||
|
||||
func (x *ConfigReceipt) Reset() {
|
||||
*x = ConfigReceipt{}
|
||||
mi := &file_netpulse_v1_ncm_proto_msgTypes[5]
|
||||
mi := &file_netpulse_v1_ncm_proto_msgTypes[6]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
|
@ -552,7 +670,7 @@ func (x *ConfigReceipt) String() string {
|
|||
func (*ConfigReceipt) ProtoMessage() {}
|
||||
|
||||
func (x *ConfigReceipt) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_netpulse_v1_ncm_proto_msgTypes[5]
|
||||
mi := &file_netpulse_v1_ncm_proto_msgTypes[6]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
|
|
@ -565,7 +683,7 @@ func (x *ConfigReceipt) ProtoReflect() protoreflect.Message {
|
|||
|
||||
// Deprecated: Use ConfigReceipt.ProtoReflect.Descriptor instead.
|
||||
func (*ConfigReceipt) Descriptor() ([]byte, []int) {
|
||||
return file_netpulse_v1_ncm_proto_rawDescGZIP(), []int{5}
|
||||
return file_netpulse_v1_ncm_proto_rawDescGZIP(), []int{6}
|
||||
}
|
||||
|
||||
func (x *ConfigReceipt) GetJobId() string {
|
||||
|
|
@ -635,7 +753,7 @@ type ConfigApplyJob struct {
|
|||
|
||||
func (x *ConfigApplyJob) Reset() {
|
||||
*x = ConfigApplyJob{}
|
||||
mi := &file_netpulse_v1_ncm_proto_msgTypes[6]
|
||||
mi := &file_netpulse_v1_ncm_proto_msgTypes[7]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
|
@ -647,7 +765,7 @@ func (x *ConfigApplyJob) String() string {
|
|||
func (*ConfigApplyJob) ProtoMessage() {}
|
||||
|
||||
func (x *ConfigApplyJob) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_netpulse_v1_ncm_proto_msgTypes[6]
|
||||
mi := &file_netpulse_v1_ncm_proto_msgTypes[7]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
|
|
@ -660,7 +778,7 @@ func (x *ConfigApplyJob) ProtoReflect() protoreflect.Message {
|
|||
|
||||
// Deprecated: Use ConfigApplyJob.ProtoReflect.Descriptor instead.
|
||||
func (*ConfigApplyJob) Descriptor() ([]byte, []int) {
|
||||
return file_netpulse_v1_ncm_proto_rawDescGZIP(), []int{6}
|
||||
return file_netpulse_v1_ncm_proto_rawDescGZIP(), []int{7}
|
||||
}
|
||||
|
||||
func (x *ConfigApplyJob) GetRollbackId() string {
|
||||
|
|
@ -756,7 +874,7 @@ type ConfigApplyResult struct {
|
|||
|
||||
func (x *ConfigApplyResult) Reset() {
|
||||
*x = ConfigApplyResult{}
|
||||
mi := &file_netpulse_v1_ncm_proto_msgTypes[7]
|
||||
mi := &file_netpulse_v1_ncm_proto_msgTypes[8]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
|
@ -768,7 +886,7 @@ func (x *ConfigApplyResult) String() string {
|
|||
func (*ConfigApplyResult) ProtoMessage() {}
|
||||
|
||||
func (x *ConfigApplyResult) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_netpulse_v1_ncm_proto_msgTypes[7]
|
||||
mi := &file_netpulse_v1_ncm_proto_msgTypes[8]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
|
|
@ -781,7 +899,7 @@ func (x *ConfigApplyResult) ProtoReflect() protoreflect.Message {
|
|||
|
||||
// Deprecated: Use ConfigApplyResult.ProtoReflect.Descriptor instead.
|
||||
func (*ConfigApplyResult) Descriptor() ([]byte, []int) {
|
||||
return file_netpulse_v1_ncm_proto_rawDescGZIP(), []int{7}
|
||||
return file_netpulse_v1_ncm_proto_rawDescGZIP(), []int{8}
|
||||
}
|
||||
|
||||
func (x *ConfigApplyResult) GetRollbackId() string {
|
||||
|
|
@ -846,7 +964,7 @@ type CommandOutcome struct {
|
|||
|
||||
func (x *CommandOutcome) Reset() {
|
||||
*x = CommandOutcome{}
|
||||
mi := &file_netpulse_v1_ncm_proto_msgTypes[8]
|
||||
mi := &file_netpulse_v1_ncm_proto_msgTypes[9]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
|
@ -858,7 +976,7 @@ func (x *CommandOutcome) String() string {
|
|||
func (*CommandOutcome) ProtoMessage() {}
|
||||
|
||||
func (x *CommandOutcome) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_netpulse_v1_ncm_proto_msgTypes[8]
|
||||
mi := &file_netpulse_v1_ncm_proto_msgTypes[9]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
|
|
@ -871,7 +989,7 @@ func (x *CommandOutcome) ProtoReflect() protoreflect.Message {
|
|||
|
||||
// Deprecated: Use CommandOutcome.ProtoReflect.Descriptor instead.
|
||||
func (*CommandOutcome) Descriptor() ([]byte, []int) {
|
||||
return file_netpulse_v1_ncm_proto_rawDescGZIP(), []int{8}
|
||||
return file_netpulse_v1_ncm_proto_rawDescGZIP(), []int{9}
|
||||
}
|
||||
|
||||
func (x *CommandOutcome) GetIndex() uint32 {
|
||||
|
|
@ -913,7 +1031,7 @@ var File_netpulse_v1_ncm_proto protoreflect.FileDescriptor
|
|||
|
||||
const file_netpulse_v1_ncm_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"\x15netpulse/v1/ncm.proto\x12\vnetpulse.v1\x1a\x1egoogle/protobuf/duration.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x18netpulse/v1/common.proto\"\x92\x04\n" +
|
||||
"\x15netpulse/v1/ncm.proto\x12\vnetpulse.v1\x1a\x1egoogle/protobuf/duration.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x18netpulse/v1/common.proto\"\xcf\x04\n" +
|
||||
"\tConfigJob\x12\x15\n" +
|
||||
"\x06job_id\x18\x01 \x01(\tR\x05jobId\x121\n" +
|
||||
"\x06device\x18\x02 \x01(\v2\x19.netpulse.v1.DeviceTargetR\x06device\x127\n" +
|
||||
|
|
@ -931,12 +1049,18 @@ const file_netpulse_v1_ncm_proto_rawDesc = "" +
|
|||
"configType\x123\n" +
|
||||
"\atimeout\x18\v \x01(\v2\x19.google.protobuf.DurationR\atimeout\x12\x1b\n" +
|
||||
"\tmax_bytes\x18\f \x01(\x04R\bmaxBytes\x12-\n" +
|
||||
"\x12capture_transcript\x18\r \x01(\bR\x11captureTranscript\"\xb5\x01\n" +
|
||||
"\x12capture_transcript\x18\r \x01(\bR\x11captureTranscript\x12;\n" +
|
||||
"\vlocal_files\x18\x0e \x01(\v2\x1a.netpulse.v1.LocalFilesJobR\n" +
|
||||
"localFiles\"z\n" +
|
||||
"\rLocalFilesJob\x12$\n" +
|
||||
"\x0emax_file_bytes\x18\x01 \x01(\x04R\fmaxFileBytes\x12\x1b\n" +
|
||||
"\tmax_files\x18\x02 \x01(\rR\bmaxFiles\x12&\n" +
|
||||
"\x0fmax_total_bytes\x18\x03 \x01(\x04R\rmaxTotalBytes\"\xb5\x01\n" +
|
||||
"\fConfigUpload\x123\n" +
|
||||
"\x06header\x18\x01 \x01(\v2\x19.netpulse.v1.ConfigHeaderH\x00R\x06header\x120\n" +
|
||||
"\x05chunk\x18\x02 \x01(\v2\x18.netpulse.v1.ConfigChunkH\x00R\x05chunk\x126\n" +
|
||||
"\atrailer\x18\x03 \x01(\v2\x1a.netpulse.v1.ConfigTrailerH\x00R\atrailerB\x06\n" +
|
||||
"\x04part\"\xd9\x01\n" +
|
||||
"\x04part\"\xf8\x01\n" +
|
||||
"\fConfigHeader\x12\x15\n" +
|
||||
"\x06job_id\x18\x01 \x01(\tR\x05jobId\x12\x19\n" +
|
||||
"\bagent_id\x18\x02 \x01(\tR\aagentId\x12\x1b\n" +
|
||||
|
|
@ -944,7 +1068,9 @@ const file_netpulse_v1_ncm_proto_rawDesc = "" +
|
|||
"\vconfig_type\x18\x04 \x01(\tR\n" +
|
||||
"configType\x12=\n" +
|
||||
"\fcollected_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\vcollectedAt\x12\x1a\n" +
|
||||
"\bencoding\x18\x06 \x01(\tR\bencoding\"=\n" +
|
||||
"\bencoding\x18\x06 \x01(\tR\bencoding\x12\x1d\n" +
|
||||
"\n" +
|
||||
"machine_id\x18\a \x01(\tR\tmachineId\"=\n" +
|
||||
"\vConfigChunk\x12\x1a\n" +
|
||||
"\bsequence\x18\x01 \x01(\rR\bsequence\x12\x12\n" +
|
||||
"\x04data\x18\x02 \x01(\fR\x04data\"\xb0\x02\n" +
|
||||
|
|
@ -1018,49 +1144,51 @@ func file_netpulse_v1_ncm_proto_rawDescGZIP() []byte {
|
|||
return file_netpulse_v1_ncm_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_netpulse_v1_ncm_proto_msgTypes = make([]protoimpl.MessageInfo, 9)
|
||||
var file_netpulse_v1_ncm_proto_msgTypes = make([]protoimpl.MessageInfo, 10)
|
||||
var file_netpulse_v1_ncm_proto_goTypes = []any{
|
||||
(*ConfigJob)(nil), // 0: netpulse.v1.ConfigJob
|
||||
(*ConfigUpload)(nil), // 1: netpulse.v1.ConfigUpload
|
||||
(*ConfigHeader)(nil), // 2: netpulse.v1.ConfigHeader
|
||||
(*ConfigChunk)(nil), // 3: netpulse.v1.ConfigChunk
|
||||
(*ConfigTrailer)(nil), // 4: netpulse.v1.ConfigTrailer
|
||||
(*ConfigReceipt)(nil), // 5: netpulse.v1.ConfigReceipt
|
||||
(*ConfigApplyJob)(nil), // 6: netpulse.v1.ConfigApplyJob
|
||||
(*ConfigApplyResult)(nil), // 7: netpulse.v1.ConfigApplyResult
|
||||
(*CommandOutcome)(nil), // 8: netpulse.v1.CommandOutcome
|
||||
(*DeviceTarget)(nil), // 9: netpulse.v1.DeviceTarget
|
||||
(*Credential)(nil), // 10: netpulse.v1.Credential
|
||||
(Transport)(0), // 11: netpulse.v1.Transport
|
||||
(*durationpb.Duration)(nil), // 12: google.protobuf.Duration
|
||||
(*timestamppb.Timestamp)(nil), // 13: google.protobuf.Timestamp
|
||||
(*Error)(nil), // 14: netpulse.v1.Error
|
||||
(*LocalFilesJob)(nil), // 1: netpulse.v1.LocalFilesJob
|
||||
(*ConfigUpload)(nil), // 2: netpulse.v1.ConfigUpload
|
||||
(*ConfigHeader)(nil), // 3: netpulse.v1.ConfigHeader
|
||||
(*ConfigChunk)(nil), // 4: netpulse.v1.ConfigChunk
|
||||
(*ConfigTrailer)(nil), // 5: netpulse.v1.ConfigTrailer
|
||||
(*ConfigReceipt)(nil), // 6: netpulse.v1.ConfigReceipt
|
||||
(*ConfigApplyJob)(nil), // 7: netpulse.v1.ConfigApplyJob
|
||||
(*ConfigApplyResult)(nil), // 8: netpulse.v1.ConfigApplyResult
|
||||
(*CommandOutcome)(nil), // 9: netpulse.v1.CommandOutcome
|
||||
(*DeviceTarget)(nil), // 10: netpulse.v1.DeviceTarget
|
||||
(*Credential)(nil), // 11: netpulse.v1.Credential
|
||||
(Transport)(0), // 12: netpulse.v1.Transport
|
||||
(*durationpb.Duration)(nil), // 13: google.protobuf.Duration
|
||||
(*timestamppb.Timestamp)(nil), // 14: google.protobuf.Timestamp
|
||||
(*Error)(nil), // 15: netpulse.v1.Error
|
||||
}
|
||||
var file_netpulse_v1_ncm_proto_depIdxs = []int32{
|
||||
9, // 0: netpulse.v1.ConfigJob.device:type_name -> netpulse.v1.DeviceTarget
|
||||
10, // 1: netpulse.v1.ConfigJob.credential:type_name -> netpulse.v1.Credential
|
||||
11, // 2: netpulse.v1.ConfigJob.transport:type_name -> netpulse.v1.Transport
|
||||
12, // 3: netpulse.v1.ConfigJob.timeout:type_name -> google.protobuf.Duration
|
||||
2, // 4: netpulse.v1.ConfigUpload.header:type_name -> netpulse.v1.ConfigHeader
|
||||
3, // 5: netpulse.v1.ConfigUpload.chunk:type_name -> netpulse.v1.ConfigChunk
|
||||
4, // 6: netpulse.v1.ConfigUpload.trailer:type_name -> netpulse.v1.ConfigTrailer
|
||||
13, // 7: netpulse.v1.ConfigHeader.collected_at:type_name -> google.protobuf.Timestamp
|
||||
14, // 8: netpulse.v1.ConfigTrailer.error:type_name -> netpulse.v1.Error
|
||||
12, // 9: netpulse.v1.ConfigTrailer.duration:type_name -> google.protobuf.Duration
|
||||
14, // 10: netpulse.v1.ConfigReceipt.error:type_name -> netpulse.v1.Error
|
||||
9, // 11: netpulse.v1.ConfigApplyJob.device:type_name -> netpulse.v1.DeviceTarget
|
||||
10, // 12: netpulse.v1.ConfigApplyJob.credential:type_name -> netpulse.v1.Credential
|
||||
11, // 13: netpulse.v1.ConfigApplyJob.transport:type_name -> netpulse.v1.Transport
|
||||
12, // 14: netpulse.v1.ConfigApplyJob.confirm_timeout:type_name -> google.protobuf.Duration
|
||||
12, // 15: netpulse.v1.ConfigApplyJob.timeout:type_name -> google.protobuf.Duration
|
||||
8, // 16: netpulse.v1.ConfigApplyResult.outcomes:type_name -> netpulse.v1.CommandOutcome
|
||||
14, // 17: netpulse.v1.ConfigApplyResult.error:type_name -> netpulse.v1.Error
|
||||
12, // 18: netpulse.v1.ConfigApplyResult.duration:type_name -> google.protobuf.Duration
|
||||
19, // [19:19] is the sub-list for method output_type
|
||||
19, // [19:19] is the sub-list for method input_type
|
||||
19, // [19:19] is the sub-list for extension type_name
|
||||
19, // [19:19] is the sub-list for extension extendee
|
||||
0, // [0:19] is the sub-list for field type_name
|
||||
10, // 0: netpulse.v1.ConfigJob.device:type_name -> netpulse.v1.DeviceTarget
|
||||
11, // 1: netpulse.v1.ConfigJob.credential:type_name -> netpulse.v1.Credential
|
||||
12, // 2: netpulse.v1.ConfigJob.transport:type_name -> netpulse.v1.Transport
|
||||
13, // 3: netpulse.v1.ConfigJob.timeout:type_name -> google.protobuf.Duration
|
||||
1, // 4: netpulse.v1.ConfigJob.local_files:type_name -> netpulse.v1.LocalFilesJob
|
||||
3, // 5: netpulse.v1.ConfigUpload.header:type_name -> netpulse.v1.ConfigHeader
|
||||
4, // 6: netpulse.v1.ConfigUpload.chunk:type_name -> netpulse.v1.ConfigChunk
|
||||
5, // 7: netpulse.v1.ConfigUpload.trailer:type_name -> netpulse.v1.ConfigTrailer
|
||||
14, // 8: netpulse.v1.ConfigHeader.collected_at:type_name -> google.protobuf.Timestamp
|
||||
15, // 9: netpulse.v1.ConfigTrailer.error:type_name -> netpulse.v1.Error
|
||||
13, // 10: netpulse.v1.ConfigTrailer.duration:type_name -> google.protobuf.Duration
|
||||
15, // 11: netpulse.v1.ConfigReceipt.error:type_name -> netpulse.v1.Error
|
||||
10, // 12: netpulse.v1.ConfigApplyJob.device:type_name -> netpulse.v1.DeviceTarget
|
||||
11, // 13: netpulse.v1.ConfigApplyJob.credential:type_name -> netpulse.v1.Credential
|
||||
12, // 14: netpulse.v1.ConfigApplyJob.transport:type_name -> netpulse.v1.Transport
|
||||
13, // 15: netpulse.v1.ConfigApplyJob.confirm_timeout:type_name -> google.protobuf.Duration
|
||||
13, // 16: netpulse.v1.ConfigApplyJob.timeout:type_name -> google.protobuf.Duration
|
||||
9, // 17: netpulse.v1.ConfigApplyResult.outcomes:type_name -> netpulse.v1.CommandOutcome
|
||||
15, // 18: netpulse.v1.ConfigApplyResult.error:type_name -> netpulse.v1.Error
|
||||
13, // 19: netpulse.v1.ConfigApplyResult.duration:type_name -> google.protobuf.Duration
|
||||
20, // [20:20] is the sub-list for method output_type
|
||||
20, // [20:20] is the sub-list for method input_type
|
||||
20, // [20:20] is the sub-list for extension type_name
|
||||
20, // [20:20] is the sub-list for extension extendee
|
||||
0, // [0:20] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_netpulse_v1_ncm_proto_init() }
|
||||
|
|
@ -1069,7 +1197,7 @@ func file_netpulse_v1_ncm_proto_init() {
|
|||
return
|
||||
}
|
||||
file_netpulse_v1_common_proto_init()
|
||||
file_netpulse_v1_ncm_proto_msgTypes[1].OneofWrappers = []any{
|
||||
file_netpulse_v1_ncm_proto_msgTypes[2].OneofWrappers = []any{
|
||||
(*ConfigUpload_Header)(nil),
|
||||
(*ConfigUpload_Chunk)(nil),
|
||||
(*ConfigUpload_Trailer)(nil),
|
||||
|
|
@ -1080,7 +1208,7 @@ func file_netpulse_v1_ncm_proto_init() {
|
|||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_netpulse_v1_ncm_proto_rawDesc), len(file_netpulse_v1_ncm_proto_rawDesc)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 9,
|
||||
NumMessages: 10,
|
||||
NumExtensions: 0,
|
||||
NumServices: 0,
|
||||
},
|
||||
|
|
|
|||
123
netpulse.conf.example
Normal file
123
netpulse.conf.example
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
# NetPulse — файл відповідей.
|
||||
#
|
||||
# Тут лежить рівно те, чого установник не може вирішити за вас: п'ять
|
||||
# значень. Решта — секрети, які людині не можна давати вводити руками
|
||||
# (секрет, який пропонують придумати, виявляється admin123), і
|
||||
# налаштування, у яких є одне правильне значення або значення, що
|
||||
# рахується з машини.
|
||||
#
|
||||
# Файл НЕ обов'язковий. `./netpulse install` без нього ставить робочу
|
||||
# систему на самопідписаному сертифікаті, без прийому трапів ззовні, з
|
||||
# помірними строками зберігання й поясом хоста. Кожен рядок нижче
|
||||
# каже, що буде, якщо його не чіпати.
|
||||
#
|
||||
# cp netpulse.conf.example netpulse.conf
|
||||
# ./netpulse install
|
||||
#
|
||||
# Синтаксис: КЛЮЧ=значення, без лапок, без пробілів навколо «=».
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# DOMAIN — доменне ім'я, на яке дивиться A-запис цього сервера.
|
||||
# ---------------------------------------------------------------------
|
||||
#
|
||||
# Це рішення людини, бо ані сервер, ані установник не знають, що написано
|
||||
# в чужій зоні DNS. Перевірити «чи веде домен сюди» до першого запуску
|
||||
# теж не можна: запис міг з'явитись хвилину тому й ще не розійтися.
|
||||
#
|
||||
# ПОРОЖНЬО: адресою системи стає IP цієї машини, а сертифікат —
|
||||
# самопідписаний. Працює одразу, але браузер щоразу лається, і зонди
|
||||
# доводиться запускати з -insecure. Let's Encrypt тут ні до чого: він не
|
||||
# видає сертифікатів на IP-адреси взагалі, тому «залишити порожнім і
|
||||
# сподіватись на справжній сертифікат» неможливо за побудовою.
|
||||
#
|
||||
# Домен можна вписати пізніше: змінити рядок і повторити
|
||||
# `./netpulse install` — переустановлення нічого не ламає.
|
||||
DOMAIN=
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# ADMIN_EMAIL — пошта для Let's Encrypt.
|
||||
# ---------------------------------------------------------------------
|
||||
#
|
||||
# На неї приходить лист, коли продовження сертифіката ламається. Це
|
||||
# єдиний канал, яким про це дізнаються ДО того, як сайт перестане
|
||||
# відкриватись, — тому адреса має бути та, яку читають, а не та, що
|
||||
# гарно виглядає.
|
||||
#
|
||||
# ПОРОЖНЬО при заданому DOMAIN: сертифікат усе одно береться, але
|
||||
# попередження про проблеми з продовженням нікуди не піде.
|
||||
#
|
||||
# Без DOMAIN не потрібна зовсім: сертифікат самопідписаний, продовжувати
|
||||
# нічого й нікому скаржитись.
|
||||
ADMIN_EMAIL=
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# TRAPS_FROM — від кого приймати SNMP-трапи (порт 162/udp).
|
||||
# ---------------------------------------------------------------------
|
||||
#
|
||||
# Це рішення, а не налаштування: 162/udp не має автентифікації взагалі.
|
||||
# Хто знає адресу — той шле, і система це запише. На машині з публічним
|
||||
# інтерфейсом «слухати всюди» означає віддати чужим людям право
|
||||
# наповнювати вашу базу подіями.
|
||||
#
|
||||
# ПОРОЖНЬО: трапи слухаються лише на 127.0.0.1. Ззовні не приймається
|
||||
# нічого. Решта моніторингу (ICMP, SNMP-опитування, збір конфігів) від
|
||||
# цього не залежить — зонди самі ходять до заліза.
|
||||
#
|
||||
# АДРЕСА цієї машини (наприклад 10.0.0.5): порт піднімається лише на
|
||||
# цьому інтерфейсі. Найдешевший спосіб пустити трапи з внутрішньої
|
||||
# мережі й не пустити з інтернету — якщо інтерфейси різні.
|
||||
#
|
||||
# ПІДМЕРЕЖА (наприклад 10.0.0.0/24) або чужа адреса: порт піднімається
|
||||
# на всіх інтерфейсах, а джерело обмежується правилом у ланцюжку
|
||||
# DOCKER-USER. Потребує iptables на хості; якщо його немає, установник
|
||||
# ЗУПИНИТЬСЯ, а не відкриє порт усім мовчки. Правило не переживає
|
||||
# перезавантаження саме собою — установник скаже, що з цим робити.
|
||||
#
|
||||
# any: слухати всюди, приймати від будь-кого. Установник це зробить і
|
||||
# гучно скаже, що саме ви щойно дозволили.
|
||||
TRAPS_FROM=
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# RETENTION — скільки тримати дані.
|
||||
# ---------------------------------------------------------------------
|
||||
#
|
||||
# Рішення організації, а не властивість збірки: «ми маємо бачити
|
||||
# півроку» — це вимога до вас, а не до продукту. Установник не має права
|
||||
# вгадати її, бо помилка в один бік коштує диска, а в другий — історії,
|
||||
# заради якої систему й ставили.
|
||||
#
|
||||
# Строк — рівня інсталяції, не кабінету: TimescaleDB видаляє дані цілими
|
||||
# чанками, а чанк ріжеться за часом і про кабінет не знає нічого
|
||||
# (пояснення — у міграції 0064). Розкладку профілю по видах даних видно
|
||||
# на сторінці «Сховище» й там же правиться поштучно.
|
||||
#
|
||||
# economy — метрики 7 діб, годинні агрегати півроку, syslog тиждень.
|
||||
# Для диска на 50 ГБ і кількох сотень хостів.
|
||||
# normal — метрики 35 діб, годинні агрегати два роки, аудит рік.
|
||||
# Те, що люди мають на увазі під «як зазвичай».
|
||||
# archive — метрики 90 діб, годинні агрегати п'ять років, аудит п'ять
|
||||
# років. Коли історію вимагає регламент. Диск рахуйте
|
||||
# заздалегідь: це в кілька разів більше за normal.
|
||||
#
|
||||
# ПОРОЖНЬО: normal.
|
||||
#
|
||||
# Профіль накладається лише на ПЕРШІЙ установці. Повторний запуск
|
||||
# `install` не чіпає строки: до того часу їх уже могли поправити руками,
|
||||
# і мовчки повернути наш профіль означало б знищити дані за чужим
|
||||
# рішенням.
|
||||
RETENTION=normal
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# TZ — часовий пояс.
|
||||
# ---------------------------------------------------------------------
|
||||
#
|
||||
# У ньому рахуються розклади бекапів конфігів, вікна тиші для сповіщень
|
||||
# і підписи часу на графіках. Пояс хоста тут не завжди правильна
|
||||
# відповідь: сервер у чужому дата-центрі цілком може стояти в UTC, а
|
||||
# «о третій ночі» для вимкнення сповіщень означає третю ночі там, де
|
||||
# сидять чергові.
|
||||
#
|
||||
# ПОРОЖНЬО: береться пояс хоста, а якщо визначити не вдалось —
|
||||
# Europe/Kyiv.
|
||||
TZ=Europe/Kyiv
|
||||
|
|
@ -51,6 +51,48 @@ message ConfigJob {
|
|||
uint64 max_bytes = 12;
|
||||
// Записувати повний транскрипт сесії (для діагностики prompt_regex).
|
||||
bool capture_transcript = 13;
|
||||
|
||||
// Заповнене поле перемикає завдання з «зайди по SSH на пристрій» на
|
||||
// «прочитай конфіг-файли машини, де ти сам стоїш». Тоді transport,
|
||||
// credential, commands і prompt_regex не діють: сесії до пристрою
|
||||
// немає взагалі.
|
||||
//
|
||||
// Окреме поле, а не ще одне значення config_type: config_type
|
||||
// потрапляє в архів як назва зрізу, і навантажувати його ще й
|
||||
// роллю «яким способом збирати» означало б, що зміна назви зрізу
|
||||
// тихо змінює спосіб збору.
|
||||
LocalFilesJob local_files = 14;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Збір конфіг-файлів САМОЇ машини, на якій працює зонд
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// LocalFilesJob — прохання зібрати локальні конфіг-файли.
|
||||
//
|
||||
// НАЙВАЖЛИВІШЕ В ЦЬОМУ ПОВІДОМЛЕННІ — ЧОГО В НЬОМУ НЕМАЄ.
|
||||
//
|
||||
// Тут немає й не буде поля зі шляхом, маскою чи каталогом. Зонд стоїть
|
||||
// на сервері й читає файли його правами; поле «які саме файли читати»,
|
||||
// кероване з кабінету, означало б віддалене читання довільного файлу на
|
||||
// КОЖНІЙ машині клієнта для будь-кого з правом ncm:write — /etc/shadow,
|
||||
// приватні ключі, .env із паролем бази, і одразу з усіх серверів.
|
||||
//
|
||||
// Тому перелік файлів живе ВИКЛЮЧНО в локальному конфізі зонда
|
||||
// (agent/internal/localfiles), який редагує адміністратор тієї самої
|
||||
// машини. Сервер може попросити зібрати — і не може сказати, що саме.
|
||||
// Наслідок: щоб дістати цим шляхом /etc/shadow, треба вже мати root на
|
||||
// тому сервері; а маючи його, NetPulse для цього не потрібен.
|
||||
//
|
||||
// Поля нижче вміють лише ЗВУЖУВАТИ дозволене машиною: зонд бере мінімум
|
||||
// зі свого й серверного значення. Нуль означає «не звужую».
|
||||
message LocalFilesJob {
|
||||
// Стеля на один файл.
|
||||
uint64 max_file_bytes = 1;
|
||||
// Скільки файлів щонайбільше взяти в один набір.
|
||||
uint32 max_files = 2;
|
||||
// Стеля на весь набір разом.
|
||||
uint64 max_total_bytes = 3;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
|
|
@ -77,6 +119,20 @@ message ConfigHeader {
|
|||
// gzip | none — агент стискає, бо конфіги добре жмуться,
|
||||
// а канал може бути вузьким.
|
||||
string encoding = 6;
|
||||
|
||||
// Відбиток МАШИНИ, з якої знято набір локальних файлів: ім'я хоста
|
||||
// плюс machine-id. Заповнюється лише збором локальних файлів.
|
||||
//
|
||||
// Потрібен через те, що зонд переносний, а картка хоста — ні.
|
||||
// Посвідчення зонда — це файл; його копіюють разом із контейнером або
|
||||
// переставляють на іншу машину. Без відбитка історія сервера А
|
||||
// мовчки продовжилась би файлами сервера Б у тій самій гілці Git —
|
||||
// найгірший різновид помилки в архіві: він виглядає справним.
|
||||
//
|
||||
// Сервер закріплює відбиток за карткою при першому зборі й далі
|
||||
// відмовляє в прийомі, якщо він змінився, доки людина не підтвердить
|
||||
// прив'язку заново.
|
||||
string machine_id = 7;
|
||||
}
|
||||
|
||||
message ConfigChunk {
|
||||
|
|
|
|||
100
scripts/check-eol.sh
Normal file
100
scripts/check-eol.sh
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
#!/usr/bin/env sh
|
||||
#
|
||||
# Закінчення рядків: чи не заліз у репозиторій CRLF.
|
||||
#
|
||||
# Навіщо окрема перевірка. За один день CRLF зламав дві різні речі, і
|
||||
# жодну з них не видно в diff:
|
||||
#
|
||||
# 1. gofmt почав друкувати як «не відформатовані» файли, яких ніхто не
|
||||
# чіпав: він вважає \r частиною рядка й пропонує переписати файл
|
||||
# цілком. Людина бачить червоне на чужому коді, і найкоротший вихід
|
||||
# із цього — `gofmt -w` — комітить зіпсовані переноси всім.
|
||||
# 2. db/profiles/build.py, запущений на Windows у текстовому режимі,
|
||||
# переписав згенеровану міграцію 0014 з \r\n. Байти інші → sha256
|
||||
# інший, а netpulse-migrate звіряє суму вже застосованих міграцій і
|
||||
# зупиняє накат словами «міграцію змінено після застосування».
|
||||
# Тобто невидима зміна зупинила б оновлення на всіх стендах одразу.
|
||||
# Порівняння `build.py --check` цього НЕ ловить: Python читає
|
||||
# текстом і сам перекладає \r\n у \n ще до порівняння.
|
||||
#
|
||||
# Перевіряються дві різні речі, і ціна в них різна:
|
||||
#
|
||||
# ІНДЕКС (i/crlf) — те, що дістанеться всім і поїде на стенд. Це
|
||||
# помилка: скрипт падає. У CI це і є сторож.
|
||||
# РОБОЧА КОПІЯ (w/crlf) — біда однієї машини. У CI її не буває, бо
|
||||
# checkout свіжий; локально ж це рівно та причина,
|
||||
# через яку gofmt червоніє на чужому. Тому тут
|
||||
# попередження з підказкою, а з --strict (як у CI)
|
||||
# — теж падіння: у свіжому клоні на Linux такого
|
||||
# бути не може, і якщо воно є, зламаний checkout.
|
||||
#
|
||||
# .gitattributes оголошує «у репозиторії — завжди LF». Файли, яким LF не
|
||||
# призначено (*.ps1 з eol=crlf, двійкові), з перевірки виключені: у них
|
||||
# CRLF — не порушення, а оголошена норма.
|
||||
#
|
||||
# Запуск:
|
||||
# sh scripts/check-eol.sh # індекс — жорстко, робоча копія — попередження
|
||||
# sh scripts/check-eol.sh --strict # обидва жорстко (так робить CI)
|
||||
#
|
||||
# Полагодити:
|
||||
# індекс: git add --renormalize . && git commit
|
||||
# робоча копія: git config core.autocrlf false
|
||||
# git rm --cached -r . && git reset --hard
|
||||
|
||||
set -u
|
||||
|
||||
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
cd "$ROOT" || exit 1
|
||||
|
||||
STRICT=0
|
||||
if [ "${1:-}" = "--strict" ]; then
|
||||
STRICT=1
|
||||
fi
|
||||
|
||||
# `git ls-files --eol` друкує «i/xx w/xx attr/… <TAB> шлях»: розділювач
|
||||
# перед шляхом — саме табуляція, тому шляхи з пробілами не розсипаються.
|
||||
list=$(git ls-files --eol) || {
|
||||
echo "check-eol: git ls-files не спрацював — це взагалі репозиторій?" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
idx=$(printf '%s\n' "$list" | awk -F'\t' '
|
||||
{ split($1, c, " ")
|
||||
if (c[1] == "i/crlf" || c[1] == "i/mixed") print $2 }')
|
||||
|
||||
# eol=crlf у атрибутах означає «цьому файлу CRLF призначено навмисно»
|
||||
# (.gitattributes: *.ps1). Такий файл у робочій копії з CRLF — норма.
|
||||
wt=$(printf '%s\n' "$list" | awk -F'\t' '
|
||||
{ split($1, c, " ")
|
||||
if ((c[2] == "w/crlf" || c[2] == "w/mixed") && $1 !~ /eol=crlf/) print $2 }')
|
||||
|
||||
rc=0
|
||||
|
||||
if [ -n "$idx" ]; then
|
||||
echo "CRLF в ІНДЕКСІ — це поїде всім і зупинить накат міграцій:"
|
||||
printf '%s\n' "$idx" | sed 's/^/ /'
|
||||
echo " полагодити: git add --renormalize . && git commit"
|
||||
rc=1
|
||||
fi
|
||||
|
||||
if [ -n "$wt" ]; then
|
||||
n=$(printf '%s\n' "$wt" | wc -l | tr -d ' ')
|
||||
if [ "$STRICT" = "1" ]; then
|
||||
echo "CRLF у РОБОЧІЙ КОПІЇ ($n): у свіжому клоні на Linux такого бути не може."
|
||||
printf '%s\n' "$wt" | sed 's/^/ /'
|
||||
rc=1
|
||||
else
|
||||
echo "увага: CRLF у робочій копії ($n файлів) — в індексі при цьому LF."
|
||||
echo " саме через це gofmt може показувати неформатованими файли,"
|
||||
echo " яких ніхто не чіпав, а генератори — переписувати цілі файли."
|
||||
printf '%s\n' "$wt" | sed 's/^/ /'
|
||||
echo " полагодити: git config core.autocrlf false"
|
||||
echo " git rm --cached -r . && git reset --hard"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$rc" = "0" ] && [ -z "$wt" ]; then
|
||||
echo "закінчення рядків у порядку"
|
||||
fi
|
||||
|
||||
exit "$rc"
|
||||
138
scripts/check.sh
Normal file
138
scripts/check.sh
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
#!/usr/bin/env sh
|
||||
#
|
||||
# Усі перевірки NetPulse однією командою.
|
||||
#
|
||||
# Навіщо окремий скрипт, коли є .forgejo/workflows/ci.yml: раннера
|
||||
# Forgejo досі немає, тобто CI не запускався жодного разу. Перевірка, яку
|
||||
# нікому виконати, не ловить нічого — а та сама послідовність, запущена
|
||||
# руками перед комітом, ловить уже сьогодні. Коли раннер зʼявиться, CI
|
||||
# викликатиме ті самі кроки; розходитись їм нема з чого.
|
||||
#
|
||||
# Запуск:
|
||||
# sh scripts/check.sh # усе
|
||||
# sh scripts/check.sh web # лише веб
|
||||
# sh scripts/check.sh go # лише server + agent
|
||||
# sh scripts/check.sh repo # лише перевірки репозиторію
|
||||
#
|
||||
# На Windows — з Git Bash; PowerShell цей файл не виконує.
|
||||
#
|
||||
# Скрипт НЕ зупиняється на першій невдачі навмисно: інакше, щоб
|
||||
# побачити три поламані місця, довелось би прогнати його тричі.
|
||||
# Ненульовий код виходу все одно повертається — рівно для гачка
|
||||
# pre-push і для CI.
|
||||
|
||||
set -u
|
||||
|
||||
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
cd "$ROOT" || exit 1
|
||||
|
||||
FAILED=""
|
||||
PASSED=""
|
||||
|
||||
# Прогін одного кроку. Назва потрібна для підсумку: список «що саме
|
||||
# впало» у кінці цінніший за чотириста рядків виводу вище.
|
||||
step() {
|
||||
name=$1
|
||||
shift
|
||||
printf '\n\033[1m== %s\033[0m\n' "$name"
|
||||
if "$@"; then
|
||||
PASSED="$PASSED|$name"
|
||||
else
|
||||
FAILED="$FAILED|$name"
|
||||
fi
|
||||
}
|
||||
|
||||
# gofmt -l друкує список файлів, а не код виходу: без явної перевірки
|
||||
# порожнечі крива форма проїжджає в main непоміченою.
|
||||
gofmt_check() {
|
||||
bad=$(gofmt -l "$1")
|
||||
if [ -n "$bad" ]; then
|
||||
echo "не відформатовано (виправити: gofmt -w $1):"
|
||||
echo "$bad"
|
||||
# На Windows цей крок червоніє ще й від CRLF у робочій копії: gofmt
|
||||
# вважає \r частиною рядка й пропонує переписати файл цілком. У
|
||||
# репозиторії лежить LF (див. .gitattributes), тож на Linux-раннері
|
||||
# той самий файл зелений. Різницю треба назвати вголос: інакше її
|
||||
# «виправлять» через gofmt -w і закомітять зіпсовані переноси.
|
||||
echo "якщо різниця в цілому файлі — це CRLF у робочій копії:"
|
||||
echo " git ls-files --eol $1 | git add --renormalize ."
|
||||
return 1
|
||||
fi
|
||||
echo "форма в порядку"
|
||||
}
|
||||
|
||||
# Зонд їде на чуже залізо: перевіряємо, що збирається під усі платформи,
|
||||
# які обіцяємо, а не лише під ту, де запустили скрипт.
|
||||
agent_cross() {
|
||||
out=${TMPDIR:-/tmp}/netpulse-crossbuild
|
||||
mkdir -p "$out" || return 1
|
||||
for target in linux/amd64 linux/arm64 linux/arm windows/amd64 darwin/arm64; do
|
||||
os=${target%/*}
|
||||
arch=${target#*/}
|
||||
echo "-- $os/$arch"
|
||||
( cd agent && CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" go build -trimpath \
|
||||
-o "$out/netpulse-agent-$os-$arch" ./cmd/netpulse-agent ) || return 1
|
||||
done
|
||||
rm -rf "$out"
|
||||
}
|
||||
|
||||
go_in() { ( cd "$1" && shift && "$@" ); }
|
||||
|
||||
check_go() {
|
||||
step "server: gofmt" gofmt_check server
|
||||
step "server: vet" go_in server go vet ./...
|
||||
# Тести проти бази самі пропускаються без NETPULSE_TEST_DSN — локальний
|
||||
# прогін не має ні піднімати Postgres, ні тим паче чіпати бойову базу.
|
||||
step "server: тести" go_in server go test ./...
|
||||
step "agent: gofmt" gofmt_check agent
|
||||
step "agent: vet" go_in agent go vet ./...
|
||||
step "agent: тести" go_in agent go test ./...
|
||||
step "agent: крос-збірка" agent_cross
|
||||
}
|
||||
|
||||
# Перевірки самого репозиторію — не коду, а того, як він лежить.
|
||||
#
|
||||
# Обидві ловлять поломки, яких не видно в diff і які проявляються вже на
|
||||
# стенді: розходження каталогу профілів зі згенерованою міграцією і CRLF,
|
||||
# що змінює sha256 тієї ж міграції. Чому саме так — у шапках
|
||||
# scripts/check-eol.sh і db/profiles/build.py.
|
||||
#
|
||||
# Тут вони без --strict: у робочій копії на Windows CRLF цілком буває, і
|
||||
# це попередження, а не привід не дати закомітити. У CI той самий скрипт
|
||||
# викликається з --strict — там свіжий клон на Linux, і CRLF означав би
|
||||
# зламане викачування.
|
||||
check_repo() {
|
||||
step "репозиторій: закінчення рядків" sh scripts/check-eol.sh
|
||||
# python3 на Linux, python у Git Bash на Windows: різні імена одного
|
||||
# й того самого, і вгадувати не треба — беремо той, що є.
|
||||
py=python3
|
||||
command -v python3 >/dev/null 2>&1 || py=python
|
||||
step "репозиторій: каталог профілів" "$py" db/profiles/build.py --check
|
||||
}
|
||||
|
||||
check_web() {
|
||||
# npm ci лише коли залежностей немає: на кожному прогоні він викидає
|
||||
# node_modules і ставить наново — хвилини замість секунд.
|
||||
if [ ! -d web/node_modules ]; then
|
||||
step "web: залежності" go_in web npm ci
|
||||
fi
|
||||
step "web: типи" go_in web npx tsc --noEmit
|
||||
step "web: тести" go_in web npx vitest run
|
||||
step "web: збірка" go_in web npx vite build
|
||||
}
|
||||
|
||||
case "${1:-all}" in
|
||||
web) check_web ;;
|
||||
go) check_go ;;
|
||||
repo) check_repo ;;
|
||||
all) check_repo; check_go; check_web ;;
|
||||
*) echo "невідомий набір: $1 (є: all, repo, go, web)"; exit 2 ;;
|
||||
esac
|
||||
|
||||
printf '\n\033[1m== підсумок\033[0m\n'
|
||||
echo "$PASSED" | tr '|' '\n' | sed '/^$/d' | sed 's/^/ ok /'
|
||||
if [ -n "$FAILED" ]; then
|
||||
echo "$FAILED" | tr '|' '\n' | sed '/^$/d' | sed 's/^/ ВПАЛО /'
|
||||
exit 1
|
||||
fi
|
||||
echo "усе зелене"
|
||||
|
|
@ -80,10 +80,14 @@ JWT — ні.
|
|||
| `DELETE` | `/api/v1/maps/{id}` | м'яко видалити мапу (топологія лишається) |
|
||||
| `POST` | `/api/v1/maps/{id}/build` | добудувати мапу з виявленої топології |
|
||||
| `POST` | `/api/v1/maps/{id}/undo` | відкотити останню зміну полотна |
|
||||
| `GET` | `/api/v1/devices` | інвентар (звужений групами доступу) |
|
||||
| `GET` | `/api/v1/devices` | інвентар (звужений групами доступу); `?view=archived` — навпаки, ЛИШЕ прибрані хости |
|
||||
| `POST` | `/api/v1/devices` | додати хост (`devices:write`) |
|
||||
| `PATCH` | `/api/v1/devices/{id}` | змінити хост і його групи |
|
||||
| `DELETE` | `/api/v1/devices/{id}` | мʼяко видалити хост |
|
||||
| `POST` | `/api/v1/devices/bulk-targets` | що саме зачепить масова дія (`mode`: archive/purge/restore) |
|
||||
| `POST` | `/api/v1/devices/bulk-update` | масова правка хостів |
|
||||
| `POST` | `/api/v1/devices/bulk-delete` | масове видалення: `mode=archive` (в архів) або `mode=purge` (назавжди, з `ncm:delete` за наявності конфігів) |
|
||||
| `POST` | `/api/v1/devices/bulk-restore` | повернути хости з архіву (`devices:write`) |
|
||||
| `GET` | `/api/v1/check-types` | що система вміє опитувати |
|
||||
| `GET` | `/api/v1/devices/{id}/checks` | перевірки хоста |
|
||||
| `PUT` | `/api/v1/devices/{id}/checks` | замінити набір перевірок |
|
||||
|
|
@ -109,6 +113,15 @@ JWT — ні.
|
|||
| `POST` | `/api/v1/channels` | створити канал (`alerts:write`) |
|
||||
| `POST` | `/api/v1/channels/{id}/test` | пробне повідомлення (`alerts:write`) |
|
||||
| `DELETE` | `/api/v1/channels/{id}` | видалити канал (`alerts:write`) |
|
||||
| `GET` | `/api/v1/escalation-policies` | драбини ескалації |
|
||||
| `POST` | `/api/v1/escalation-policies` | створити драбину (`alerts:write`) |
|
||||
| `PUT` | `/api/v1/escalation-policies/{id}` | замінити драбину цілком (`alerts:write`) |
|
||||
| `DELETE` | `/api/v1/escalation-policies/{id}` | видалити драбину (`alerts:write`) |
|
||||
| `GET` | `/api/v1/storage` | розміри даних, приріст за добу й запас місця |
|
||||
| `PUT` | `/api/v1/storage/config` | ємність тому під базу (`settings:write`) |
|
||||
| `GET` | `/api/v1/storage/retention` | строки зберігання за видами даних |
|
||||
| `POST` | `/api/v1/storage/retention/preview` | **що зникне** від запропонованих строків (`settings:write`) |
|
||||
| `PUT` | `/api/v1/storage/retention` | зберегти строки й накласти політики (`settings:write`) |
|
||||
| `GET` | `/api/v1/ws` | WebSocket: події та завантаження каналів |
|
||||
|
||||
### `POST /api/v1/auth/login` — вхід
|
||||
|
|
@ -1024,6 +1037,60 @@ JSON у таблиці правил.
|
|||
|
||||
`PUT /api/v1/alert-rules/{id}` замінює правило цілком.
|
||||
|
||||
### Ескалація
|
||||
|
||||
Сповіщення, надіслане один раз, нічого не гарантує: черговий може спати.
|
||||
Драбина ескалації відповідає на питання «а якщо ніхто не прочитав» —
|
||||
через N хвилин мовчання піднімається наступний за списком.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"name": "Нічне чергування",
|
||||
"steps": [ // after_min рахується від ПОЧАТКУ алерту
|
||||
{"after_min": 15, "channel_ids": ["…черговий"]},
|
||||
{"after_min": 45, "channel_ids": ["…керівник зміни"]}
|
||||
],
|
||||
"repeat_after_min": 60, // 0 — не повторювати драбину
|
||||
"max_repeats": 2
|
||||
}
|
||||
```
|
||||
|
||||
Драбина прив'язується **до правила** (`escalation_policy_id` у тілі
|
||||
правила). Порожньо — без ескалації, і це типове значення: після
|
||||
оновлення жоден кабінет не починає будити людей сам собою. Прив'язка до
|
||||
серйозності дала б одну драбину на всі `high` у кабінеті (а `high` на
|
||||
тестовому комутаторі й на ядрі — різні люди), прив'язка до групи хостів
|
||||
— однакову драбину для «завантаження порту» й «пристрій не відповідає».
|
||||
|
||||
Правила проходження:
|
||||
|
||||
- **Стан алерту перевіряється перед КОЖНОЮ сходинкою**, а не один раз при
|
||||
взведенні. Підтверджений або закритий алерт зупиняє драбину — ескалація
|
||||
не воскрешає мертве.
|
||||
- **Заглушення й вікно обслуговування сходинку не витрачають**, а
|
||||
відкладають: вікно на пів години інакше тихо роззброїло б драбину до
|
||||
кінця життя алерту. Відкладання обмежене стелею життя драбини.
|
||||
- **Драбина взводиться лише тоді, коли перше сповіщення справді пішло.**
|
||||
Якщо каналів не знайшлось (тиха година, поріг серйозності, вимкнений
|
||||
канал), ескалації не буде: інакше о 15-й хвилині пішло б те, що на
|
||||
нульовій свідомо не надсилали.
|
||||
- **Подієві алерти** (`syslog`, `ncm`, `compliance`) проходять драбину
|
||||
один раз, без повторів. Повтор — це ставка на те, що проблема триває, а
|
||||
її можна робити лише там, де існування алерту саме по собі є доказом:
|
||||
метричний алерт зникає, щойно умова перестала виконуватись, подієвий —
|
||||
ні.
|
||||
- Стан драбини живе в базі (`alr.alert_escalations`), а рішення пишеться
|
||||
до надсилання. Перезапуск процесу посеред драбини не подвоює сходинку;
|
||||
ціна — падіння між записом і надсиланням коштує однієї сходинки (та
|
||||
сама угода, що й для черги подієвих алертів).
|
||||
|
||||
Журнал сходинок (`alr.escalation_steps`) фіксує і надсилання, і
|
||||
НЕнадсилання з причиною — «сходинку 2 пропущено: підтверджено о 02:47».
|
||||
Без цього на питання «чому мене розбудили» відповіді немає.
|
||||
|
||||
У `GET /api/v1/alerts` кожен алерт із живою драбиною має поле
|
||||
`escalation` — назва драбини, скільки сходинок пройдено, коли наступна.
|
||||
|
||||
### Канали й маршрути
|
||||
|
||||
Канал зберігає несекретну частину в `config`, а токен — у
|
||||
|
|
|
|||
|
|
@ -37,8 +37,10 @@ func main() {
|
|||
|
||||
func run() error {
|
||||
var (
|
||||
listen = flag.String("listen", envOr("NETPULSE_API_LISTEN", ":8080"), "адреса HTTP")
|
||||
dsn = flag.String("dsn", os.Getenv("NETPULSE_DSN"), "DSN PostgreSQL")
|
||||
listen = flag.String("listen", envOr("NETPULSE_API_LISTEN", ":8080"), "адреса HTTP")
|
||||
dsn = flag.String("dsn", os.Getenv("NETPULSE_DSN"), "DSN PostgreSQL")
|
||||
dsnWorker = flag.String("dsn-worker", os.Getenv("NETPULSE_DSN_WORKER"),
|
||||
"DSN для фонових тактів поверх усіх кабінетів (порожньо — тим самим з'єднанням)")
|
||||
certFile = flag.String("cert", os.Getenv("NETPULSE_API_CERT"), "сертифікат TLS")
|
||||
keyFile = flag.String("key", os.Getenv("NETPULSE_API_KEY"), "приватний ключ TLS")
|
||||
logLevel = flag.String("log-level", envOr("NETPULSE_LOG_LEVEL", "info"), "debug|info|warn|error")
|
||||
|
|
@ -57,6 +59,14 @@ func run() error {
|
|||
privateHooks = flag.Bool("allow-private-webhooks",
|
||||
os.Getenv("NETPULSE_ALLOW_PRIVATE_WEBHOOKS") == "1",
|
||||
"дозволити вебхуки на внутрішні адреси — для self-hosted інсталяцій")
|
||||
// Увімкнено за замовчуванням: кнопки під сповіщеннями малюються
|
||||
// завжди, і інсталяція, де вони є, а приймача немає, — це рівно
|
||||
// той стан, який цей приймач і виправляє. Прапорець лишається
|
||||
// для мереж, з яких немає виходу на api.telegram.org: там
|
||||
// опитування давало б лише потік помилок у журналі.
|
||||
telegramBot = flag.Bool("telegram-callbacks",
|
||||
os.Getenv("NETPULSE_TELEGRAM_CALLBACKS") != "0",
|
||||
"приймати натискання кнопок під сповіщеннями Telegram (довге опитування)")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
|
|
@ -85,6 +95,15 @@ func run() error {
|
|||
}
|
||||
defer st.Close()
|
||||
|
||||
// Друге з'єднання — роллю netpulse_worker, для запитів, які за
|
||||
// побудовою ходять поверх усіх кабінетів (див. коментар до Store.bg
|
||||
// і міграцію 0063). Порожня змінна лишає все як було: фонові запити
|
||||
// підуть основним пулом. Саме тому цю зміну можна викотити наперед,
|
||||
// а роль перемкнути окремим кроком.
|
||||
if err := st.UseWorkerDSN(ctx, *dsnWorker); err != nil {
|
||||
return fmt.Errorf("підключення воркера: %w", err)
|
||||
}
|
||||
|
||||
ring, err := crypto.ParseKeyring(*keysFlag)
|
||||
if err != nil {
|
||||
return fmt.Errorf("-dek: %w", err)
|
||||
|
|
@ -99,6 +118,10 @@ func run() error {
|
|||
}
|
||||
|
||||
api := httpapi.New(st, signer, log)
|
||||
// Ключі шифрування потрібні не лише каналам сповіщень: секрет
|
||||
// дзеркала конфігів лежить у тому самому core.secrets, а движок
|
||||
// алертів на інсталяції може бути вимкнений.
|
||||
api = api.WithKeyring(ring)
|
||||
|
||||
// Зібраний інтерфейс, якщо він є в цій збірці. Порожній dist —
|
||||
// робочий стан: розробка йде проти vite, а API просто віддає API.
|
||||
|
|
@ -117,8 +140,28 @@ func run() error {
|
|||
if *alertEvery > 0 {
|
||||
eng := alerting.New(st, ring, log, *alertEvery, *privateHooks)
|
||||
api = api.WithNotifications(ring, eng.Notifier())
|
||||
// Прогін відповідності запускають з UI, тобто з цього процесу —
|
||||
// і саме він перетворює знахідку на алерт. Без цього тригер
|
||||
// «порушено вимогу» лишався б тим, чим був: рядком у базі, який
|
||||
// ніколи не спрацює.
|
||||
api = api.WithEventAlerts(alerting.NewEventSink(st, log))
|
||||
go eng.Run(ctx)
|
||||
go eng.RunHousekeeping(ctx, *alertKeep)
|
||||
|
||||
// Приймач натискань кнопок під сповіщеннями Telegram.
|
||||
//
|
||||
// Тут же, де й доставка: кнопки малює notify.go, і розводити
|
||||
// «надіслати» й «прийняти натиснуте» по різних процесах
|
||||
// означало б інсталяцію, де кнопки є, а відповіді на них немає.
|
||||
//
|
||||
// Довге опитування, а не вебхук — розгортання за самопідписаним
|
||||
// TLS на IP-адресі вебхука не приймає в принципі. Повне
|
||||
// обґрунтування — у telegram_bot.go, поруч із самим кодом.
|
||||
// Кілька екземплярів API безпечні: приймач тримає власне
|
||||
// advisory-блокування, тож getUpdates робить рівно один.
|
||||
if *telegramBot {
|
||||
go alerting.NewBot(st, ring, log).Run(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
srv := &http.Server{
|
||||
|
|
|
|||
|
|
@ -14,6 +14,14 @@
|
|||
// netpulse-gitsync -dsn ... -dek np1=<hex> -git-root /var/lib/netpulse/git
|
||||
//
|
||||
// Повторний запуск безпечний: незмінений вміст нового коміту не створює.
|
||||
//
|
||||
// Про дзеркало на зовнішньому Git. Переливання складає коміти заново, і
|
||||
// їхні ідентифікатори залежать від того, що зараз є в базі: якщо
|
||||
// ретеншен уже прибрав частину версій, відновлена гілка НЕ збігається з
|
||||
// тією, що колись поїхала на дзеркало. Такий push буде відхилено як
|
||||
// non-fast-forward — і це правильно: дзеркало не переписує чужу історію
|
||||
// само. Після відновлення з бази копію на тому кінці або створюють
|
||||
// заново порожньою, або перезаписують свідомо з інтерфейсу.
|
||||
package main
|
||||
|
||||
import (
|
||||
|
|
@ -39,7 +47,9 @@ func main() {
|
|||
|
||||
func run() error {
|
||||
var (
|
||||
dsn = flag.String("dsn", os.Getenv("NETPULSE_DSN"), "DSN PostgreSQL")
|
||||
dsn = flag.String("dsn", os.Getenv("NETPULSE_DSN"), "DSN PostgreSQL")
|
||||
dsnWorker = flag.String("dsn-worker", os.Getenv("NETPULSE_DSN_WORKER"),
|
||||
"DSN для фонових тактів поверх усіх кабінетів (порожньо — тим самим з'єднанням)")
|
||||
keys = flag.String("dek", os.Getenv("NETPULSE_DEK"), "ключі шифрування: key_id=<hex|base64>[,...]")
|
||||
gitRoot = flag.String("git-root", envOr("NETPULSE_GIT_ROOT", "/var/lib/netpulse/git"), "корінь сховища версій")
|
||||
tenant = flag.String("tenant", "", "uuid тенанта; порожньо — усі")
|
||||
|
|
@ -71,6 +81,15 @@ func run() error {
|
|||
return fmt.Errorf("підключення до БД: %w", err)
|
||||
}
|
||||
defer st.Close()
|
||||
// Друге з'єднання — роллю netpulse_worker, для запитів, які за
|
||||
// побудовою ходять поверх усіх кабінетів (див. коментар до Store.bg
|
||||
// і міграцію 0063). Порожня змінна лишає все як було: фонові запити
|
||||
// підуть основним пулом. Саме тому цю зміну можна викотити наперед,
|
||||
// а роль перемкнути окремим кроком.
|
||||
if err := st.UseWorkerDSN(ctx, *dsnWorker); err != nil {
|
||||
return fmt.Errorf("підключення воркера: %w", err)
|
||||
}
|
||||
|
||||
st.UseGit(gitstore.New(*gitRoot))
|
||||
|
||||
tenants := []string{*tenant}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,16 @@
|
|||
//
|
||||
// Міграції вшиті в бінарник через embed: файл, який лежить поруч,
|
||||
// рано чи пізно виявиться версією з іншого релізу.
|
||||
//
|
||||
// Крім схеми, ця команда відповідає ще за одне — і саме тому вона тут, а
|
||||
// не в скрипті установника. На ЧИСТІЙ базі вона видає ролям netpulse_app
|
||||
// і netpulse_worker паролі з DSN, які їй передали, і перевіряє, що ними
|
||||
// справді можна зайти. Тобто нова інсталяція вмикає RLS сама, без
|
||||
// окремого кроку й без вікна, у якому RLS вимкнено. На базі, де вже
|
||||
// щось накочено, вона паролів не чіпає: перехід на роль без BYPASSRLS
|
||||
// там — покрокова процедура з відкатом (deploy/RLS-EXISTING-INSTALL.md),
|
||||
// а не побічний наслідок `docker compose up`. Як саме розрізняються ці
|
||||
// два випадки — у коментарі до installKind.
|
||||
package main
|
||||
|
||||
import (
|
||||
|
|
@ -58,6 +68,10 @@ func main() {
|
|||
|
||||
func run() error {
|
||||
dsn := flag.String("dsn", os.Getenv("NETPULSE_DSN"), "postgres://user:pass@host:5432/db")
|
||||
dsnApp := flag.String("dsn-app", os.Getenv("NETPULSE_DSN_APP"),
|
||||
"DSN, яким ходитимуть api й collector; на чистій базі звідси береться пароль ролі")
|
||||
dsnWorker := flag.String("dsn-worker", os.Getenv("NETPULSE_DSN_WORKER"),
|
||||
"DSN фонових тактів; на чистій базі звідси береться пароль ролі")
|
||||
dryRun := flag.Bool("dry-run", false, "лише показати, що буде застосовано")
|
||||
timeout := flag.Duration("timeout", 10*time.Minute, "стеля на всі міграції")
|
||||
flag.Parse()
|
||||
|
|
@ -106,6 +120,20 @@ func run() error {
|
|||
`SELECT pg_advisory_unlock($1)`, int64(migrateLockKey))
|
||||
}()
|
||||
|
||||
// Ознака інсталяції знімається ТУТ: під блокуванням і до першої
|
||||
// застосованої міграції. Пізніше вона вже неправдива — після
|
||||
// накочування будь-яка база виглядає як «наявна інсталяція».
|
||||
//
|
||||
// Під -dry-run не знімається взагалі: режим обіцяє «лише показати»,
|
||||
// а перший запис відповіді — теж запис, і він назавжди.
|
||||
var inst installInfo
|
||||
if !*dryRun {
|
||||
inst, err = installKind(ctx, conn.Conn())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
applied, err := appliedVersions(ctx, conn.Conn())
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -154,7 +182,14 @@ func run() error {
|
|||
default:
|
||||
fmt.Printf("застосовано міграцій: %d\n", pending)
|
||||
}
|
||||
return nil
|
||||
|
||||
if *dryRun {
|
||||
return nil
|
||||
}
|
||||
// Ролі — після схеми, бо саме схема (0063) їх і заводить. І досі
|
||||
// під блокуванням: два мігратори, що стартували разом, інакше
|
||||
// виконали б ALTER ROLE ... PASSWORD одночасно.
|
||||
return ensureRoles(ctx, conn.Conn(), *dsn, *dsnApp, *dsnWorker, inst)
|
||||
}
|
||||
|
||||
func listMigrations() ([]string, error) {
|
||||
|
|
@ -177,16 +212,94 @@ func listMigrations() ([]string, error) {
|
|||
}
|
||||
|
||||
func bootstrap(ctx context.Context, conn *pgx.Conn) error {
|
||||
_, err := conn.Exec(ctx, `
|
||||
if _, err := conn.Exec(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS public.schema_migrations (
|
||||
version text PRIMARY KEY,
|
||||
checksum text NOT NULL,
|
||||
applied_at timestamptz NOT NULL DEFAULT now()
|
||||
)
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Обидві таблиці заводить мігратор, а не міграція: вони мусять
|
||||
// існувати ДО того, як накотиться перша з них.
|
||||
if _, err := conn.Exec(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS public.netpulse_install (
|
||||
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
|
||||
fresh boolean NOT NULL,
|
||||
applied_was integer NOT NULL,
|
||||
decided_at timestamptz NOT NULL DEFAULT now()
|
||||
)
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := conn.Exec(ctx, `
|
||||
COMMENT ON TABLE public.netpulse_install IS
|
||||
'Чи була база порожня, коли netpulse-migrate уперше її побачив. '
|
||||
'Від цього рядка залежить, чи вільно міграторові видавати паролі '
|
||||
'ролям, тобто вмикати RLS. Пишеться один раз і не переглядається.'
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
// installKind — «чиста база» чи «оновлення наявної інсталяції».
|
||||
//
|
||||
// Питання не риторичне: від відповіді залежить, чи вільно міграторові
|
||||
// видати ролям паролі й тим самим перевести застосунок на роль без
|
||||
// BYPASSRLS. Помилка в один бік дає нову інсталяцію, у якій api не може
|
||||
// підключитись; у другий — робочий стенд, який серед ночі перемкнувся
|
||||
// сам, бо хтось дописав рядок у .env.
|
||||
//
|
||||
// Ознака одна, фактична й перевірна очима: чи була public.schema_migrations
|
||||
// ПОРОЖНЯ в ту мить, коли мігратор уперше побачив цю базу.
|
||||
//
|
||||
// - порожня — жодна міграція ще не котилась, тобто в базі немає ані
|
||||
// схеми, ані даних, ані клієнтів. Перемикати нічого, ламати нічого;
|
||||
// роль без BYPASSRLS — просто те, з чим ця інсталяція народжується.
|
||||
// - непорожня — інсталяція вже працює. Перехід на ній робить людина
|
||||
// покроково (deploy/RLS-EXISTING-INSTALL.md), а не `docker compose up`.
|
||||
//
|
||||
// Чому відповідь ЗАПИСУЄТЬСЯ, а не обчислюється щоразу. Бо обчислення
|
||||
// дає різні відповіді в різні дні. Перший запуск, що впав на тридцятій
|
||||
// міграції, лишає непорожню schema_migrations — і повторний запуск
|
||||
// вирішив би, що це «наявна інсталяція», не видав би паролів, і нова
|
||||
// система лишилась би без входу. Записаний рядок такої різниці не має:
|
||||
// його пишуть раз, під тим самим advisory-блокуванням, що й накочування.
|
||||
//
|
||||
// Чому не «чи є роль netpulse_app без пароля» і не «чи застосовано 0063».
|
||||
// Обидві ознаки не розрізняють двох випадків, які треба розрізнити: у
|
||||
// наявної інсталяції ДО переходу роль теж є й теж без пароля. Тобто
|
||||
// вгадування дало б рівно ту тиху зміну, проти якої все це написано.
|
||||
func installKind(ctx context.Context, conn *pgx.Conn) (installInfo, error) {
|
||||
if _, err := conn.Exec(ctx, `
|
||||
INSERT INTO public.netpulse_install (fresh, applied_was)
|
||||
SELECT count(*) = 0, count(*) FROM public.schema_migrations
|
||||
ON CONFLICT (singleton) DO NOTHING
|
||||
`); err != nil {
|
||||
return installInfo{}, fmt.Errorf("ознака інсталяції: %w", err)
|
||||
}
|
||||
|
||||
var out installInfo
|
||||
if err := conn.QueryRow(ctx,
|
||||
`SELECT fresh, applied_was, decided_at FROM public.netpulse_install`,
|
||||
).Scan(&out.fresh, &out.appliedWas, &out.decidedAt); err != nil {
|
||||
return installInfo{}, fmt.Errorf("ознака інсталяції: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// installInfo — записана відповідь разом із доказом, з якого вона взялась.
|
||||
//
|
||||
// appliedWas зберігається не для коду, а для людини: питання «чому мій
|
||||
// стенд вважається наявною інсталяцією» має відповідь в одному SELECT,
|
||||
// а не в читанні цього файлу.
|
||||
type installInfo struct {
|
||||
fresh bool
|
||||
appliedWas int
|
||||
decidedAt time.Time
|
||||
}
|
||||
|
||||
func appliedVersions(ctx context.Context, conn *pgx.Conn) (map[string]string, error) {
|
||||
rows, err := conn.Query(ctx, `SELECT version, checksum FROM public.schema_migrations`)
|
||||
if err != nil {
|
||||
|
|
@ -241,6 +354,194 @@ func apply(ctx context.Context, conn *pgx.Conn, version, checksum, body string)
|
|||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// ensureRoles — крок, після якого застосунок заходить у базу тією роллю,
|
||||
// якою мав, і про це відомо ще до того, як api спробує піднятись.
|
||||
//
|
||||
// Робить рівно дві речі, і друга важливіша за першу.
|
||||
//
|
||||
// 1. На ЧИСТІЙ базі видає ролям паролі. Не міграція, бо пароль у
|
||||
// міграції — це пароль у git і в контрольній сумі
|
||||
// public.schema_migrations, тобто пароль, який уже не змінити, не
|
||||
// зачепивши перевірку цілісності. І не установник окремим кроком,
|
||||
// бо крок, який можна забути, забувають. Пароль береться з САМОГО
|
||||
// DSN застосунку: у базу лягає рівно той рядок, який потім надішле
|
||||
// pgx, — той самий текст, розібраний тим самим розбирачем.
|
||||
//
|
||||
// 2. Заходить кожним DSN і перевіряє, ким саме зайшов. Це та половина,
|
||||
// якої бракувало 0063: тоді перевірили, що роль не бачить ЧУЖОГО, і
|
||||
// не перевірили, що вона взагалі заходить і бачить СВОЄ. Зелена
|
||||
// перевірка доводить рівно те, що вона перевіряє, тому тут
|
||||
// перевіряється найдешевше й найгучніше: сам вхід і атрибут
|
||||
// BYPASSRLS на тому кінці.
|
||||
//
|
||||
// Порожній appDSN — робочий стан, а не недоглянута конфігурація: так
|
||||
// ходять тести, `go run` на стенді розробника й кожен .env, написаний до
|
||||
// цієї зміни. Тоді функція не робить нічого.
|
||||
func ensureRoles(ctx context.Context, conn *pgx.Conn, ownerDSN, appDSN, workerDSN string, inst installInfo) error {
|
||||
if appDSN == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
owner, err := pgx.ParseConfig(ownerDSN)
|
||||
if err != nil {
|
||||
return fmt.Errorf("розбір DSN власника: %w", err)
|
||||
}
|
||||
app, err := pgx.ParseConfig(appDSN)
|
||||
if err != nil {
|
||||
return fmt.Errorf("розбір DSN застосунку: %w%s", err, dsnHint)
|
||||
}
|
||||
|
||||
// DSN згорнувся до ролі власника — тобто NETPULSE_APP_PASSWORD
|
||||
// порожній і стек працює як до 0063. Для наявної інсталяції це
|
||||
// звичайний стан, і мовчати про нього правильно. Для чистої — ні:
|
||||
// нова система, яка щойно народилась без другого рубежу, має про це
|
||||
// сказати вголос один раз, поки її ще ніхто не наповнив даними.
|
||||
if app.User == owner.User {
|
||||
if inst.fresh {
|
||||
fmt.Println("УВАГА: чиста база, а NETPULSE_APP_PASSWORD порожній.")
|
||||
fmt.Println(" Застосунок ходитиме роллю власника, і жодна з політик RLS")
|
||||
fmt.Println(" не діятиме — ізоляцію кабінетів триматиме лише предикат")
|
||||
fmt.Println(" tenant_id у коді. Заповніть NETPULSE_APP_PASSWORD і")
|
||||
fmt.Println(" NETPULSE_WORKER_PASSWORD у .env і накотіть ще раз.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Роль застосунку без ролі воркера — найгірший з можливих станів, і
|
||||
// саме тому це помилка, а не попередження. Інтерфейс при ньому
|
||||
// працює, а фонові такти мовчки нічого не знаходять: запити-шукачі
|
||||
// черг ходять поверх усіх кабінетів, і під NOBYPASSRLS повертають
|
||||
// нуль рядків без жодної помилки в журналі. Бекапи не запускаються,
|
||||
// алерти не розсилаються, події не доходять до браузера — і все це
|
||||
// виглядає як тиша, а не як поломка.
|
||||
if workerDSN == "" {
|
||||
return fmt.Errorf(
|
||||
"NETPULSE_APP_PASSWORD задано, а NETPULSE_WORKER_PASSWORD — ні.\n"+
|
||||
"Роль %s не має BYPASSRLS, тому фонові такти нічого не знайдуть і не "+
|
||||
"поскаржаться. Задайте обидва паролі або жодного", app.User)
|
||||
}
|
||||
worker, err := pgx.ParseConfig(workerDSN)
|
||||
if err != nil {
|
||||
return fmt.Errorf("розбір DSN воркера: %w%s", err, dsnHint)
|
||||
}
|
||||
|
||||
switch {
|
||||
case inst.fresh:
|
||||
if err := setPassword(ctx, conn, app.User, app.Password); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := setPassword(ctx, conn, worker.User, worker.Password); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("чиста база: видано паролі ролям %s і %s\n", app.User, worker.User)
|
||||
default:
|
||||
// Наявна інсталяція: паролі тут уже видані руками, за
|
||||
// deploy/RLS-EXISTING-INSTALL.md. Переписувати їх із .env
|
||||
// мігратор не береться — це означало б, що файл, який хтось
|
||||
// відредагував і не перечитав, мовчки змінює доступ до робочої
|
||||
// бази. Нижче лишається сама перевірка входу, і саме вона
|
||||
// скаже, якщо крок з паролями пропустили.
|
||||
fmt.Printf("наявна інсталяція (на момент %s застосованих міграцій: %d): "+
|
||||
"паролі ролей не чіпаю\n",
|
||||
inst.decidedAt.Format("2006-01-02"), inst.appliedWas)
|
||||
}
|
||||
|
||||
if err := checkLogin(ctx, appDSN, app.User, false); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkLogin(ctx, workerDSN, worker.User, true); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("вхід перевірено: api й collector — %s (без BYPASSRLS), "+
|
||||
"фонові такти — %s (з BYPASSRLS)\n", app.User, worker.User)
|
||||
return nil
|
||||
}
|
||||
|
||||
// dsnHint — про пароль, який зіпсував DSN.
|
||||
//
|
||||
// Урок цього проєкту, і коштував він півдня. Пароль ролі їде всередині
|
||||
// URL, тому не кожен рядок у ньому лишається собою:
|
||||
//
|
||||
// - `/` обриває користувацьку частину, і хвіст пароля стає шляхом,
|
||||
// тобто іменем бази. З'єднання не встановлюється, у журналі —
|
||||
// «database ... does not exist»;
|
||||
// - `%` читається як початок %XX. Пароль при цьому мовчки стає іншим
|
||||
// рядком, і помилка виглядає як «authentication failed» без причини.
|
||||
//
|
||||
// Обидва символи трапляються в base64 (`/`) і в тому, що люди набирають
|
||||
// руками (`%`). Виглядає це не як зіпсований рядок у .env, а як «RLS усе
|
||||
// зламав», і шукають не там. Тому hex — не порада, а вимога.
|
||||
const dsnHint = "\n" +
|
||||
"Найчастіша причина — пароль ролі не в hex. Пароль їде всередині URL:\n" +
|
||||
"`/` обриває його й перетворює хвіст на ім'я бази, `%` читається як\n" +
|
||||
"початок %XX і мовчки змінює пароль. Генеруйте так: openssl rand -hex 24"
|
||||
|
||||
// setPassword видає роль пароль.
|
||||
//
|
||||
// Лапки ставить сам Postgres через format(%I/%L): ALTER ROLE не приймає
|
||||
// параметрів, а команда, яка ЗАДАЄ пароль, — найгірше місце для склеювання
|
||||
// рядків руками. Ціна відома й прийнята: якщо на сервері ввімкнено
|
||||
// log_statement = all, готова команда потрапить у журнал БД разом із
|
||||
// паролем. Так само поводиться будь-який ALTER ROLE з psql.
|
||||
func setPassword(ctx context.Context, conn *pgx.Conn, role, password string) error {
|
||||
if password == "" {
|
||||
return fmt.Errorf("порожній пароль ролі %s: DSN зібрано неправильно", role)
|
||||
}
|
||||
var stmt string
|
||||
if err := conn.QueryRow(ctx,
|
||||
`SELECT format('ALTER ROLE %I PASSWORD %L', $1::text, $2::text)`,
|
||||
role, password).Scan(&stmt); err != nil {
|
||||
return fmt.Errorf("скласти команду для ролі %s: %w", role, err)
|
||||
}
|
||||
if _, err := conn.Exec(ctx, stmt); err != nil {
|
||||
return fmt.Errorf("видати пароль ролі %s: %w", role, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkLogin підключається наданим DSN і питає базу, ким він зайшов.
|
||||
//
|
||||
// Окреме з'єднання, а не SELECT з наявного: перевіряється саме те, що
|
||||
// ламається, — автентифікація рядком, який зібрав docker compose.
|
||||
//
|
||||
// wantBypass — не прикраса. Роль застосунку з BYPASSRLS означає, що все
|
||||
// нижче зелене й нічого не діє (рівно стан до 0063). Роль воркера БЕЗ
|
||||
// BYPASSRLS означає протилежну поломку: черги мовчки порожні. Обидві
|
||||
// беззвучні, тому обидві тут — помилка.
|
||||
func checkLogin(ctx context.Context, dsn, wantUser string, wantBypass bool) error {
|
||||
c, err := pgx.Connect(ctx, dsn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("вхід роллю %s не вдався: %w%s\n"+
|
||||
"Якщо це інсталяція, зроблена до 0063, — паролі ролям видає\n"+
|
||||
"deploy/RLS-EXISTING-INSTALL.md, крок 2; мігратор на непорожній базі\n"+
|
||||
"робить це навмисно не сам", wantUser, err, dsnHint)
|
||||
}
|
||||
defer func() { _ = c.Close(context.WithoutCancel(ctx)) }()
|
||||
|
||||
var got string
|
||||
var bypass bool
|
||||
if err := c.QueryRow(ctx,
|
||||
`SELECT current_user, rolbypassrls FROM pg_roles WHERE rolname = current_user`,
|
||||
).Scan(&got, &bypass); err != nil {
|
||||
return fmt.Errorf("перевірка ролі %s: %w", wantUser, err)
|
||||
}
|
||||
if got != wantUser {
|
||||
return fmt.Errorf("DSN обіцяв роль %s, а з'єднання відкрито як %s", wantUser, got)
|
||||
}
|
||||
if bypass != wantBypass {
|
||||
if wantBypass {
|
||||
return fmt.Errorf(
|
||||
"роль %s не має BYPASSRLS: фонові такти нічого не знайдуть і не "+
|
||||
"поскаржаться (ALTER ROLE %s BYPASSRLS)", got, got)
|
||||
}
|
||||
return fmt.Errorf(
|
||||
"роль %s має BYPASSRLS: політики RLS на неї не діють, і все нижче "+
|
||||
"виглядатиме справним, лишаючись без другого рубежу "+
|
||||
"(ALTER ROLE %s NOBYPASSRLS)", got, got)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkEncoding зупиняє накочування на базі не в UTF-8.
|
||||
//
|
||||
// SQL_ASCII не кодування, а його відсутність: сервер просто пропускає
|
||||
|
|
|
|||
96
server/cmd/netpulse-migrate/roles_test.go
Normal file
96
server/cmd/netpulse-migrate/roles_test.go
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Перевіряються рівно ті гілки ensureRoles, які завершуються ДО першого
|
||||
// звернення до бази, — тому conn тут nil, і це навмисно: якби якась із
|
||||
// цих гілок раптом почала ходити в базу, тест упав би на nil, а не
|
||||
// пройшов би тихо.
|
||||
//
|
||||
// Головне, що тут захищається, — властивість «порожні змінні = стара
|
||||
// поведінка». Її легко зламати наступною правкою, а помітити зламану
|
||||
// можна лише на чужому робочому стенді.
|
||||
//
|
||||
// Чого цей тест НЕ покриває: усе, що починається з підключення. Видача
|
||||
// паролів, перевірка входу, атрибут BYPASSRLS на тому кінці — жодного
|
||||
// разу не виконуються без живого Postgres.
|
||||
func TestEnsureRolesEarlyBranches(t *testing.T) {
|
||||
const owner = "postgres://netpulse:ownerpw@db:5432/netpulse?sslmode=disable"
|
||||
|
||||
fresh := installInfo{fresh: true, appliedWas: 0}
|
||||
existing := installInfo{fresh: false, appliedWas: 68}
|
||||
|
||||
// DSN, який docker compose збирає при порожньому NETPULSE_APP_PASSWORD:
|
||||
// та сама роль, що й у власника.
|
||||
collapsed := "postgres://netpulse:ownerpw@db:5432/netpulse?sslmode=disable"
|
||||
appRole := "postgres://netpulse_app:3f9a2b7c1d@db:5432/netpulse?sslmode=disable"
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
appDSN string
|
||||
wrkDSN string
|
||||
inst installInfo
|
||||
wantErr string // порожньо — має пройти без помилки й без роботи
|
||||
}{
|
||||
{"наявний стенд, .env без нових рядків", collapsed, "", existing, ""},
|
||||
{"чиста база без паролів — попередження, не помилка", collapsed, "", fresh, ""},
|
||||
{"нічого не передано (тести, go run)", "", "", fresh, ""},
|
||||
{"роль застосунку без ролі воркера", appRole, "", fresh, "NETPULSE_WORKER_PASSWORD"},
|
||||
{"те саме на наявній інсталяції", appRole, "", existing, "NETPULSE_WORKER_PASSWORD"},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
err := ensureRoles(context.Background(), nil, owner, c.appDSN, c.wrkDSN, c.inst)
|
||||
switch {
|
||||
case c.wantErr == "" && err != nil:
|
||||
t.Fatalf("мало бути без роботи й без помилки, вийшло: %v", err)
|
||||
case c.wantErr != "" && err == nil:
|
||||
t.Fatalf("очікували відмову зі згадкою %q, вийшло nil", c.wantErr)
|
||||
case c.wantErr != "" && !strings.Contains(err.Error(), c.wantErr):
|
||||
t.Fatalf("очікували відмову зі згадкою %q, вийшло: %v", c.wantErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Чому паролі ролей мають бути в hex — на прикладі того самого розбирача,
|
||||
// яким їх читатиме застосунок.
|
||||
//
|
||||
// Тест закріплює не бажану поведінку pgx, а факт: те, що виглядає як
|
||||
// «пароль не підійшов», насправді буває зіпсованим DSN.
|
||||
func TestPasswordCharactersInDSN(t *testing.T) {
|
||||
dsn := func(pw string) string {
|
||||
return "postgres://netpulse_app:" + pw + "@db:5432/netpulse?sslmode=disable"
|
||||
}
|
||||
|
||||
// hex доїжджає до бази як є.
|
||||
cfg, err := pgx.ParseConfig(dsn("3f9a2b7c1d"))
|
||||
if err != nil {
|
||||
t.Fatalf("hex-пароль мав розібратись, а вийшло: %v", err)
|
||||
}
|
||||
if cfg.Password != "3f9a2b7c1d" || cfg.User != "netpulse_app" {
|
||||
t.Fatalf("hex-пароль спотворено: user=%q password=%q", cfg.User, cfg.Password)
|
||||
}
|
||||
|
||||
// Скісна риска (звичайний символ base64) обриває DSN — і це помітно.
|
||||
if _, err := pgx.ParseConfig(dsn("aB/cD+eF=")); err == nil {
|
||||
t.Fatal("пароль зі скісною рискою мав дати помилку розбору")
|
||||
}
|
||||
|
||||
// Відсоток гірший за скісну риску: помилки немає, а пароль інший.
|
||||
cfg, err = pgx.ParseConfig(dsn("aB%cDef"))
|
||||
if err != nil {
|
||||
t.Fatalf("несподівана помилка розбору: %v", err)
|
||||
}
|
||||
if cfg.Password == "aB%cDef" {
|
||||
t.Fatal("очікували, що відсоткову послідовність буде розкодовано; " +
|
||||
"якщо ні — підказку в dsnHint треба переписати")
|
||||
}
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ import (
|
|||
"time"
|
||||
|
||||
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
|
||||
"github.com/netpulse/netpulse/server/internal/alerting"
|
||||
"github.com/netpulse/netpulse/server/internal/crypto"
|
||||
"github.com/netpulse/netpulse/server/internal/gitstore"
|
||||
"github.com/netpulse/netpulse/server/internal/grpcapi"
|
||||
|
|
@ -38,8 +39,10 @@ func main() {
|
|||
|
||||
func run() error {
|
||||
var (
|
||||
listen = flag.String("listen", envOr("NETPULSE_LISTEN", ":9443"), "адреса прослуховування gRPC")
|
||||
dsn = flag.String("dsn", os.Getenv("NETPULSE_DSN"), "DSN PostgreSQL")
|
||||
listen = flag.String("listen", envOr("NETPULSE_LISTEN", ":9443"), "адреса прослуховування gRPC")
|
||||
dsn = flag.String("dsn", os.Getenv("NETPULSE_DSN"), "DSN PostgreSQL")
|
||||
dsnWorker = flag.String("dsn-worker", os.Getenv("NETPULSE_DSN_WORKER"),
|
||||
"DSN для фонових тактів поверх усіх кабінетів (порожньо — тим самим з'єднанням)")
|
||||
certFile = flag.String("cert", os.Getenv("NETPULSE_CERT"), "сертифікат сервера")
|
||||
keyFile = flag.String("key", os.Getenv("NETPULSE_KEY"), "приватний ключ")
|
||||
caFile = flag.String("client-ca", os.Getenv("NETPULSE_CLIENT_CA"), "CA для перевірки сертифікатів зондів (mTLS)")
|
||||
|
|
@ -73,6 +76,15 @@ func run() error {
|
|||
}
|
||||
defer st.Close()
|
||||
|
||||
// Друге з'єднання — роллю netpulse_worker, для запитів, які за
|
||||
// побудовою ходять поверх усіх кабінетів (див. коментар до Store.bg
|
||||
// і міграцію 0063). Порожня змінна лишає все як було: фонові запити
|
||||
// підуть основним пулом. Саме тому цю зміну можна викотити наперед,
|
||||
// а роль перемкнути окремим кроком.
|
||||
if err := st.UseWorkerDSN(ctx, *dsnWorker); err != nil {
|
||||
return fmt.Errorf("підключення воркера: %w", err)
|
||||
}
|
||||
|
||||
// Сховище версій конфігів. Спільний каталог для API й колектора:
|
||||
// колектор туди пише під час бекапу, API звідти читає для diff.
|
||||
// Різні машини потребують спільного тому — інакше половина історії
|
||||
|
|
@ -81,7 +93,14 @@ func run() error {
|
|||
st.UseGit(gitstore.New(*gitRoot))
|
||||
}
|
||||
|
||||
svc := grpcapi.New(st, ring, log)
|
||||
// Подієві алерти на журналі й конфігах.
|
||||
//
|
||||
// Живуть у цьому процесі, бо саме сюди приходять і те, і те. Сам
|
||||
// приймач нічого нікуди не шле — він лише піднімає алерт із
|
||||
// позначкою «розіслати»; розсилає netpulse-api, де є ключі каналів,
|
||||
// маршрути й тихі години.
|
||||
svc := grpcapi.New(st, ring, log).
|
||||
WithEventAlerts(alerting.NewEventSink(st, log))
|
||||
|
||||
opts := []grpc.ServerOption{
|
||||
grpc.ChainUnaryInterceptor(svc.UnaryInterceptor),
|
||||
|
|
@ -144,6 +163,19 @@ func run() error {
|
|||
// Звірка планів: чеки міняє REST-процес, а перезалити план може
|
||||
// лише той, хто тримає сесію зонда.
|
||||
go svc.SyncPlans(ctx)
|
||||
// Прибиральник старих версій конфігів. Тут, а не в REST-процесі:
|
||||
// поруч із тим, хто версії створює, і подалі від шляху запитів
|
||||
// людини — див. ncm_retention.go.
|
||||
go svc.SweepRetention(ctx)
|
||||
// Прибиральник телеметрії та журналів за строками зберігання, він
|
||||
// же — спостерігач за розміром бази. Тут із тих самих міркувань, що
|
||||
// й попередній: поруч із тим, хто ці дані створює, і подалі від
|
||||
// шляху запитів людини — див. storage_retention.go.
|
||||
go svc.SweepDataRetention(ctx)
|
||||
// Дзеркалення архіву конфігів на зовнішній Git. Окремий такт, а не
|
||||
// push після коміту: недоступний Forgejo не має коштувати жодного
|
||||
// бекапу — див. ncm_mirror.go.
|
||||
go svc.MirrorGit(ctx)
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() { errCh <- srv.Serve(lis) }()
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ func (e *Engine) Run(ctx context.Context) {
|
|||
}
|
||||
|
||||
func (e *Engine) tick(ctx context.Context) (int, error) {
|
||||
conn, err := e.st.Pool().Acquire(ctx)
|
||||
conn, err := e.st.WorkerPool().Acquire(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
|
@ -103,6 +103,19 @@ func (e *Engine) tick(ctx context.Context) (int, error) {
|
|||
`SELECT pg_advisory_unlock($1)`, int64(advisoryLockKey))
|
||||
}()
|
||||
|
||||
// Дві дії нижче стосуються подієвих алертів, які піднімає не цей
|
||||
// цикл, а приймачі подій (events.go). Вони мають статись навіть у
|
||||
// кабінеті без жодного метричного правила, тому стоять до вибірки
|
||||
// й до перевірки на порожньо.
|
||||
e.expireEvents(ctx)
|
||||
e.deliverPending(ctx)
|
||||
|
||||
// Ескалація теж не залежить від наявності метричних правил: драбину
|
||||
// може взвести подієвий алерт у кабінеті, де опитуваних правил немає
|
||||
// зовсім. І так само, як дві дії вище, вона мусить статись до
|
||||
// перевірки на порожній перелік правил.
|
||||
e.escalate(ctx)
|
||||
|
||||
rules, err := e.st.ActiveRules(ctx)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("читання правил: %w", err)
|
||||
|
|
@ -115,6 +128,14 @@ func (e *Engine) tick(ctx context.Context) (int, error) {
|
|||
// й канали читаються раз на тенант, а не раз на правило.
|
||||
byTenant := map[string][]store.Rule{}
|
||||
for _, r := range rules {
|
||||
// Подієві джерела цей цикл не чіпає — і не «просто пропускає»,
|
||||
// а мусить пропустити. Обчислення дало б порожній список
|
||||
// кандидатів, а ResolveMissing слідом закрив би щойно піднятий
|
||||
// подієвий алерт: із погляду опитування він «зник», хоча
|
||||
// зникнути він не може за побудовою.
|
||||
if store.IsEventSource(r.Source) {
|
||||
continue
|
||||
}
|
||||
byTenant[r.TenantID] = append(byTenant[r.TenantID], r)
|
||||
}
|
||||
|
||||
|
|
@ -297,6 +318,60 @@ func (e *Engine) publish(ctx context.Context, tenantID string, fired, resolved,
|
|||
emit("alert.updated", changed)
|
||||
}
|
||||
|
||||
// expireEvents гасить подієві алерти, до яких давно не було подій.
|
||||
//
|
||||
// Робиться щотіку, а не в прибиральнику раз на чверть години: строк
|
||||
// життя правило задає в секундах, і «доба» з точністю до п'ятнадцяти
|
||||
// хвилин виглядала б на екрані як несправність.
|
||||
func (e *Engine) expireEvents(ctx context.Context) {
|
||||
n, err := e.st.ExpireEventAlerts(ctx)
|
||||
if err != nil {
|
||||
e.log.Error("гасіння подієвих алертів", "помилка", err)
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
e.log.Info("подієві алерти прострочено", "рядків", n)
|
||||
}
|
||||
}
|
||||
|
||||
// deliverPending розсилає алерти, підняті подієвим шляхом.
|
||||
//
|
||||
// Подія приходить у процес, який не має ані ключів шифрування каналів,
|
||||
// ані маршрутів, ані тихих годин — тож розсилка лишається тут, де все
|
||||
// це вже прочитано, і під тим самим advisory-блокуванням: інакше два
|
||||
// інстанси API розбудили б людину двічі.
|
||||
//
|
||||
// Плата — затримка до одного тіку. Для «конфіг змінився» чи «порушено
|
||||
// стандарт» це прийнятно: жодне з них не є аварією, на яку біжать за
|
||||
// секунди. Для метричних алертів затримки як була, так і немає.
|
||||
func (e *Engine) deliverPending(ctx context.Context) {
|
||||
pending, err := e.st.TakeNotifyPending(ctx, 200)
|
||||
if err != nil {
|
||||
e.log.Error("черга розсилки подієвих алертів", "помилка", err)
|
||||
return
|
||||
}
|
||||
if len(pending) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
byTenant := map[string][]store.Alert{}
|
||||
for _, a := range pending {
|
||||
// Придушене не турбує нікого — рівно як у метричному шляху.
|
||||
if a.State != "firing" {
|
||||
continue
|
||||
}
|
||||
byTenant[a.TenantID] = append(byTenant[a.TenantID], a)
|
||||
}
|
||||
for tenantID, as := range byTenant {
|
||||
e.notifier.Dispatch(ctx, tenantID, as, e.ring)
|
||||
}
|
||||
}
|
||||
|
||||
// escalationLogKeep — той самий строк, що в alr.notifications (0007).
|
||||
// Розходження тут означало б, що на питання «чому мене розбудили» одна
|
||||
// половина відповіді ще є, а друга вже стерта.
|
||||
const escalationLogKeep = 90 * 24 * time.Hour
|
||||
|
||||
// RunHousekeeping переносить закриті алерти в історію.
|
||||
func (e *Engine) RunHousekeeping(ctx context.Context, keepResolved time.Duration) {
|
||||
t := time.NewTicker(15 * time.Minute)
|
||||
|
|
@ -314,6 +389,14 @@ func (e *Engine) RunHousekeeping(ctx context.Context, keepResolved time.Duration
|
|||
if n > 0 {
|
||||
e.log.Info("алерти заархівовано", "рядків", n)
|
||||
}
|
||||
// Журнал сходинок ескалації не гіпертаблиця, тож політики
|
||||
// ретеншену TimescaleDB в нього немає — прибираємо тут, тим
|
||||
// самим строком, що й у журналу доставки (0007).
|
||||
if k, err := e.st.PurgeEscalationLog(ctx, escalationLogKeep); err != nil {
|
||||
e.log.Error("прибирання журналу ескалацій", "помилка", err)
|
||||
} else if k > 0 {
|
||||
e.log.Info("журнал ескалацій прибрано", "рядків", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
149
server/internal/alerting/escalation.go
Normal file
149
server/internal/alerting/escalation.go
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
package alerting
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/netpulse/netpulse/server/internal/store"
|
||||
)
|
||||
|
||||
// Ескалація: «не підтвердили за 15 хвилин — буди наступного».
|
||||
//
|
||||
// Розділення обов'язків тут таке саме, як у решті движка, і воно
|
||||
// принципове:
|
||||
//
|
||||
// - store.PlanEscalation ухвалює РІШЕННЯ і не знає ні про канали, ні
|
||||
// про мережу. Функція чиста, тому «сходинка НЕ спрацювала, бо алерт
|
||||
// підтвердили / закрили / хост заглушено» перевіряється тестом без
|
||||
// бази — а це рівно та половина поведінки, яку легко залишити
|
||||
// неперевіреною, бо вона нічого не робить.
|
||||
//
|
||||
// - цей файл ВИКОНУЄ рішення: бере канали сходинки й шле в них той
|
||||
// самий алерт з іншою шапкою.
|
||||
//
|
||||
// Стан драбини живе в базі (alr.alert_escalations), а не в пам'яті. Це
|
||||
// не педантизм: викочування нової версії о другій ночі перезапускає
|
||||
// процес, і драбина в пам'яті померла б рівно посеред тієї аварії,
|
||||
// заради якої вона й існує.
|
||||
|
||||
// escalationBatch — скільки сходинок обробляти за тік.
|
||||
//
|
||||
// Сотні достатньо з великим запасом: сходинка — це подія раз на
|
||||
// чверть години на алерт, а не потік. Обмеження стоїть, щоб один
|
||||
// патологічний кабінет не з'їв увесь тік.
|
||||
const escalationBatch = 100
|
||||
|
||||
// escalate проганяє сходинки, час яких настав.
|
||||
//
|
||||
// Викликається з тіку движка, тобто під тим самим advisory-блокуванням,
|
||||
// що й решта. Оренда на рядку (EscalationLease) — другий рубіж на
|
||||
// випадок, коли блокування з якоїсь причини взяли двоє.
|
||||
func (e *Engine) escalate(ctx context.Context) {
|
||||
due, err := e.st.TakeDueEscalations(ctx, escalationBatch)
|
||||
if err != nil {
|
||||
e.log.Error("черга ескалацій", "помилка", err)
|
||||
return
|
||||
}
|
||||
if len(due) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Канали читаються раз на кабінет, а не раз на сходинку: розшифровка
|
||||
// секретів каналу коштує дорого, а сходинок одного кабінету в партії
|
||||
// може бути десяток.
|
||||
channels := map[string]map[string]store.Channel{}
|
||||
|
||||
now := time.Now()
|
||||
for _, snap := range due {
|
||||
d := store.PlanEscalation(snap, now)
|
||||
|
||||
// Запис ДО надсилання — той самий порядок, що й у журналі
|
||||
// доставки, і з тієї ж причини: якщо процес упаде між ними,
|
||||
// краще не надіслати сходинку, ніж надіслати її вдруге.
|
||||
if err := e.st.ApplyEscalation(ctx, snap, d); err != nil {
|
||||
e.log.Error("запис рішення ескалації", "алерт", snap.AlertID, "помилка", err)
|
||||
continue
|
||||
}
|
||||
if d.Action != store.EscFire {
|
||||
e.log.Debug("ескалацію не продовжено", "алерт", snap.AlertID,
|
||||
"причина", d.Outcome, "деталі", d.Detail)
|
||||
continue
|
||||
}
|
||||
|
||||
byID, ok := channels[snap.TenantID]
|
||||
if !ok {
|
||||
cs, err := e.st.LoadChannels(ctx, snap.TenantID, e.ring)
|
||||
if err != nil {
|
||||
e.log.Error("читання каналів для ескалації",
|
||||
"tenant", snap.TenantID, "помилка", err)
|
||||
channels[snap.TenantID] = map[string]store.Channel{}
|
||||
continue
|
||||
}
|
||||
byID = make(map[string]store.Channel, len(cs))
|
||||
for _, c := range cs {
|
||||
byID[c.ID] = c
|
||||
}
|
||||
channels[snap.TenantID] = byID
|
||||
}
|
||||
|
||||
e.notifier.deliverEscalation(ctx, snap, d, byID)
|
||||
}
|
||||
}
|
||||
|
||||
// deliverEscalation шле сходинку в її канали.
|
||||
func (n *Notifier) deliverEscalation(ctx context.Context, snap store.EscalationSnapshot,
|
||||
d store.EscalationDecision, byID map[string]store.Channel) {
|
||||
|
||||
a := snap.Alert
|
||||
head := escalationHeader(snap, d)
|
||||
|
||||
sent := 0
|
||||
for _, id := range d.ChannelIDs {
|
||||
c, ok := byID[id]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// Вимкнений канал і поріг серйозності діють на сходинку так
|
||||
// само, як на звичайне сповіщення: обидва — рішення про канал, а
|
||||
// не про драбину. Інакше «вимкнув Telegram на час переїзду»
|
||||
// означало б «вимкнув усе, крім ескалації», тобто рівно те, чого
|
||||
// людина не просила.
|
||||
if !c.Enabled || severityRank[a.Severity] < severityRank[c.MinSeverity] {
|
||||
continue
|
||||
}
|
||||
n.sendText(ctx, snap.TenantID, a, c, head+renderMessage(a, c))
|
||||
sent++
|
||||
}
|
||||
|
||||
if sent == 0 {
|
||||
// Сходинка вже списана (рішення записано до надсилання), і це
|
||||
// правильно: інакше вона поверталася б щотіку. Але мовчазна
|
||||
// втрата сходинки — саме те, від чого ескалація рятує, тож слід
|
||||
// лишається в журналі процесу.
|
||||
n.log.Warn("сходинка ескалації не мала куди піти",
|
||||
"алерт", snap.AlertID, "сходинка", d.StepIdx+1,
|
||||
"каналів у сходинці", len(d.ChannelIDs))
|
||||
}
|
||||
}
|
||||
|
||||
// escalationHeader пояснює людині, чому вона це читає.
|
||||
//
|
||||
// Без цього рядка третє повідомлення про ту саму аварію виглядає як
|
||||
// збій розсилки, а не як ескалація, — і перше, що зробить розбуджений
|
||||
// керівник, це вимкне канал.
|
||||
func escalationHeader(snap store.EscalationSnapshot, d store.EscalationDecision) string {
|
||||
total := len(snap.Steps)
|
||||
mins := int(time.Since(snap.Alert.StartedAt).Round(time.Minute).Minutes())
|
||||
if mins < 0 {
|
||||
mins = 0
|
||||
}
|
||||
|
||||
head := fmt.Sprintf("🔺 ЕСКАЛАЦІЯ %d/%d (%s): не підтверджено %s\n",
|
||||
d.StepIdx+1, total, snap.PolicyName, humanDur(mins*60))
|
||||
if d.RepeatIdx > 0 {
|
||||
head = fmt.Sprintf("🔺 ЕСКАЛАЦІЯ %d/%d, повтор %d (%s): не підтверджено %s\n",
|
||||
d.StepIdx+1, total, d.RepeatIdx, snap.PolicyName, humanDur(mins*60))
|
||||
}
|
||||
return head
|
||||
}
|
||||
97
server/internal/alerting/escalation_test.go
Normal file
97
server/internal/alerting/escalation_test.go
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
package alerting
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/netpulse/netpulse/server/internal/store"
|
||||
)
|
||||
|
||||
// Взведення драбини перевіряється з боку «не взвели».
|
||||
//
|
||||
// Тест «драбину взведено» доводить мало: він проходить і в реалізації,
|
||||
// яка взводить її завжди. Кожен із випадків нижче — це людина, яку
|
||||
// система розбудила б о третій ночі без причини, або (у випадку з
|
||||
// каналами) сповіщення, яке о нульовій хвилині свідомо не надіслали, а
|
||||
// о п'ятнадцятій раптом надіслали б.
|
||||
|
||||
const policy = "p1"
|
||||
|
||||
func act() store.RuleAction {
|
||||
return store.RuleAction{EscalationPolicyID: policy, Source: "icmp"}
|
||||
}
|
||||
|
||||
// Контроль: нормальний випадок драбину взводить.
|
||||
func TestEscalationArmedForFiringAlert(t *testing.T) {
|
||||
if !shouldArmEscalation(store.Alert{State: "firing"}, act(), true, 2) {
|
||||
t.Fatal("алерт, про який справді повідомили, має отримати драбину")
|
||||
}
|
||||
}
|
||||
|
||||
// Правило без політики — типовий стан, і він має лишатись мовчазним.
|
||||
//
|
||||
// Це головна вимога до оновлення: жоден наявний кабінет не має раптом
|
||||
// почати будити людей тільки тому, що з'явилась нова можливість.
|
||||
func TestNoPolicyMeansNoEscalation(t *testing.T) {
|
||||
empty := store.RuleAction{Source: "icmp"}
|
||||
if shouldArmEscalation(store.Alert{State: "firing"}, empty, true, 3) {
|
||||
t.Fatal("правило без політики не має ескалювати")
|
||||
}
|
||||
// Правила взагалі немає (його видалили, алерт лишився).
|
||||
if shouldArmEscalation(store.Alert{State: "firing"}, store.RuleAction{}, false, 3) {
|
||||
t.Fatal("алерт без правила ескалювати нікуди")
|
||||
}
|
||||
}
|
||||
|
||||
// Придушений алерт драбини не отримує: заглушення означає «не
|
||||
// турбувати», а драбина — це рівно турбування, тільки за розкладом.
|
||||
func TestSuppressedAlertIsNotArmed(t *testing.T) {
|
||||
for _, st := range []string{"suppressed", "acknowledged", "resolved", "expired"} {
|
||||
if shouldArmEscalation(store.Alert{State: st}, act(), true, 2) {
|
||||
t.Errorf("стан %q отримав драбину", st)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Найтонший випадок: перше сповіщення нікуди не пішло.
|
||||
//
|
||||
// Каналів могло не знайтись через тиху годину правила, поріг
|
||||
// серйозності або вимкнений канал. Взвести драбину означало б надіслати
|
||||
// о 15-й хвилині те, що на нульовій ми свідомо не надсилали, — і людина
|
||||
// прочитала б це як несправність. Нею це й було б.
|
||||
func TestEscalationNotArmedWhenNothingWasSent(t *testing.T) {
|
||||
if shouldArmEscalation(store.Alert{State: "firing"}, act(), true, 0) {
|
||||
t.Fatal("драбина взведена попри те, що перше сповіщення нікуди не пішло")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// Шапка ескалації має з першого рядка пояснювати, чому це третє
|
||||
// повідомлення про ту саму аварію. Без цього розбуджений керівник
|
||||
// вимкне канал, вирішивши, що розсилка зациклилась.
|
||||
func TestEscalationHeaderExplainsItself(t *testing.T) {
|
||||
start := time.Now().Add(-45 * time.Minute)
|
||||
snap := store.EscalationSnapshot{
|
||||
PolicyName: "Нічне чергування",
|
||||
Steps: []store.EscalationStep{
|
||||
{AfterMin: 15}, {AfterMin: 45},
|
||||
},
|
||||
Alert: store.Alert{StartedAt: start},
|
||||
}
|
||||
head := escalationHeader(snap, store.EscalationDecision{StepIdx: 1})
|
||||
|
||||
for _, want := range []string{"ЕСКАЛАЦІЯ", "2/2", "Нічне чергування", "не підтверджено"} {
|
||||
if !strings.Contains(head, want) {
|
||||
t.Errorf("у шапці немає %q:\n%s", want, head)
|
||||
}
|
||||
}
|
||||
|
||||
// Повтор має бути видно окремо: «друга сходинка» і «друга сходинка
|
||||
// втретє» — це різні новини для того, хто їх читає.
|
||||
rep := escalationHeader(snap, store.EscalationDecision{StepIdx: 1, RepeatIdx: 2})
|
||||
if !strings.Contains(rep, "повтор 2") {
|
||||
t.Errorf("номер повтору не показано:\n%s", rep)
|
||||
}
|
||||
}
|
||||
820
server/internal/alerting/events.go
Normal file
820
server/internal/alerting/events.go
Normal file
|
|
@ -0,0 +1,820 @@
|
|||
package alerting
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/netpulse/netpulse/server/internal/store"
|
||||
)
|
||||
|
||||
// Подієві алерти.
|
||||
//
|
||||
// Движок у engine.go працює тактами: раз на пів хвилини перепитує ряди
|
||||
// вимірів і питає в них «чи виконується умова зараз». Для трьох джерел
|
||||
// (metric, icmp, interface) це єдиний можливий спосіб — ряд є, питання
|
||||
// осмислене, відповідь може змінитися будь-коли.
|
||||
//
|
||||
// Для журналу, конфігів і відповідності такого ряду немає. Питати
|
||||
// «чи змінився конфіг зараз» безглуздо: він змінився о 10:42 і більше
|
||||
// нічого про це не скаже. Тому ці джерела не опитуються взагалі —
|
||||
// правило перевіряється рівно в ту мить, коли подія надійшла, у тому
|
||||
// процесі, який її прийняв.
|
||||
//
|
||||
// Наслідки цієї різниці треба було вирішити явно, і вони вирішені так:
|
||||
//
|
||||
// дедуплікація — один алерт на пару «правило + хост», скільки б подій
|
||||
// під нього не підпало. Ключ не містить нічого від самої події;
|
||||
// замість переліку в алерті лічильник. Інакше потік syslog за
|
||||
// хвилину зробив би дошку алертів нечитабельною — тобто зламав би
|
||||
// саме те, заради чого вона є.
|
||||
//
|
||||
// частота — між двома зверненнями до одного алерту витримується
|
||||
// min_interval_seconds правила. Пропущені за цей час події не
|
||||
// губляться: вони накопичуються тут і доливаються в лічильник
|
||||
// наступним зверненням. Ми економимо запити до бази, а не правду.
|
||||
//
|
||||
// гасіння — подієвий алерт не зникає сам, бо зникати нема чому.
|
||||
// Його закриває або людина, або строк (ExpireEventAlerts, стан
|
||||
// `expired`). Виняток один — відповідність: там прогін, у якому
|
||||
// хост правило пройшов, і є чесний сигнал «більше не порушено».
|
||||
//
|
||||
// доставка — подія приходить у netpulse-server, а канали, маршрути й
|
||||
// тихі години живуть у netpulse-api. Тому тут алерт лише
|
||||
// піднімається з позначкою notify_pending, а розсилає його движок
|
||||
// наступним тіком — під тим самим advisory-блокуванням, тобто в
|
||||
// одному екземплярі.
|
||||
|
||||
// SyslogEvent — рядок журналу у вигляді, потрібному правилам.
|
||||
//
|
||||
// Власний тип, а не protobuf: пакет алертів не має знати про транспорт
|
||||
// зондів, інакше кожна зміна .proto тягла б за собою правку движка.
|
||||
type SyslogEvent struct {
|
||||
DeviceID string
|
||||
Message string
|
||||
Tag string
|
||||
Severity int
|
||||
}
|
||||
|
||||
// ConfigEvent — те, що сталося з конфігом хоста.
|
||||
//
|
||||
// Kind: "changed" — приїхала версія, відмінна від попередньої;
|
||||
// "backup_failed" — збір не вдався.
|
||||
type ConfigEvent struct {
|
||||
DeviceID string
|
||||
ConfigType string
|
||||
Kind string
|
||||
Detail string
|
||||
}
|
||||
|
||||
// ComplianceEvent — результат перевірки одного правила на одному хості.
|
||||
type ComplianceEvent struct {
|
||||
RuleID string
|
||||
RuleName string
|
||||
Severity string
|
||||
DeviceID string
|
||||
Passed bool
|
||||
Line string
|
||||
LineNumber int
|
||||
}
|
||||
|
||||
// EventSink приймає події й піднімає за ними алерти.
|
||||
//
|
||||
// Безпечний для конкурентного використання: приймач журналу викликає
|
||||
// його з кожного стріму зонда.
|
||||
type EventSink struct {
|
||||
st *store.Store
|
||||
log *slog.Logger
|
||||
|
||||
// Як довго живе кеш правил і хостів тенанта.
|
||||
//
|
||||
// Кеш тут не оптимізація, а умова існування: без нього кожен рядок
|
||||
// журналу коштував би читання правил, розгортання селектора й
|
||||
// вибірки вікон обслуговування. Ціна — щойно створене правило
|
||||
// починає діяти не миттєво, і це чесний розмін: подія, яка сталася
|
||||
// за півхвилини до появи правила, і так під нього не підпадає.
|
||||
ttl time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
cache map[string]*tenantView
|
||||
rate map[string]*rateEntry
|
||||
}
|
||||
|
||||
func NewEventSink(st *store.Store, log *slog.Logger) *EventSink {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &EventSink{
|
||||
st: st,
|
||||
log: log.With("component", "alerting.events"),
|
||||
ttl: 30 * time.Second,
|
||||
cache: map[string]*tenantView{},
|
||||
rate: map[string]*rateEntry{},
|
||||
}
|
||||
}
|
||||
|
||||
// tenantView — усе, що потрібно знати про кабінет, щоб вирішити долю
|
||||
// події, не звертаючись до бази.
|
||||
type tenantView struct {
|
||||
at time.Time
|
||||
rules []compiledRule
|
||||
devices map[string]string // device_id → ім'я
|
||||
sup store.Suppression
|
||||
// Власний словник трапів кабінету. Потрібен лише для тексту
|
||||
// алерту: «linkDown на sw-core-01» замість
|
||||
// «1.3.6.1.6.3.1.1.5.3 на sw-core-01». Читається лише коли в
|
||||
// кабінеті є хоч одне правило на трапи — зайвий запит раз на пів
|
||||
// хвилини на кожного клієнта, який трапами не користується, нічого
|
||||
// не вартий рівно доти, доки клієнтів мало.
|
||||
trapNames map[string]store.TrapMeaning
|
||||
}
|
||||
|
||||
type compiledRule struct {
|
||||
rule store.Rule
|
||||
re *regexp.Regexp
|
||||
// Хости під селектором. nil означає «усі»: порожній селектор — це
|
||||
// найчастіший випадок, і перетворювати його на перелік означало б
|
||||
// щоразу відставати від щойно доданого хоста.
|
||||
scope map[string]bool
|
||||
}
|
||||
|
||||
func (c compiledRule) covers(deviceID string) bool {
|
||||
return c.scope == nil || c.scope[deviceID]
|
||||
}
|
||||
|
||||
// rateEntry — стан обмежувача частоти для одного алерту.
|
||||
type rateEntry struct {
|
||||
last time.Time
|
||||
// Події, що надійшли, поки діяв проміжок. Не викидаються: людині
|
||||
// важлива не кожна з них окремо, а те, що їх було багато.
|
||||
carried int
|
||||
touched time.Time
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Журнал
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// OnSyslog звіряє пачку рядків журналу з правилами джерела `syslog`.
|
||||
//
|
||||
// Зведення робиться до звернення до бази: пачка від зонда — це сотні
|
||||
// рядків, і половина з них зазвичай про один і той самий порт, що
|
||||
// мигає. Одна подія на пару «правило + хост» замість сотні запитів —
|
||||
// різниця між приймачем, який справляється, і тим, який гальмує самі
|
||||
// зонди.
|
||||
func (s *EventSink) OnSyslog(ctx context.Context, tenantID string, events []SyslogEvent) {
|
||||
if len(events) == 0 {
|
||||
return
|
||||
}
|
||||
view := s.view(ctx, tenantID)
|
||||
if view == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// ключ пари «правило+хост» → скільки збігів і останній текст
|
||||
type hit struct {
|
||||
rule compiledRule
|
||||
device string
|
||||
count int
|
||||
last string
|
||||
tag string
|
||||
sevSeen int
|
||||
}
|
||||
hits := map[string]*hit{}
|
||||
|
||||
for _, ev := range events {
|
||||
if ev.DeviceID == "" || ev.Message == "" {
|
||||
// Подія з невідомої адреси не належить нікому. Піднімати
|
||||
// алерт «десь у мережі щось сталося» — гірше, ніж мовчати:
|
||||
// з ним нічого не можна зробити.
|
||||
continue
|
||||
}
|
||||
for _, c := range view.rules {
|
||||
if c.rule.Source != "syslog" || c.re == nil || !c.covers(ev.DeviceID) {
|
||||
continue
|
||||
}
|
||||
if lte := c.rule.Condition.SeverityLTE; lte != nil && ev.Severity > *lte {
|
||||
continue
|
||||
}
|
||||
if t := c.rule.Condition.Tag; t != "" && !strings.EqualFold(t, ev.Tag) {
|
||||
continue
|
||||
}
|
||||
if !c.re.MatchString(ev.Message) {
|
||||
continue
|
||||
}
|
||||
key := store.EventDedupKey(c.rule.ID, ev.DeviceID)
|
||||
h, ok := hits[key]
|
||||
if !ok {
|
||||
h = &hit{rule: c, device: ev.DeviceID, sevSeen: ev.Severity}
|
||||
hits[key] = h
|
||||
}
|
||||
h.count++
|
||||
h.last = ev.Message
|
||||
h.tag = ev.Tag
|
||||
}
|
||||
}
|
||||
|
||||
for _, h := range hits {
|
||||
meta := map[string]any{
|
||||
"kind": "syslog",
|
||||
"pattern": h.rule.rule.Condition.Regex,
|
||||
"sample": trimLine(h.last),
|
||||
"tag": h.tag,
|
||||
}
|
||||
s.raise(ctx, tenantID, view, h.rule, h.device,
|
||||
trimLine(h.last), h.count, meta)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Конфіги
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// OnConfig піднімає алерти правил джерела `ncm`.
|
||||
//
|
||||
// Саме той сценарій, заради якого все це писалося: людина заводить
|
||||
// тригер «конфіг змінився», і він має спрацювати тоді, коли конфіг
|
||||
// змінився, — а не ніколи.
|
||||
func (s *EventSink) OnConfig(ctx context.Context, tenantID string, ev ConfigEvent) {
|
||||
if ev.DeviceID == "" || ev.Kind == "" {
|
||||
return
|
||||
}
|
||||
view := s.view(ctx, tenantID)
|
||||
if view == nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, c := range view.rules {
|
||||
if c.rule.Source != "ncm" || c.rule.Condition.Event != ev.Kind || !c.covers(ev.DeviceID) {
|
||||
continue
|
||||
}
|
||||
msg := "конфіг змінився (" + orDefault(ev.ConfigType, "running") + ")"
|
||||
if ev.Kind == "backup_failed" {
|
||||
msg = "збір конфігу не вдався: " + trimLine(ev.Detail)
|
||||
}
|
||||
meta := map[string]any{
|
||||
"kind": "ncm",
|
||||
"event": ev.Kind,
|
||||
"config_type": ev.ConfigType,
|
||||
"detail": trimLine(ev.Detail),
|
||||
}
|
||||
s.raise(ctx, tenantID, view, c, ev.DeviceID, msg, 1, meta)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Відповідність
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// OnCompliance переносить результат прогону у стан алертів.
|
||||
//
|
||||
// Єдине з подієвих джерел, у якого є зворотний бік. Прогін перевіряє
|
||||
// всі хости під правилом і каже про кожен «пройшов» або «ні» — отже,
|
||||
// «пройшов» і є той самий сигнал зняття, якого немає в журналі. Тому
|
||||
// тут алерт закривається сам, і це не виняток із правила, а наслідок
|
||||
// того, що дані інші.
|
||||
func (s *EventSink) OnCompliance(ctx context.Context, tenantID string, events []ComplianceEvent) {
|
||||
if len(events) == 0 {
|
||||
return
|
||||
}
|
||||
view := s.view(ctx, tenantID)
|
||||
if view == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var healed []string
|
||||
for _, ev := range events {
|
||||
if ev.DeviceID == "" {
|
||||
continue
|
||||
}
|
||||
for _, c := range view.rules {
|
||||
if c.rule.Source != "compliance" || !c.covers(ev.DeviceID) {
|
||||
continue
|
||||
}
|
||||
if !matchesComplianceRule(c.rule.Condition, ev) {
|
||||
continue
|
||||
}
|
||||
key := store.EventDedupKey(c.rule.ID, ev.DeviceID)
|
||||
if ev.Passed {
|
||||
healed = append(healed, key)
|
||||
continue
|
||||
}
|
||||
msg := fmt.Sprintf("порушено вимогу «%s»", ev.RuleName)
|
||||
if ev.Line != "" {
|
||||
msg = fmt.Sprintf("%s: рядок %d — %s", msg, ev.LineNumber, trimLine(ev.Line))
|
||||
}
|
||||
meta := map[string]any{
|
||||
"kind": "compliance",
|
||||
"compliance_rule": ev.RuleName,
|
||||
"compliance_id": ev.RuleID,
|
||||
"line": trimLine(ev.Line),
|
||||
"line_number": ev.LineNumber,
|
||||
"finding_severity": ev.Severity,
|
||||
}
|
||||
s.raise(ctx, tenantID, view, c, ev.DeviceID, msg, 1, meta)
|
||||
}
|
||||
}
|
||||
|
||||
if len(healed) > 0 {
|
||||
if _, err := s.st.ResolveEventAlerts(ctx, tenantID, healed,
|
||||
"хост пройшов перевірку відповідності"); err != nil {
|
||||
s.log.Error("закриття алертів відповідності", "tenant", tenantID, "помилка", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// matchesComplianceRule звужує тригер до частини знахідок.
|
||||
//
|
||||
// Порожня умова означає «будь-яке порушення»: тригер «скажи мені, коли
|
||||
// щось поїхало» — найчастіший і найкорисніший, і вимагати для нього
|
||||
// переліку правил означало б, що новий стандарт, доданий завтра, під
|
||||
// нього не підпаде.
|
||||
func matchesComplianceRule(cond store.Condition, ev ComplianceEvent) bool {
|
||||
if len(cond.RuleIDs) > 0 {
|
||||
var found bool
|
||||
for _, id := range cond.RuleIDs {
|
||||
if id == ev.RuleID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if cond.MinSeverity != "" && store.SeverityRank(ev.Severity) < store.SeverityRank(cond.MinSeverity) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// SNMP-трапи
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// TrapVarbind — одне поле трапа у вигляді, потрібному правилам.
|
||||
type TrapVarbind struct {
|
||||
OID string
|
||||
Value string
|
||||
}
|
||||
|
||||
// TrapEvent — трап, зведений до того, про що можна запитати в умові.
|
||||
//
|
||||
// Власний тип, а не protobuf: пакет алертів не має знати про транспорт
|
||||
// зондів. DeviceID порожній, якщо адресу відправника не вдалося
|
||||
// зіставити з хостом, — і це не помилка, див. OnTrap.
|
||||
type TrapEvent struct {
|
||||
DeviceID string
|
||||
SourceIP string
|
||||
TrapOID string
|
||||
Varbinds []TrapVarbind
|
||||
}
|
||||
|
||||
// OnTrap звіряє пачку трапів із правилами джерела `trap`.
|
||||
//
|
||||
// Зведення робиться до звернення до бази — так само, як для журналу:
|
||||
// порт, що фліпає, дає linkDown/linkUp пачками, і сто UPSERT-ів замість
|
||||
// одного тут нічого не додають.
|
||||
//
|
||||
// Окреме рішення, яке варто знати. Трап БЕЗ хоста піднімає алерт лише
|
||||
// тоді, коли правило явно назвало адресу джерела (умова source_ip).
|
||||
// Причина в тому, що алерт без хоста нікуди не маршрутизується, не
|
||||
// глушиться вікном обслуговування й майже нічого не каже черговому:
|
||||
// «трап від 10.20.0.77» — це питання, а не аварія. Робити з кожного
|
||||
// такого питання алерт означало б залити дошку тим, з чим о третій ночі
|
||||
// не можна зробити нічого.
|
||||
//
|
||||
// Але й губити їх не можна: незнайома адреса, що шле трапи, — часто
|
||||
// перший слід нового заліза в мережі. Тому вони не зникають, а
|
||||
// потрапляють у власний перелік (inv.trap_unknown_sources), який видно
|
||||
// на сторінці трапів окремим блоком. Алерт — для того, що вже знаєш;
|
||||
// перелік — для того, чого ще не знаєш.
|
||||
func (s *EventSink) OnTrap(ctx context.Context, tenantID string, events []TrapEvent) {
|
||||
if len(events) == 0 {
|
||||
return
|
||||
}
|
||||
view := s.view(ctx, tenantID)
|
||||
if view == nil {
|
||||
return
|
||||
}
|
||||
|
||||
hits := map[string]*trapHit{}
|
||||
for _, ev := range events {
|
||||
for _, c := range view.rules {
|
||||
if c.rule.Source != "trap" || !trapMatches(c, ev) {
|
||||
continue
|
||||
}
|
||||
key := store.TrapDedupKey(c.rule.ID, ev.DeviceID, ev.SourceIP)
|
||||
h, ok := hits[key]
|
||||
if !ok {
|
||||
h = &trapHit{rule: c, device: ev.DeviceID, ip: ev.SourceIP, oid: ev.TrapOID}
|
||||
hits[key] = h
|
||||
}
|
||||
h.count++
|
||||
h.last = ev
|
||||
}
|
||||
}
|
||||
|
||||
for key, h := range hits {
|
||||
meaning := store.ResolveTrapOID(view.trapNames, h.oid)
|
||||
what := meaning.Name
|
||||
if what == "" {
|
||||
// Назви немає — так і кажемо. Вигадана за схожістю префікса
|
||||
// назва в заголовку алерту була б найгіршим із можливих
|
||||
// варіантів: саме заголовок читають, коли вирішують, чи
|
||||
// вставати.
|
||||
what = "невідомий трап " + orDefault(h.oid, "без OID")
|
||||
}
|
||||
msg := what
|
||||
if h.device == "" {
|
||||
msg += " від " + h.ip + " (адреси немає серед хостів)"
|
||||
}
|
||||
if detail := trapDetail(view, h.last); detail != "" {
|
||||
msg += " · " + detail
|
||||
}
|
||||
|
||||
meta := map[string]any{
|
||||
"kind": "trap",
|
||||
"trap_oid": h.oid,
|
||||
"trap_name": meaning.Name,
|
||||
"source_ip": h.ip,
|
||||
"varbinds": trapVarbindMeta(view, h.last),
|
||||
}
|
||||
// Підпис для алерту без хоста — сама адреса: це єдине, що про
|
||||
// такого відправника взагалі відомо.
|
||||
label := ""
|
||||
if h.device == "" {
|
||||
label = h.ip
|
||||
}
|
||||
s.raiseKeyed(ctx, tenantID, view, h.rule, key, h.device, label, msg, h.count, meta)
|
||||
}
|
||||
}
|
||||
|
||||
// trapHit — зведення однакових трапів до одного звернення до бази.
|
||||
type trapHit struct {
|
||||
rule compiledRule
|
||||
device string
|
||||
ip string
|
||||
oid string
|
||||
count int
|
||||
last TrapEvent
|
||||
}
|
||||
|
||||
// trapMatches — чи підпадає трап під умову правила.
|
||||
func trapMatches(c compiledRule, ev TrapEvent) bool {
|
||||
cond := c.rule.Condition
|
||||
|
||||
if oid := store.NormalizeOID(cond.TrapOID); oid != "" {
|
||||
if store.NormalizeOID(ev.TrapOID) != oid {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if src := strings.TrimSpace(cond.SourceIP); src != "" {
|
||||
if !ipMatches(src, ev.SourceIP) {
|
||||
return false
|
||||
}
|
||||
} else if ev.DeviceID == "" {
|
||||
// Трап без хоста й без явно названої адреси — не алерт.
|
||||
// Пояснення в коментарі до OnTrap.
|
||||
return false
|
||||
}
|
||||
|
||||
// Селектор перевіряємо лише там, де хост є: він оперує хостами, і
|
||||
// застосувати його до адреси, якої немає в інвентарі, неможливо.
|
||||
// Тому правило з адресою джерела працює й для незнайомців — інакше
|
||||
// саме той випадок, заради якого адресу й вписали, не спрацював би
|
||||
// ніколи.
|
||||
if ev.DeviceID != "" && !c.covers(ev.DeviceID) {
|
||||
return false
|
||||
}
|
||||
|
||||
if vbOID := store.NormalizeOID(cond.VarbindOID); vbOID != "" {
|
||||
want := strings.TrimSpace(cond.VarbindValue)
|
||||
var found bool
|
||||
for _, vb := range ev.Varbinds {
|
||||
if !varbindIs(vb.OID, vbOID) {
|
||||
continue
|
||||
}
|
||||
// Порожнє очікуване значення означає «щоб такий varbind
|
||||
// узагалі був»: умова «трап, у якому є ifIndex» осмислена й
|
||||
// відсіює половину службового шуму.
|
||||
if want == "" || vb.Value == want {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// varbindIs порівнює OID varbind-а з OID стовпця.
|
||||
//
|
||||
// Збіг рахується й за префіксом: у трапі приходить ifOperStatus.7 —
|
||||
// конкретний порт, — а в умові людина пише ifOperStatus без індексу, бо
|
||||
// індексу наперед не знає. Вимагати повного збігу означало б, що умова
|
||||
// «ifOperStatus = down» працює рівно для сьомого порту.
|
||||
func varbindIs(got, want string) bool {
|
||||
return got == want || strings.HasPrefix(got, want+".")
|
||||
}
|
||||
|
||||
// ipMatches — чи належить адреса відправника тому, що написано в умові.
|
||||
func ipMatches(pattern, ip string) bool {
|
||||
addr := net.ParseIP(ip)
|
||||
if addr == nil {
|
||||
return false
|
||||
}
|
||||
if _, netw, err := net.ParseCIDR(pattern); err == nil {
|
||||
return netw.Contains(addr)
|
||||
}
|
||||
return net.ParseIP(pattern).Equal(addr)
|
||||
}
|
||||
|
||||
// trapDetail добирає з varbind-ів те, що варто показати в тексті.
|
||||
//
|
||||
// Не всі підряд: у повідомленні алерту (а звідти — у Telegram) десяток
|
||||
// OID-ів займе весь екран і не пояснить нічого. Беремо ті, у яких є
|
||||
// людська назва, — тобто ті, які словник упізнав. Решта лежить у
|
||||
// контексті алерту й на сторінці трапів.
|
||||
func trapDetail(view *tenantView, ev TrapEvent) string {
|
||||
var parts []string
|
||||
for _, vb := range ev.Varbinds {
|
||||
name := store.ResolveVarbindOID(view.trapNames, vb.OID)
|
||||
if name == "" || name == "sysUpTime" || name == "snmpTrapOID" {
|
||||
continue
|
||||
}
|
||||
val := vb.Value
|
||||
if lbl := store.DescribeVarbindValue(vb.OID, vb.Value); lbl != "" {
|
||||
val = lbl
|
||||
}
|
||||
parts = append(parts, name+"="+val)
|
||||
if len(parts) == 4 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// trapVarbindMeta кладе varbind-и в контекст алерту.
|
||||
func trapVarbindMeta(view *tenantView, ev TrapEvent) []map[string]string {
|
||||
out := make([]map[string]string, 0, len(ev.Varbinds))
|
||||
for _, vb := range ev.Varbinds {
|
||||
m := map[string]string{"oid": vb.OID, "value": vb.Value}
|
||||
if name := store.ResolveVarbindOID(view.trapNames, vb.OID); name != "" {
|
||||
m["name"] = name
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Спільне
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// raise доводить один збіг до алерту.
|
||||
func (s *EventSink) raise(ctx context.Context, tenantID string, view *tenantView,
|
||||
c compiledRule, deviceID, message string, count int, meta map[string]any) {
|
||||
|
||||
s.raiseKeyed(ctx, tenantID, view, c,
|
||||
store.EventDedupKey(c.rule.ID, deviceID), deviceID, "", message, count, meta)
|
||||
}
|
||||
|
||||
// raiseKeyed — те саме, але з явним ключем дедуплікації й підписом.
|
||||
//
|
||||
// Знадобилось рівно одному джерелу — трапам. Усі інші події приходять
|
||||
// від хоста, і хост дає і ключ, і назву в заголовку. Трап приходить від
|
||||
// АДРЕСИ, і адреса не завжди є хостом: саме такі трапи найцікавіші
|
||||
// (у мережі з'явилось щось, чого інвентар не знає), і зводити їх усі до
|
||||
// одного безіменного алерту означало б показати «щось десь сталося».
|
||||
func (s *EventSink) raiseKeyed(ctx context.Context, tenantID string, view *tenantView,
|
||||
c compiledRule, key, deviceID, label, message string, count int, meta map[string]any) {
|
||||
|
||||
allowed, carried := s.throttle(key, c.rule.MinIntervalSeconds, count)
|
||||
if !allowed {
|
||||
return
|
||||
}
|
||||
|
||||
name := view.devices[deviceID]
|
||||
if name == "" {
|
||||
name = label
|
||||
}
|
||||
if name == "" {
|
||||
name = deviceID
|
||||
}
|
||||
meta["events"] = carried
|
||||
ctxJSON, err := json.Marshal(meta)
|
||||
if err != nil {
|
||||
ctxJSON = []byte("{}")
|
||||
}
|
||||
|
||||
fired, err := s.st.RaiseEventAlert(ctx, tenantID, store.EventAlert{
|
||||
RuleID: c.rule.ID,
|
||||
DeviceID: deviceID,
|
||||
DeviceName: name,
|
||||
Severity: c.rule.Severity,
|
||||
Title: name + ": " + c.rule.Name,
|
||||
Message: message,
|
||||
DedupKey: key,
|
||||
Context: ctxJSON,
|
||||
Count: carried,
|
||||
SuppressedBy: view.sup.For(deviceID, c.rule.ID),
|
||||
})
|
||||
if err != nil {
|
||||
s.log.Error("подієвий алерт", "правило", c.rule.Name, "помилка", err)
|
||||
return
|
||||
}
|
||||
if !fired.IsNew {
|
||||
// Продовження вже відомої події не показуємо окремо: лічильник
|
||||
// у самому алерті вже виріс, а список алертів перечитується за
|
||||
// подією `alert.fired`, якої тут навмисно немає.
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.st.PublishEvent(ctx, tenantID, "alert.fired", map[string]any{
|
||||
"alert_id": fired.ID, "device_id": deviceID, "severity": fired.Severity,
|
||||
"title": fired.Title, "state": fired.State, "suppressed_by": fired.SuppressedBy,
|
||||
}); err != nil {
|
||||
s.log.Error("подія alert.fired", "помилка", err)
|
||||
}
|
||||
}
|
||||
|
||||
// throttle вирішує, чи йти в базу зараз.
|
||||
//
|
||||
// Обмежувач у пам'яті, а не в SQL, бо захищати треба саме звернення до
|
||||
// бази: у потоці журналу дорогим є не сам UPSERT, а те, що їх сотня на
|
||||
// секунду з кожного зонда. Кілька процесів матимуть кожен свій
|
||||
// обмежувач — і це нормально: остаточну дедуплікацію все одно робить
|
||||
// унікальний індекс, а тут йдеться лише про кількість спроб.
|
||||
//
|
||||
// Повертає, скільки подій слід записати: власні плюс усі, що набігли,
|
||||
// поки проміжок не минув.
|
||||
func (s *EventSink) throttle(key string, minInterval, count int) (bool, int) {
|
||||
if minInterval <= 0 {
|
||||
return true, count
|
||||
}
|
||||
now := time.Now()
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
e, ok := s.rate[key]
|
||||
if !ok {
|
||||
e = &rateEntry{}
|
||||
s.rate[key] = e
|
||||
}
|
||||
e.touched = now
|
||||
if ok && now.Sub(e.last) < time.Duration(minInterval)*time.Second {
|
||||
e.carried += count
|
||||
return false, 0
|
||||
}
|
||||
e.last = now
|
||||
total := e.carried + count
|
||||
e.carried = 0
|
||||
return true, total
|
||||
}
|
||||
|
||||
// view віддає стан кабінету з кешу, оновлюючи його за потреби.
|
||||
func (s *EventSink) view(ctx context.Context, tenantID string) *tenantView {
|
||||
now := time.Now()
|
||||
|
||||
s.mu.Lock()
|
||||
v, ok := s.cache[tenantID]
|
||||
s.mu.Unlock()
|
||||
if ok && now.Sub(v.at) < s.ttl {
|
||||
return v
|
||||
}
|
||||
|
||||
rules, err := s.st.EventRules(ctx, tenantID)
|
||||
if err != nil {
|
||||
s.log.Error("читання подієвих правил", "tenant", tenantID, "помилка", err)
|
||||
// Стухлий кеш кращий за жодного: правила рідко міняються, а
|
||||
// перебій у базі не має вимикати алерти на весь час перебою.
|
||||
return v
|
||||
}
|
||||
if len(rules) == 0 {
|
||||
fresh := &tenantView{at: now, devices: map[string]string{}}
|
||||
s.remember(tenantID, fresh)
|
||||
return fresh
|
||||
}
|
||||
|
||||
devices, err := s.st.DeviceNames(ctx, tenantID)
|
||||
if err != nil {
|
||||
s.log.Error("читання хостів", "tenant", tenantID, "помилка", err)
|
||||
return v
|
||||
}
|
||||
sup, err := s.st.LoadSuppression(ctx, tenantID)
|
||||
if err != nil {
|
||||
// Не привід не піднімати алерти: гірше показати те, про що
|
||||
// просили не турбувати, ніж проґавити справжню подію.
|
||||
s.log.Warn("вікна обслуговування", "tenant", tenantID, "помилка", err)
|
||||
}
|
||||
|
||||
fresh := &tenantView{at: now, devices: devices, sup: sup}
|
||||
if hasSource(rules, "trap") {
|
||||
// Помилка тут не має вимикати правила: без словника трап
|
||||
// підпаде під умову так само (умова написана OID-ом), просто в
|
||||
// заголовку алерту стоятиме число замість назви. Зворотний
|
||||
// розмін — тиша замість неідеального тексту — був би гіршим.
|
||||
names, err := s.st.TrapNames(ctx, tenantID)
|
||||
if err != nil {
|
||||
s.log.Warn("словник трапів", "tenant", tenantID, "помилка", err)
|
||||
}
|
||||
fresh.trapNames = names
|
||||
}
|
||||
for _, r := range rules {
|
||||
c := compiledRule{rule: r}
|
||||
if r.Source == "syslog" {
|
||||
re, err := regexp.Compile(r.Condition.Regex)
|
||||
if err != nil {
|
||||
// Зразок перевіряється при збереженні, тож сюди можна
|
||||
// дістатись лише правкою в обхід API. Мовчати не можна:
|
||||
// правило виглядає ввімкненим.
|
||||
s.log.Error("зразок правила не компілюється",
|
||||
"правило", r.Name, "помилка", err)
|
||||
continue
|
||||
}
|
||||
c.re = re
|
||||
}
|
||||
if !emptySelector(r.Selector) {
|
||||
ids, err := s.st.SelectorDevices(ctx, tenantID, r.Selector)
|
||||
if err != nil {
|
||||
s.log.Error("розгортання селектора", "правило", r.Name, "помилка", err)
|
||||
continue
|
||||
}
|
||||
c.scope = make(map[string]bool, len(ids))
|
||||
for _, id := range ids {
|
||||
c.scope[id] = true
|
||||
}
|
||||
}
|
||||
fresh.rules = append(fresh.rules, c)
|
||||
}
|
||||
|
||||
s.remember(tenantID, fresh)
|
||||
return fresh
|
||||
}
|
||||
|
||||
func (s *EventSink) remember(tenantID string, v *tenantView) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.cache[tenantID] = v
|
||||
|
||||
// Обмежувач частоти тримає по рядку на кожен алерт, який колись
|
||||
// піднімався. Без прибирання це повільний витік у процесі, що
|
||||
// живе місяцями; година тиші означає, що алерт давно закритий.
|
||||
cutoff := time.Now().Add(-time.Hour)
|
||||
for k, e := range s.rate {
|
||||
if e.touched.Before(cutoff) {
|
||||
delete(s.rate, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func hasSource(rules []store.Rule, source string) bool {
|
||||
for _, r := range rules {
|
||||
if r.Source == source {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func emptySelector(s store.Selector) bool {
|
||||
return len(s.DeviceIDs) == 0 && len(s.GroupIDs) == 0 && len(s.SiteIDs) == 0 &&
|
||||
len(s.Kinds) == 0 && len(s.TemplateIDs) == 0 && len(s.Vendors) == 0 &&
|
||||
len(s.Tags) == 0
|
||||
}
|
||||
|
||||
// trimLine готує текст події до показу людині.
|
||||
//
|
||||
// Рядок журналу буває довжиною в кілограм: у заголовку алерту й у
|
||||
// повідомленні в Telegram від цього немає користі, а є втрата решти
|
||||
// тексту.
|
||||
func trimLine(s string) string {
|
||||
s = strings.TrimSpace(strings.ReplaceAll(s, "\n", " "))
|
||||
const max = 300
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
r := []rune(s)
|
||||
if len(r) <= max {
|
||||
return s
|
||||
}
|
||||
return string(r[:max]) + "…"
|
||||
}
|
||||
|
||||
func orDefault(s, def string) string {
|
||||
if s == "" {
|
||||
return def
|
||||
}
|
||||
return s
|
||||
}
|
||||
113
server/internal/alerting/events_test.go
Normal file
113
server/internal/alerting/events_test.go
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
package alerting
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/netpulse/netpulse/server/internal/store"
|
||||
)
|
||||
|
||||
// Головне, заради чого писався обмежувач: потік журналу не має
|
||||
// перетворюватись на потік звернень до бази.
|
||||
func TestThrottleCollapsesFlood(t *testing.T) {
|
||||
s := NewEventSink(nil, nil)
|
||||
|
||||
ok, n := s.throttle("r:dev:d1", 60, 1)
|
||||
if !ok || n != 1 {
|
||||
t.Fatalf("перша подія має пройти з лічильником 1, отримали %v/%d", ok, n)
|
||||
}
|
||||
for i := 0; i < 500; i++ {
|
||||
if ok, _ := s.throttle("r:dev:d1", 60, 1); ok {
|
||||
t.Fatal("подія в межах проміжку не має йти в базу")
|
||||
}
|
||||
}
|
||||
|
||||
// Проміжок минув — накопичене доливається одним зверненням, а не
|
||||
// губиться: людині важливо не кожне повідомлення окремо, а те, що
|
||||
// їх було п'ятсот.
|
||||
s.rate["r:dev:d1"].last = time.Now().Add(-2 * time.Minute)
|
||||
ok, n = s.throttle("r:dev:d1", 60, 1)
|
||||
if !ok {
|
||||
t.Fatal("після проміжку подія має пройти")
|
||||
}
|
||||
if n != 501 {
|
||||
t.Fatalf("накопичене мало долитись: очікували 501, отримали %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Різні хости — різні алерти, і обмежувач одного не має глушити інший.
|
||||
func TestThrottleIsPerAlert(t *testing.T) {
|
||||
s := NewEventSink(nil, nil)
|
||||
if ok, _ := s.throttle("r:dev:d1", 60, 1); !ok {
|
||||
t.Fatal("перша подія хоста d1")
|
||||
}
|
||||
if ok, _ := s.throttle("r:dev:d2", 60, 1); !ok {
|
||||
t.Fatal("хост d2 не має залежати від d1")
|
||||
}
|
||||
}
|
||||
|
||||
// Нульовий проміжок означає «без обмежень»: правило про рідкісну подію
|
||||
// не має чекати хвилину, щоб про неї сказати.
|
||||
func TestThrottleZeroIntervalAlwaysPasses(t *testing.T) {
|
||||
s := NewEventSink(nil, nil)
|
||||
for i := 0; i < 5; i++ {
|
||||
if ok, n := s.throttle("k", 0, 3); !ok || n != 3 {
|
||||
t.Fatalf("без проміжку має проходити все, отримали %v/%d", ok, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Порожній селектор — «усі хости». Це найчастіший випадок, і саме він
|
||||
// має працювати без перетворення на перелік.
|
||||
func TestScopeCoversEverythingWhenEmpty(t *testing.T) {
|
||||
if !(compiledRule{}).covers("будь-який") {
|
||||
t.Fatal("правило без селектора має накривати всіх")
|
||||
}
|
||||
c := compiledRule{scope: map[string]bool{"d1": true}}
|
||||
if !c.covers("d1") || c.covers("d2") {
|
||||
t.Fatal("правило із селектором має накривати лише своїх")
|
||||
}
|
||||
}
|
||||
|
||||
// Тригер без переліку правил відповідності ловить будь-яке порушення:
|
||||
// стандарт, доданий завтра, має підпасти під нього сам.
|
||||
func TestComplianceMatchDefaultsToEverything(t *testing.T) {
|
||||
ev := ComplianceEvent{RuleID: "c1", Severity: "warning"}
|
||||
if !matchesComplianceRule(store.Condition{}, ev) {
|
||||
t.Fatal("порожня умова має ловити будь-яку знахідку")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComplianceMatchNarrowsByRuleAndSeverity(t *testing.T) {
|
||||
ev := ComplianceEvent{RuleID: "c1", Severity: "warning"}
|
||||
|
||||
if matchesComplianceRule(store.Condition{RuleIDs: []string{"c2"}}, ev) {
|
||||
t.Error("чуже правило відповідності не має підпадати")
|
||||
}
|
||||
if !matchesComplianceRule(store.Condition{RuleIDs: []string{"c1", "c2"}}, ev) {
|
||||
t.Error("своє правило має підпадати")
|
||||
}
|
||||
if matchesComplianceRule(store.Condition{MinSeverity: "high"}, ev) {
|
||||
t.Error("знахідка нижче порога не має підпадати")
|
||||
}
|
||||
if !matchesComplianceRule(store.Condition{MinSeverity: "info"}, ev) {
|
||||
t.Error("знахідка вище порога має підпадати")
|
||||
}
|
||||
}
|
||||
|
||||
// Рядок журналу буває довжиною в кілограм. У заголовку алерту від
|
||||
// цього немає користі, а є втрата решти тексту.
|
||||
func TestTrimLineFlattensAndCuts(t *testing.T) {
|
||||
if got := trimLine(" два\nрядки "); got != "два рядки" {
|
||||
t.Errorf("отримали %q", got)
|
||||
}
|
||||
long := strings.Repeat("я", 500)
|
||||
got := trimLine(long)
|
||||
if !strings.HasSuffix(got, "…") {
|
||||
t.Error("надто довгий рядок має бути обрізаний із багатокрапкою")
|
||||
}
|
||||
if len([]rune(got)) != 301 {
|
||||
t.Errorf("очікували 300 символів плюс багатокрапка, отримали %d", len([]rune(got)))
|
||||
}
|
||||
}
|
||||
|
|
@ -74,18 +74,85 @@ func (n *Notifier) Dispatch(ctx context.Context, tenantID string, alerts []store
|
|||
actions = map[string]store.RuleAction{}
|
||||
}
|
||||
|
||||
// Політики ескалації читаються раз на партію, а не раз на алерт: під
|
||||
// час масової аварії партія — це сотні алертів на десяток правил, і
|
||||
// драбина в них та сама.
|
||||
//
|
||||
// Помилка тут не має зупиняти доставку: без ескалації сповіщення все
|
||||
// одно піде, а от навпаки — ні.
|
||||
policies := map[string]store.EscalationPolicy{}
|
||||
if ps, err := n.st.ListEscalationPolicies(ctx, tenantID); err != nil {
|
||||
n.log.Error("читання політик ескалації", "tenant", tenantID, "помилка", err)
|
||||
} else {
|
||||
for _, p := range ps {
|
||||
policies[p.ID] = p
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
for _, a := range alerts {
|
||||
act, hasAct := actions[a.RuleID]
|
||||
if hasAct && a.State == "resolved" && !act.NotifyOnResolve {
|
||||
continue
|
||||
}
|
||||
for _, c := range n.targets(a, act, hasAct, routes, channels, byID, now, tenantID) {
|
||||
targets := n.targets(a, act, hasAct, routes, channels, byID, now, tenantID)
|
||||
for _, c := range targets {
|
||||
n.send(ctx, tenantID, a, c)
|
||||
}
|
||||
n.armEscalation(ctx, tenantID, a, act, hasAct, policies, len(targets))
|
||||
}
|
||||
}
|
||||
|
||||
// armEscalation ставить драбину на бойовий звід — або свідомо не ставить.
|
||||
//
|
||||
// Три умови, і кожна з них — окрема відповідь на «чому мене розбудили»
|
||||
// (та на дзеркальне «чому не розбудили»):
|
||||
//
|
||||
// - Драбина взводиться лише для 'firing'. Придушений алерт — це «не
|
||||
// турбувати», а сповіщення про відновлення ескалювати нема сенсу:
|
||||
// воно вже добра новина.
|
||||
//
|
||||
// - Драбина взводиться лише тоді, коли перше сповіщення справді мало
|
||||
// куди піти. Якщо жодного каналу не знайшлось — вимкнені канали,
|
||||
// поріг серйозності, тиха година правила — то ескалація о 15-й
|
||||
// хвилині надіслала б те, що на нульовій ми свідомо не надіслали.
|
||||
// Це виглядало б як несправність, і саме так і було б.
|
||||
//
|
||||
// - Політику називає саме правило. Немає політики — немає драбини, і
|
||||
// це типовий стан: після оновлення жоден кабінет не має раптом
|
||||
// почати будити людей.
|
||||
func (n *Notifier) armEscalation(ctx context.Context, tenantID string, a store.Alert,
|
||||
act store.RuleAction, hasAct bool, policies map[string]store.EscalationPolicy, sent int) {
|
||||
|
||||
if !shouldArmEscalation(a, act, hasAct, sent) {
|
||||
return
|
||||
}
|
||||
p, ok := policies[act.EscalationPolicyID]
|
||||
if !ok || len(p.Steps) == 0 {
|
||||
// Правило посилається на політику, якої вже немає (або в неї
|
||||
// немає жодної сходинки). Мовчати про це не можна: людина
|
||||
// лишила галочку в формі й вважає, що її прикриють.
|
||||
n.log.Warn("правило посилається на порожню або зниклу політику ескалації",
|
||||
"tenant", tenantID, "правило", a.RuleName, "політика", act.EscalationPolicyID)
|
||||
return
|
||||
}
|
||||
if err := n.st.ArmEscalation(ctx, tenantID, a.ID, p.ID,
|
||||
store.IsEventSource(act.Source), p, a.StartedAt); err != nil {
|
||||
n.log.Error("взведення ескалації", "алерт", a.ID, "помилка", err)
|
||||
}
|
||||
}
|
||||
|
||||
// shouldArmEscalation — сам вирішувач, окремо від запису в базу.
|
||||
//
|
||||
// Виділено в чисту функцію навмисно: усі три його «ні» коштують чийогось
|
||||
// сну, а перевірити їх інакше можна лише піднявши базу. Тест на «драбину
|
||||
// взведено» доводить мало; тести на «драбину НЕ взведено, бо каналів не
|
||||
// знайшлось / алерт придушено / політики немає» — це і є те, заради чого
|
||||
// функція існує.
|
||||
func shouldArmEscalation(a store.Alert, act store.RuleAction, hasAct bool, sent int) bool {
|
||||
return a.State == "firing" && sent > 0 && hasAct && act.EscalationPolicyID != ""
|
||||
}
|
||||
|
||||
// targets вирішує, у які канали піде цей алерт.
|
||||
//
|
||||
// Порядок вирішення: канали самого правила → маршрути тенанта → усі
|
||||
|
|
@ -159,7 +226,15 @@ func (n *Notifier) targets(a store.Alert, act store.RuleAction, hasAct bool,
|
|||
}
|
||||
|
||||
func (n *Notifier) send(ctx context.Context, tenantID string, a store.Alert, c store.Channel) {
|
||||
text := renderMessage(a, c)
|
||||
n.sendText(ctx, tenantID, a, c, renderMessage(a, c))
|
||||
}
|
||||
|
||||
// sendText відокремлено від send, бо ескалація шле той самий алерт із
|
||||
// іншою шапкою: людина, розбуджена третьою сходинкою, має з першого
|
||||
// рядка бачити, що це вже ескалація, а не перше сповіщення, яке вона
|
||||
// проспала.
|
||||
func (n *Notifier) sendText(ctx context.Context, tenantID string, a store.Alert,
|
||||
c store.Channel, text string) {
|
||||
|
||||
var extID string
|
||||
var err error
|
||||
|
|
@ -274,13 +349,28 @@ func (n *Notifier) sendTelegram(ctx context.Context, c store.Channel, a store.Al
|
|||
if cfg.ThreadID != 0 {
|
||||
body["message_thread_id"] = cfg.ThreadID
|
||||
}
|
||||
// Кнопки ведуть на веб: підтвердити алерт із телефона — головний
|
||||
// сценарій чергового, і зайвий перехід у браузер тут коштує хвилин.
|
||||
body["reply_markup"] = map[string]any{
|
||||
"inline_keyboard": [][]map[string]string{{
|
||||
// Кнопки: підтвердити алерт із телефона — головний сценарій
|
||||
// чергового, і зайвий перехід у браузер тут коштує хвилин.
|
||||
// Натискання приймає telegram_bot.go; те, що лежить у callback_data,
|
||||
// нічого не авторизує — кабінет і особа з'ясовуються там із чату й
|
||||
// прив'язки, а не з цього рядка.
|
||||
//
|
||||
// Кнопки немає в пробного повідомлення (TestChannel): алерту не
|
||||
// існує, і «Підтвердити» показувало б дію, якої не буде.
|
||||
if a.ID != "" {
|
||||
row := []map[string]string{
|
||||
{"text": "Підтвердити", "callback_data": "ack:" + a.ID},
|
||||
{"text": "Заглушити 1 год", "callback_data": "mute:" + a.DeviceID},
|
||||
}},
|
||||
}
|
||||
// «Заглушити» без хоста глушити нічого. Алерт без пристрою —
|
||||
// звичайна річ (зонд не на зв'язку, черга не встигає), і досі
|
||||
// така кнопка малювалась усе одно: у callback_data їхало голе
|
||||
// «mute:», а натискання не могло дати нічого, крім відмови.
|
||||
if a.DeviceID != "" {
|
||||
row = append(row, map[string]string{
|
||||
"text": "Заглушити 1 год", "callback_data": "mute:" + a.DeviceID,
|
||||
})
|
||||
}
|
||||
body["reply_markup"] = map[string]any{"inline_keyboard": [][]map[string]string{row}}
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(body)
|
||||
|
|
|
|||
662
server/internal/alerting/telegram_bot.go
Normal file
662
server/internal/alerting/telegram_bot.go
Normal file
|
|
@ -0,0 +1,662 @@
|
|||
package alerting
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/netpulse/netpulse/server/internal/crypto"
|
||||
"github.com/netpulse/netpulse/server/internal/store"
|
||||
)
|
||||
|
||||
// Приймач натискань кнопок Telegram.
|
||||
//
|
||||
// ЧОМУ ДОВГЕ ОПИТУВАННЯ, А НЕ ВЕБХУК
|
||||
//
|
||||
// Bot API дає два способи отримувати оновлення, і вибір тут зробило
|
||||
// саме розгортання, а не смак.
|
||||
//
|
||||
// Вебхук вимагає, щоб Telegram МІГ ДО НАС ДОСТУКАТИСЬ: публічний
|
||||
// порт із переліку 443/80/88/8443 і TLS-сертифікат, якому довіряє
|
||||
// їхній бік. Самопідписаний приймається лише як завантажений у
|
||||
// setWebhook файл, і навіть тоді потрібне ім'я, на яке він виданий.
|
||||
// Наш стенд — самопідписаний TLS на голій IP-адресі без домену. Це не
|
||||
// «поки не налаштували», а стан, у якому продукт живе: self-hosted
|
||||
// інсталяція в мережі оператора зазвичай узагалі не має входу ззовні.
|
||||
// Вебхук там не запрацює ніколи, і код, написаний під нього, був би
|
||||
// кодом, який не працює в жодній наявній інсталяції.
|
||||
//
|
||||
// Довге опитування не вимагає від нас ані вхідного порту, ані імені,
|
||||
// ані сертифіката: з'єднання ініціює сервер, TLS перевіряється в бік
|
||||
// api.telegram.org, тобто в той бік, де сертифікат справжній. Ціна —
|
||||
// одне висяче HTTP-з'єднання на бота й курсор у базі (0061).
|
||||
//
|
||||
// Секретний токен у заголовку X-Telegram-Bot-Api-Secret-Token — це
|
||||
// захист вебхука від сторонніх POST-ів на наш відкритий шлях. Тут
|
||||
// відкритого шляху немає взагалі: приймати нема чого, ми самі ходимо
|
||||
// по оновлення й показуємо в URL токен бота. Отвору, який той заголовок
|
||||
// затуляє, у цій схемі не існує.
|
||||
//
|
||||
// Якщо колись з'явиться домен і справжній сертифікат, вебхук стане
|
||||
// кращим (менше з'єднань, менша затримка) — і перевірка натискання
|
||||
// (chatMatch → прив'язка → права → дія) переїде в нього без змін: вона
|
||||
// навмисно не знає, звідки прийшло оновлення.
|
||||
|
||||
// Bot читає оновлення ботів усіх кабінетів і виконує натиснуте.
|
||||
type Bot struct {
|
||||
st *store.Store
|
||||
ring *crypto.Keyring
|
||||
log *slog.Logger
|
||||
|
||||
// hc — для довгого опитування. Таймаут свідомо більший за
|
||||
// pollTimeout: getUpdates мовчить рівно стільки, скільки просили,
|
||||
// і клієнт, який рветься раніше, перетворював би штатне очікування
|
||||
// на потік помилок.
|
||||
hc *http.Client
|
||||
// action — для коротких викликів (відповідь на натискання,
|
||||
// редагування повідомлення). Окремий клієнт, бо чекати на них 40
|
||||
// секунд немає жодного сенсу.
|
||||
action *http.Client
|
||||
|
||||
pollTimeout time.Duration
|
||||
}
|
||||
|
||||
// NewBot створює приймач. ring обов'язковий: без ключів шифрування
|
||||
// токени ботів не розшифрувати, а отже й опитувати нікого.
|
||||
func NewBot(st *store.Store, ring *crypto.Keyring, log *slog.Logger) *Bot {
|
||||
poll := 25 * time.Second
|
||||
return &Bot{
|
||||
st: st,
|
||||
ring: ring,
|
||||
log: log.With("component", "telegram"),
|
||||
hc: &http.Client{Timeout: poll + 15*time.Second},
|
||||
action: &http.Client{Timeout: 15 * time.Second},
|
||||
pollTimeout: poll,
|
||||
}
|
||||
}
|
||||
|
||||
// telegramLockKey — довільна стала, аби її не займав ніхто інший у цій
|
||||
// же базі. Сусідня з ключем движка алертів (див. engine.go).
|
||||
const telegramLockKey = 0x6e70_7467 // "nptg"
|
||||
|
||||
// Run тримає опитування до скасування контексту.
|
||||
//
|
||||
// Advisory-блокування береться на ВЕСЬ час роботи, а не на такт, як у
|
||||
// движка алертів. Причина в тому, що getUpdates ексклюзивний: вибране
|
||||
// оновлення другому читачеві вже не дістанеться, і два процеси на
|
||||
// одному боті ділили б натискання між собою навпіл. Блокування живе
|
||||
// разом із з'єднанням, тож падіння процесу звільняє його само — сусід
|
||||
// підхопить опитування за пів хвилини.
|
||||
func (b *Bot) Run(ctx context.Context) {
|
||||
if b.ring == nil {
|
||||
b.log.Info("приймач Telegram вимкнено: немає ключів шифрування")
|
||||
return
|
||||
}
|
||||
b.log.Info("приймач натискань Telegram запущено")
|
||||
|
||||
for ctx.Err() == nil {
|
||||
conn, held := b.acquire(ctx)
|
||||
if !held {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(30 * time.Second):
|
||||
continue
|
||||
}
|
||||
}
|
||||
b.serve(ctx, conn)
|
||||
|
||||
// context.WithoutCancel: під час зупинки контекст уже мертвий,
|
||||
// а блокування зняти все одно треба — інакше сусідній процес
|
||||
// чекатиме на нього до розриву з'єднання.
|
||||
_, _ = conn.Exec(context.WithoutCancel(ctx),
|
||||
`SELECT pg_advisory_unlock($1)`, int64(telegramLockKey))
|
||||
conn.Release()
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bot) acquire(ctx context.Context) (*pgxpool.Conn, bool) {
|
||||
// WorkerPool, а не Pool: блокування має жити стільки ж, скільки
|
||||
// саме опитування, а опитування за побудовою ходить поверх усіх
|
||||
// кабінетів — це та сама роль, що й у решти фонових тактів.
|
||||
conn, err := b.st.WorkerPool().Acquire(ctx)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
var got bool
|
||||
if err := conn.QueryRow(ctx, `SELECT pg_try_advisory_lock($1)`,
|
||||
int64(telegramLockKey)).Scan(&got); err != nil || !got {
|
||||
conn.Release()
|
||||
return nil, false
|
||||
}
|
||||
return conn, true
|
||||
}
|
||||
|
||||
// serve крутить такти, доки тримається блокування.
|
||||
func (b *Bot) serve(ctx context.Context, conn *pgxpool.Conn) {
|
||||
for ctx.Err() == nil {
|
||||
// Перелік ботів перечитується щотакту. Такт — це майже завжди
|
||||
// очікування на getUpdates, тобто раз на ~25 секунд, і за цю
|
||||
// ціну щойно доданий канал починає слухати кнопки сам, без
|
||||
// перезапуску процесу.
|
||||
groups, err := b.groups(ctx)
|
||||
if err != nil {
|
||||
b.log.Error("читання каналів Telegram", "помилка", err)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(30 * time.Second):
|
||||
continue
|
||||
}
|
||||
}
|
||||
if len(groups) == 0 {
|
||||
// Жодного telegram-каналу: спати довше, ніж такт опитування.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(60 * time.Second):
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for _, g := range groups {
|
||||
wg.Add(1)
|
||||
go func(g botGroup) {
|
||||
defer wg.Done()
|
||||
b.pollOnce(ctx, g)
|
||||
}(g)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// Живе з'єднання — доказ, що блокування ще наше. Мертве означає,
|
||||
// що Postgres його вже зняв і опитувати далі не можна: сусідній
|
||||
// процес міг узяти бота собі.
|
||||
if err := conn.Ping(ctx); err != nil {
|
||||
b.log.Warn("з'єднання з блокуванням втрачено", "помилка", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// botGroup — один бот і всі канали, які через нього шлють.
|
||||
//
|
||||
// Групування саме за токеном, а не за каналом: один бот цілком може
|
||||
// обслуговувати кілька чатів і навіть кілька кабінетів, а getUpdates
|
||||
// у нього одна черга на всіх.
|
||||
type botGroup struct {
|
||||
token string
|
||||
hash []byte
|
||||
chans []store.Channel
|
||||
}
|
||||
|
||||
func (b *Bot) groups(ctx context.Context) ([]botGroup, error) {
|
||||
channels, err := b.st.TelegramChannels(ctx, b.ring)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byToken := map[string]*botGroup{}
|
||||
var out []botGroup
|
||||
for _, c := range channels {
|
||||
if c.Secret == "" {
|
||||
continue
|
||||
}
|
||||
g, ok := byToken[c.Secret]
|
||||
if !ok {
|
||||
sum := sha256.Sum256([]byte(c.Secret))
|
||||
g = &botGroup{token: c.Secret, hash: sum[:]}
|
||||
byToken[c.Secret] = g
|
||||
}
|
||||
g.chans = append(g.chans, c)
|
||||
}
|
||||
for _, g := range byToken {
|
||||
out = append(out, *g)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// pollOnce робить один getUpdates і обробляє все, що прийшло.
|
||||
func (b *Bot) pollOnce(ctx context.Context, g botGroup) {
|
||||
offset, err := b.st.TelegramCursor(ctx, g.hash)
|
||||
if err != nil {
|
||||
b.log.Error("читання курсора", "помилка", err)
|
||||
return
|
||||
}
|
||||
|
||||
updates, err := b.getUpdates(ctx, g, offset)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
b.log.Warn("getUpdates", "бот", g.chans[0].Name, "помилка", err)
|
||||
// Пауза після помилки: без неї недоступний api.telegram.org
|
||||
// перетворював би такт на щільний цикл запитів.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-time.After(10 * time.Second):
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var next int64
|
||||
for _, u := range updates {
|
||||
if u.UpdateID >= next {
|
||||
next = u.UpdateID + 1
|
||||
}
|
||||
b.handle(ctx, g, u)
|
||||
}
|
||||
if next == 0 {
|
||||
return
|
||||
}
|
||||
// Курсор посувається НЕЗАЛЕЖНО від того, чи вдалася сама дія.
|
||||
// Оновлення, на якому обробник спіткнувся, інакше приходило б знову
|
||||
// й знову, і одна крива кнопка глушила б усі наступні назавжди.
|
||||
// Людина при цьому не лишається без відповіді: невдача їй сказана
|
||||
// текстом у answerCallbackQuery.
|
||||
if err := b.st.SaveTelegramCursor(context.WithoutCancel(ctx), g.hash, next); err != nil {
|
||||
b.log.Error("збереження курсора", "помилка", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bot) getUpdates(ctx context.Context, g botGroup, offset int64) ([]tgUpdate, error) {
|
||||
body := map[string]any{
|
||||
"timeout": int(b.pollTimeout.Seconds()),
|
||||
// Просимо рівно ті два типи, які вміємо: натискання кнопок і
|
||||
// повідомлення з командою прив'язки. Решта (правки, реакції,
|
||||
// вступи в чат) не має навіть потрапляти в чергу — вона займала
|
||||
// б місце й змушувала б нас її вичитувати.
|
||||
"allowed_updates": []string{"callback_query", "message"},
|
||||
}
|
||||
if offset > 0 {
|
||||
body["offset"] = offset
|
||||
}
|
||||
|
||||
var out struct {
|
||||
OK bool `json:"ok"`
|
||||
Description string `json:"description"`
|
||||
Result []tgUpdate `json:"result"`
|
||||
}
|
||||
if err := b.call(ctx, b.hc, g.token, "getUpdates", body, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !out.OK {
|
||||
return nil, fmt.Errorf("%s", out.Description)
|
||||
}
|
||||
return out.Result, nil
|
||||
}
|
||||
|
||||
// handle розводить оновлення по обробниках.
|
||||
func (b *Bot) handle(ctx context.Context, g botGroup, u tgUpdate) {
|
||||
switch {
|
||||
case u.CallbackQuery != nil:
|
||||
b.handleCallback(ctx, g, u.CallbackQuery)
|
||||
case u.Message != nil:
|
||||
b.handleMessage(ctx, g, u.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Натискання кнопки
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// handleCallback виконує натиснуте.
|
||||
//
|
||||
// Порядок перевірок навмисно такий: спершу «чий це чат» (звідси
|
||||
// кабінет), потім «що просять» (розбір callback_data), потім «хто саме
|
||||
// натиснув» (прив'язка), потім «чи можна йому» (права й доступ до
|
||||
// хоста) — і лише тоді дія. Жоден крок не бере кабінет чи особу з
|
||||
// вмісту кнопки: підробити її може будь-хто, хто бачив формат.
|
||||
func (b *Bot) handleCallback(ctx context.Context, g botGroup, cq *tgCallbackQuery) {
|
||||
answer := "Не вдалося обробити"
|
||||
|
||||
// Відповідь на натискання обов'язкова й безумовна. Доки її немає,
|
||||
// Telegram крутить на кнопці годинник — і людина бачить не
|
||||
// «відмовлено», а «зламалось». Тому вона в defer, а не в кінці
|
||||
// щасливого шляху, і йде з власним контекстом: під час зупинки
|
||||
// процесу натискання все одно має отримати відповідь.
|
||||
defer func() {
|
||||
ansCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := b.answerCallback(ansCtx, g.token, cq.ID, answer); err != nil {
|
||||
b.log.Warn("answerCallbackQuery", "помилка", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if cq.Message == nil {
|
||||
answer = "Повідомлення застаріле — відкрийте алерт у NetPulse"
|
||||
return
|
||||
}
|
||||
|
||||
ch, ok := matchChannel(g.chans, cq.Message.Chat.ID, cq.Message.ThreadID)
|
||||
if !ok {
|
||||
// Бот стоїть у чаті, якого немає в жодному каналі. Кабінет із
|
||||
// такого натискання не виводиться ніяк, і вгадувати його за
|
||||
// вмістом кнопки — рівно те, чого робити не можна.
|
||||
answer = "Цей чат не налаштовано в NetPulse"
|
||||
b.log.Warn("натискання з невідомого чату", "chat", cq.Message.Chat.ID)
|
||||
return
|
||||
}
|
||||
|
||||
act, err := parseCallbackData(cq.Data)
|
||||
if err != nil {
|
||||
answer = "Кнопка застаріла або невідома"
|
||||
b.log.Warn("розбір callback_data", "канал", ch.Name, "помилка", err)
|
||||
return
|
||||
}
|
||||
|
||||
acc, err := b.st.TelegramAccountByTgID(ctx, ch.TenantID, cq.From.ID)
|
||||
if errors.Is(err, store.ErrTelegramNotLinked) {
|
||||
answer = "Ваш Telegram не прив'язано до NetPulse.\n" +
|
||||
"Профіль → Telegram → отримати код, потім надішліть боту /link КОД"
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
b.log.Error("пошук прив'язки", "канал", ch.Name, "помилка", err)
|
||||
return
|
||||
}
|
||||
|
||||
perms, err := b.st.UserPermissions(ctx, acc.UserID, ch.TenantID)
|
||||
if err != nil {
|
||||
b.log.Error("права користувача", "помилка", err)
|
||||
return
|
||||
}
|
||||
if !hasPerm(perms, "alerts:ack") {
|
||||
// Порожній набір прав означає ще й відкликане членство: людину
|
||||
// прибрали з кабінету, а прив'язка лишилась. Відповідь однакова
|
||||
// навмисно — з боку Telegram це та сама відмова.
|
||||
answer = "Немає права підтверджувати алерти"
|
||||
return
|
||||
}
|
||||
|
||||
sc, err := b.st.LoadScope(ctx, ch.TenantID, acc.UserID)
|
||||
if err != nil {
|
||||
b.log.Error("доступ до хостів", "помилка", err)
|
||||
return
|
||||
}
|
||||
|
||||
var line string
|
||||
switch act.Kind {
|
||||
case "ack":
|
||||
answer, line = b.doAck(ctx, ch, acc, sc, act.ID)
|
||||
case "mute":
|
||||
answer, line = b.doMute(ctx, ch, acc, sc, act.ID)
|
||||
}
|
||||
if line == "" {
|
||||
return
|
||||
}
|
||||
|
||||
_ = b.st.TouchTelegramAccount(ctx, ch.TenantID, acc.ID)
|
||||
|
||||
// Правка самого повідомлення — не прикраса. answerCallbackQuery
|
||||
// показує спливаючий рядок на кілька секунд і тому, хто натиснув;
|
||||
// у чат він не потрапляє, а чат читає вся зміна. Без правки
|
||||
// повідомлення про аварію так і лишається з живими кнопками, і
|
||||
// наступний черговий натискає їх ще раз.
|
||||
if err := b.editMessage(ctx, g.token, cq.Message, line); err != nil {
|
||||
b.log.Warn("правка повідомлення", "помилка", err)
|
||||
}
|
||||
}
|
||||
|
||||
// doAck підтверджує алерт від імені прив'язаного користувача.
|
||||
//
|
||||
// Викликає той самий store.AckAlert, що й POST /api/v1/alerts/{id}/ack:
|
||||
// підтвердження з телефона й підтвердження з браузера мають лишати в
|
||||
// базі однаковий слід, а власна копія логіки розійшлася б із оригіналом
|
||||
// на першій же зміні — і розбіжність побачили б не тут, а в звіті.
|
||||
func (b *Bot) doAck(ctx context.Context, ch store.Channel, acc store.TelegramAccount,
|
||||
sc store.Scope, alertID string) (answer, line string) {
|
||||
|
||||
cur, err := b.st.AlertAckState(ctx, ch.TenantID, alertID)
|
||||
if errors.Is(err, store.ErrAlertNotFound) {
|
||||
// Кабінет узято з чату, тож «не знайдено» тут означає саме
|
||||
// «немає в цьому кабінеті» — зокрема й тоді, коли алерт із
|
||||
// таким id є в чужому.
|
||||
return "Алерт не знайдено", ""
|
||||
}
|
||||
if err != nil {
|
||||
b.log.Error("читання алерту", "помилка", err)
|
||||
return "Не вдалося прочитати алерт", ""
|
||||
}
|
||||
if cur.DeviceID != "" && !sc.CanWrite(cur.DeviceID) {
|
||||
return "Немає доступу до цього хоста", ""
|
||||
}
|
||||
|
||||
// Ідемпотентність. Друге натискання не має ні падати помилкою, ні
|
||||
// переписувати автора: перший, хто взявся, лишається першим.
|
||||
if cur.State == "acknowledged" {
|
||||
who := cur.AckedByEmail
|
||||
if who == "" {
|
||||
who = "невідомо ким"
|
||||
}
|
||||
at := time.Now()
|
||||
if cur.AckedAt != nil {
|
||||
at = *cur.AckedAt
|
||||
}
|
||||
return "Уже підтверджено: " + who, ackLine(who, at)
|
||||
}
|
||||
if cur.State == "resolved" || cur.State == "expired" {
|
||||
return "Алерт уже закрито", ""
|
||||
}
|
||||
|
||||
a, err := b.st.AckAlert(ctx, ch.TenantID, alertID, acc.UserID, "підтверджено з Telegram")
|
||||
if errors.Is(err, store.ErrAlertNotFound) {
|
||||
// Хтось встиг підтвердити між читанням і записом — для людини
|
||||
// це той самий результат, що й гілка вище.
|
||||
return "Уже підтверджено", ""
|
||||
}
|
||||
if err != nil {
|
||||
b.log.Error("підтвердження алерту", "помилка", err)
|
||||
return "Не вдалося підтвердити", ""
|
||||
}
|
||||
|
||||
at := time.Now()
|
||||
if a.AckedAt != nil {
|
||||
at = *a.AckedAt
|
||||
}
|
||||
return "Підтверджено", ackLine(acc.Email, at)
|
||||
}
|
||||
|
||||
// doMute глушить хост на годину — тією ж дією, що й POST /api/v1/mutes.
|
||||
func (b *Bot) doMute(ctx context.Context, ch store.Channel, acc store.TelegramAccount,
|
||||
sc store.Scope, deviceID string) (answer, line string) {
|
||||
|
||||
name, err := b.st.DeviceNameInTenant(ctx, ch.TenantID, deviceID)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return "Хост не знайдено", ""
|
||||
}
|
||||
if err != nil {
|
||||
b.log.Error("пошук хоста", "помилка", err)
|
||||
return "Не вдалося знайти хост", ""
|
||||
}
|
||||
if !sc.CanWrite(deviceID) {
|
||||
return "Немає доступу до хоста " + name, ""
|
||||
}
|
||||
|
||||
// Ідемпотентність: уже заглушений хост не глушиться вдруге.
|
||||
// Інакше подвійне натискання мовчки подвоювало б час тиші, і
|
||||
// дізнались би про це аж тоді, коли алерт не прийшов.
|
||||
if until, muted, err := b.st.ActiveMute(ctx, ch.TenantID, deviceID); err == nil && muted {
|
||||
return "Уже заглушено до " + until.In(tgLocation).Format("15:04"),
|
||||
muteLine(acc.Email, until)
|
||||
}
|
||||
|
||||
until := time.Now().Add(time.Hour)
|
||||
if max := time.Now().Add(store.MaxMute); until.After(max) {
|
||||
until = max
|
||||
}
|
||||
if err := b.st.MuteDevice(ctx, ch.TenantID, deviceID, acc.UserID,
|
||||
"заглушено з Telegram", until); err != nil {
|
||||
b.log.Error("заглушення хоста", "помилка", err)
|
||||
return "Не вдалося заглушити", ""
|
||||
}
|
||||
return "Заглушено до " + until.In(tgLocation).Format("15:04"), muteLine(acc.Email, until)
|
||||
}
|
||||
|
||||
func hasPerm(perms []string, want string) bool {
|
||||
for _, p := range perms {
|
||||
if p == want || p == "*" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Прив'язка акаунта
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// handleMessage відповідає лише на дві команди й мовчить на решту.
|
||||
//
|
||||
// Бот часто стоїть у робочому груповому чаті. Відповідь на кожне
|
||||
// повідомлення зробила б його джерелом шуму — і першою реакцією
|
||||
// команди стало б вимкнути сповіщення того чату, тобто рівно те, чому
|
||||
// продукт має запобігати.
|
||||
func (b *Bot) handleMessage(ctx context.Context, g botGroup, m *tgMessage) {
|
||||
if m.From == nil || m.From.IsBot {
|
||||
return
|
||||
}
|
||||
code, isLink := parseLinkCommand(m.Text)
|
||||
if !isLink {
|
||||
return
|
||||
}
|
||||
if code == "" {
|
||||
b.reply(ctx, g.token, m, "Надішліть код із профілю NetPulse: /link КОД")
|
||||
return
|
||||
}
|
||||
|
||||
// Кабінети, яким належить цей бот. Без цього переліку код був би
|
||||
// універсальним: чинний код кабінету А, надісланий боту кабінету Б,
|
||||
// прив'язав би людину туди, де її бот навіть не стоїть.
|
||||
seen := map[string]bool{}
|
||||
var tenants []string
|
||||
for _, c := range g.chans {
|
||||
if !seen[c.TenantID] {
|
||||
seen[c.TenantID] = true
|
||||
tenants = append(tenants, c.TenantID)
|
||||
}
|
||||
}
|
||||
|
||||
acc, err := b.st.RedeemTelegramLinkCode(ctx, code, tenants,
|
||||
m.From.ID, m.From.Username, tgDisplayName(*m.From))
|
||||
if errors.Is(err, store.ErrTelegramLinkInvalid) {
|
||||
b.reply(ctx, g.token, m, "Код недійсний, вже використаний або прострочений. "+
|
||||
"Візьміть новий у профілі NetPulse.")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
b.log.Error("прив'язка telegram", "помилка", err)
|
||||
b.reply(ctx, g.token, m, "Не вдалося прив'язати. Спробуйте пізніше.")
|
||||
return
|
||||
}
|
||||
|
||||
_ = b.st.WriteAudit(ctx, acc.TenantID, store.AuditEntry{
|
||||
ActorUserID: acc.UserID,
|
||||
Action: store.AuditActionTelegramLink,
|
||||
ObjectType: store.AuditObjectTelegram,
|
||||
ObjectID: acc.ID,
|
||||
Meta: map[string]any{"tg_user_id": acc.TgUserID, "tg_username": acc.TgUsername},
|
||||
})
|
||||
|
||||
b.reply(ctx, g.token, m, "Готово: кнопки під алертами тепер працюють від вашого імені. "+
|
||||
"Повідомлення з кодом можна видалити — код уже зужито.")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Виклики Bot API
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
func (b *Bot) answerCallback(ctx context.Context, token, queryID, text string) error {
|
||||
// show_alert=false: спливаючий рядок замість вікна з кнопкою «ОК».
|
||||
// Черговий тримає телефон однією рукою, і зайве підтвердження на
|
||||
// кожне натискання коштувало б рівно стільки ж, скільки економить
|
||||
// сама кнопка.
|
||||
body := map[string]any{"callback_query_id": queryID, "text": text}
|
||||
return b.call(ctx, b.action, token, "answerCallbackQuery", body, nil)
|
||||
}
|
||||
|
||||
// editMessage дописує підсумок у повідомлення й прибирає кнопки.
|
||||
func (b *Bot) editMessage(ctx context.Context, token string, m *tgMessage, line string) error {
|
||||
text := withStatus(m.Text, line)
|
||||
if m.Text == "" {
|
||||
text = line
|
||||
}
|
||||
body := map[string]any{
|
||||
"chat_id": m.Chat.ID,
|
||||
"message_id": m.MessageID,
|
||||
"text": text,
|
||||
// parse_mode навмисно НЕ задається, хоч надсилали ми з HTML.
|
||||
// Telegram віддає в message.text уже готовий текст без розмітки,
|
||||
// і повторна відправка його як HTML або зламалася б на першому
|
||||
// «<» у назві інтерфейсу, або перетворила б частину тексту
|
||||
// алерту на теги.
|
||||
//
|
||||
// Порожній inline_keyboard замість пропуску поля: так кнопки
|
||||
// зникають гарантовано, а не за замовчуванням, на яке довелось
|
||||
// би покладатися.
|
||||
"reply_markup": map[string]any{"inline_keyboard": [][]any{}},
|
||||
}
|
||||
return b.call(ctx, b.action, token, "editMessageText", body, nil)
|
||||
}
|
||||
|
||||
func (b *Bot) reply(ctx context.Context, token string, m *tgMessage, text string) {
|
||||
body := map[string]any{"chat_id": m.Chat.ID, "text": text}
|
||||
if m.ThreadID != 0 {
|
||||
body["message_thread_id"] = m.ThreadID
|
||||
}
|
||||
if err := b.call(ctx, b.action, token, "sendMessage", body, nil); err != nil {
|
||||
b.log.Warn("відповідь боту", "помилка", err)
|
||||
}
|
||||
}
|
||||
|
||||
// call — один виклик Bot API.
|
||||
//
|
||||
// Токен іде в шляху URL (так вимагає Bot API), тому будь-яка помилка
|
||||
// транспорту проходить через scrubToken: http.Client вкладає в її текст
|
||||
// повний URL, а в журналі токен бота — це чинний доступ.
|
||||
func (b *Bot) call(ctx context.Context, hc *http.Client, token, method string, body, out any) error {
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
"https://api.telegram.org/bot"+token+"/"+method, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return scrubToken(err, token)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
res, err := hc.Do(req)
|
||||
if err != nil {
|
||||
return scrubToken(err, token)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if out == nil {
|
||||
// Тіло відповіді нікому не потрібне, але прочитати його треба:
|
||||
// недочитане з'єднання не повертається в keep-alive, а на
|
||||
// довгому опитуванні це нове TLS-рукостискання щохвилини.
|
||||
var sink struct {
|
||||
OK bool `json:"ok"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
if err := json.NewDecoder(res.Body).Decode(&sink); err != nil {
|
||||
return fmt.Errorf("%s: відповідь %d нерозбірлива", method, res.StatusCode)
|
||||
}
|
||||
if !sink.OK {
|
||||
return fmt.Errorf("%s: %s", method, sink.Description)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := json.NewDecoder(res.Body).Decode(out); err != nil {
|
||||
return fmt.Errorf("%s: відповідь %d нерозбірлива", method, res.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
263
server/internal/alerting/telegram_callback.go
Normal file
263
server/internal/alerting/telegram_callback.go
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
package alerting
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/netpulse/netpulse/server/internal/store"
|
||||
)
|
||||
|
||||
// Розбір і перевірка натискання кнопки Telegram.
|
||||
//
|
||||
// Усе, що тут лежить, навмисно не знає ні про мережу, ні про базу:
|
||||
// «кому належить цей чат» і «що взагалі натиснули» — рішення, які
|
||||
// мають перевірятись тестом, а не стендом із живим ботом.
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Те, що приходить від Bot API
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
type tgUser struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
IsBot bool `json:"is_bot"`
|
||||
}
|
||||
|
||||
type tgChat struct {
|
||||
ID int64 `json:"id"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type tgMessage struct {
|
||||
MessageID int64 `json:"message_id"`
|
||||
Chat tgChat `json:"chat"`
|
||||
From *tgUser `json:"from"`
|
||||
Text string `json:"text"`
|
||||
ThreadID int `json:"message_thread_id"`
|
||||
}
|
||||
|
||||
type tgCallbackQuery struct {
|
||||
ID string `json:"id"`
|
||||
From tgUser `json:"from"`
|
||||
Message *tgMessage `json:"message"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
|
||||
type tgUpdate struct {
|
||||
UpdateID int64 `json:"update_id"`
|
||||
Message *tgMessage `json:"message"`
|
||||
CallbackQuery *tgCallbackQuery `json:"callback_query"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// callback_data
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// callbackAction — розібране натискання.
|
||||
type callbackAction struct {
|
||||
// Kind — "ack" або "mute". Перелік закритий: невідома дія — це не
|
||||
// «поки не реалізовано», а або наша стара збірка, або чужа спроба.
|
||||
Kind string
|
||||
// ID — алерт для ack, хост для mute. Завжди uuid.
|
||||
ID string
|
||||
}
|
||||
|
||||
// maxCallbackData — стеля Bot API на callback_data (1–64 байти).
|
||||
//
|
||||
// Перевіряється явно, хоч Telegram і не пропустив би довшого: дані
|
||||
// приходять із мережі, і «нам їх точно вкоротить той бік» — це довіра
|
||||
// до чужого сервера там, де вона нічого не коштує.
|
||||
const maxCallbackData = 64
|
||||
|
||||
// parseCallbackData розбирає вміст кнопки.
|
||||
//
|
||||
// У callback_data лежить рівно те, що ми туди поклали (див. notify.go),
|
||||
// але прийти воно може від будь-кого, хто бачив формат: сам рядок ніяк
|
||||
// не підписаний і не зашифрований. Тому цей розбір НІЧОГО не
|
||||
// авторизує — він лише каже, що саме просять зробити. Право на дію
|
||||
// перевіряється далі й іншим: чатом, з якого прийшло натискання, і
|
||||
// прив'язаним до нього користувачем.
|
||||
func parseCallbackData(raw string) (callbackAction, error) {
|
||||
if raw == "" || len(raw) > maxCallbackData {
|
||||
return callbackAction{}, fmt.Errorf("некоректна довжина callback_data")
|
||||
}
|
||||
kind, id, ok := strings.Cut(raw, ":")
|
||||
if !ok {
|
||||
return callbackAction{}, fmt.Errorf("немає роздільника")
|
||||
}
|
||||
switch kind {
|
||||
case "ack", "mute":
|
||||
default:
|
||||
return callbackAction{}, fmt.Errorf("невідома дія %q", kind)
|
||||
}
|
||||
if !isUUID(id) {
|
||||
return callbackAction{}, fmt.Errorf("ідентифікатор не uuid")
|
||||
}
|
||||
return callbackAction{Kind: kind, ID: id}, nil
|
||||
}
|
||||
|
||||
// isUUID перевіряє канонічну форму 8-4-4-4-12.
|
||||
//
|
||||
// Не для краси: далі цей рядок іде в запит як ::uuid, і кривий текст
|
||||
// повернувся б помилкою розбору з глибини SQL — тобто «внутрішня
|
||||
// помилка» замість «кнопка застаріла».
|
||||
func isUUID(s string) bool {
|
||||
if len(s) != 36 {
|
||||
return false
|
||||
}
|
||||
for i := range len(s) {
|
||||
c := s[i]
|
||||
if i == 8 || i == 13 || i == 18 || i == 23 {
|
||||
if c != '-' {
|
||||
return false
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case c >= '0' && c <= '9', c >= 'a' && c <= 'f', c >= 'A' && c <= 'F':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Чий це чат
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// matchChannel шукає канал, якому належить чат натискання.
|
||||
//
|
||||
// Це головна перевірка всього приймача. Кабінет береться НЕ з
|
||||
// callback_data — там немає нічого, чому можна вірити, — а з того, у
|
||||
// який чат прийшло повідомлення з кнопкою. Чат налаштований у каналі
|
||||
// руками адміністратора кабінету, тож збіг chat_id означає рівно те,
|
||||
// що потрібно: кнопку натиснули в чаті цього кабінету.
|
||||
//
|
||||
// Один бот може обслуговувати кілька каналів (різні чати, а в
|
||||
// self-hosted — і різні кабінети), тому перебір іде по всій групі
|
||||
// каналів цього бота. Коли на той самий чат заведено кілька каналів,
|
||||
// перевага в того, у якого збігається ще й гілка: канал із
|
||||
// message_thread_id описує чат точніше, ніж канал без нього.
|
||||
func matchChannel(chans []store.Channel, chatID int64, threadID int) (store.Channel, bool) {
|
||||
var loose store.Channel
|
||||
var found bool
|
||||
for _, c := range chans {
|
||||
var cfg telegramConfig
|
||||
if err := json.Unmarshal(c.Config, &cfg); err != nil {
|
||||
continue
|
||||
}
|
||||
if cfg.ChatID.String() != fmt.Sprint(chatID) {
|
||||
continue
|
||||
}
|
||||
if cfg.ThreadID != 0 && cfg.ThreadID == threadID {
|
||||
return c, true
|
||||
}
|
||||
if !found {
|
||||
loose, found = c, true
|
||||
}
|
||||
}
|
||||
return loose, found
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Що дописати в повідомлення після дії
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// tgLocation — часовий пояс, у якому показуються години в Telegram.
|
||||
//
|
||||
// Київ, а не UTC: повідомлення читає черговий, і «підтверджено о 01:12»
|
||||
// має збігатися з тим, що показує йому годинник на телефоні. Якщо бази
|
||||
// поясів у системі немає (мінімальний образ без tzdata), лишається UTC —
|
||||
// зсунута на кілька годин позначка гірша за правильну, але незрівнянно
|
||||
// краща за відсутність рядка взагалі.
|
||||
var tgLocation = func() *time.Location {
|
||||
if l, err := time.LoadLocation("Europe/Kyiv"); err == nil {
|
||||
return l
|
||||
}
|
||||
return time.UTC
|
||||
}()
|
||||
|
||||
// ackLine — рядок, який дописується в повідомлення після підтвердження.
|
||||
//
|
||||
// Ім'я тут обов'язкове. «Підтверджено» без автора не відповідає на
|
||||
// єдине питання, заради якого решта команди дивиться в чат: чи вже
|
||||
// хтось узявся, чи всі думають, що взявся хтось інший.
|
||||
func ackLine(who string, at time.Time) string {
|
||||
return fmt.Sprintf("✅ Підтверджено: %s, %s", who, at.In(tgLocation).Format("02.01 15:04"))
|
||||
}
|
||||
|
||||
// muteLine — те саме для заглушення.
|
||||
func muteLine(who string, until time.Time) string {
|
||||
return fmt.Sprintf("🔕 Заглушено до %s (%s)",
|
||||
until.In(tgLocation).Format("02.01 15:04"), who)
|
||||
}
|
||||
|
||||
// maxMessageText — стеля Bot API на текст повідомлення.
|
||||
const maxMessageText = 4096
|
||||
|
||||
// withStatus дописує підсумок до тексту повідомлення.
|
||||
//
|
||||
// Повторний рядок не додається: друге натискання має привести
|
||||
// повідомлення в той самий вигляд, що й перше, а не відростити другий
|
||||
// «Підтверджено». Це та сама ідемпотентність, що й у самій дії, тільки
|
||||
// видима — саме її людина й перевіряє очима.
|
||||
func withStatus(text, line string) string {
|
||||
if strings.Contains(text, line) {
|
||||
return text
|
||||
}
|
||||
out := strings.TrimRight(text, "\n") + "\n\n" + line
|
||||
if len(out) > maxMessageText {
|
||||
// Обрізати доводиться з голови, а не з хвоста: підсумок —
|
||||
// найновіше й найпотрібніше в повідомленні, а початок алерту
|
||||
// вже прочитали, коли він прийшов.
|
||||
out = "…" + out[len(out)-maxMessageText+3:]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// tgDisplayName — як назвати того, хто натиснув, коли прив'язки немає.
|
||||
//
|
||||
// Використовується лише в текстах відмов: у самому підтвердженні
|
||||
// автором стоїть користувач NetPulse, а не telegram-акаунт.
|
||||
func tgDisplayName(u tgUser) string {
|
||||
if u.Username != "" {
|
||||
return "@" + u.Username
|
||||
}
|
||||
name := strings.TrimSpace(u.FirstName + " " + u.LastName)
|
||||
if name != "" {
|
||||
return name
|
||||
}
|
||||
return fmt.Sprint(u.ID)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Команда прив'язки
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// parseLinkCommand дістає код із повідомлення боту.
|
||||
//
|
||||
// Приймається і «/link КОД», і «/link@netpulse_bot КОД»: у групових
|
||||
// чатах Telegram дописує ім'я бота до команди сам, і без цього
|
||||
// прив'язка працювала б лише в особистих повідомленнях.
|
||||
//
|
||||
// Регістр коду не має значення — абетка кодів (див. міграцію 0061) уся
|
||||
// у верхньому регістрі, а телефон охоче виправляє введене на нижній.
|
||||
func parseLinkCommand(text string) (string, bool) {
|
||||
fields := strings.Fields(strings.TrimSpace(text))
|
||||
if len(fields) == 0 {
|
||||
return "", false
|
||||
}
|
||||
cmd, _, _ := strings.Cut(fields[0], "@")
|
||||
if cmd != "/link" {
|
||||
return "", false
|
||||
}
|
||||
if len(fields) < 2 {
|
||||
return "", true
|
||||
}
|
||||
return strings.ToUpper(fields[1]), true
|
||||
}
|
||||
255
server/internal/alerting/telegram_callback_test.go
Normal file
255
server/internal/alerting/telegram_callback_test.go
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
package alerting
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/netpulse/netpulse/server/internal/store"
|
||||
)
|
||||
|
||||
// Мережі тут немає навмисно: усе, що вирішує долю натискання, —
|
||||
// розбір рядка й пошук каналу за чатом. Тест, якому потрібен живий
|
||||
// бот, не запускають, а отже й не пишуть.
|
||||
|
||||
const goodUUID = "3f2b1a5c-9d84-4e77-b0c1-2a6f8e5d4c31"
|
||||
|
||||
func TestParseCallbackDataAcceptsOwnFormat(t *testing.T) {
|
||||
for _, kind := range []string{"ack", "mute"} {
|
||||
got, err := parseCallbackData(kind + ":" + goodUUID)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", kind, err)
|
||||
}
|
||||
if got.Kind != kind || got.ID != goodUUID {
|
||||
t.Errorf("%s: отримали %+v", kind, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// callback_data приходить рядком і нічим не підписаний, тож усе, що не
|
||||
// збігається з нашим форматом до символу, має відхилятись до першого
|
||||
// звернення до бази.
|
||||
func TestParseCallbackDataRejectsAnythingElse(t *testing.T) {
|
||||
bad := map[string]string{
|
||||
"порожньо": "",
|
||||
"без роздільника": "ack" + goodUUID,
|
||||
"невідома дія": "close:" + goodUUID,
|
||||
"порожній id": "ack:",
|
||||
"не uuid": "ack:12345",
|
||||
"майже uuid": "ack:3f2b1a5c-9d84-4e77-b0c1-2a6f8e5d4c3",
|
||||
"нешістнадцятковий": "ack:3f2b1a5c-9d84-4e77-b0c1-2a6f8e5d4czz",
|
||||
"дефіси не на місці": "ack:3f2b1a5c9d84-4e77-b0c1-2a6f8e5d4c311",
|
||||
"спроба ін'єкції": "ack:' OR 1=1 --",
|
||||
"два роздільники": "ack:mute:" + goodUUID,
|
||||
"довше за стелю": "ack:" + strings.Repeat("a", 70),
|
||||
"регістр дії": "ACK:" + goodUUID,
|
||||
"пробіл перед дією": " ack:" + goodUUID,
|
||||
"порожній ідентиф-р ": "mute:",
|
||||
}
|
||||
for name, raw := range bad {
|
||||
if _, err := parseCallbackData(raw); err == nil {
|
||||
t.Errorf("%s (%q): очікували відмову", name, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Верхній регістр у uuid трапляється у вивантаженнях із інших систем і
|
||||
// коректний за RFC — відкидати його немає підстав.
|
||||
func TestParseCallbackDataAcceptsUpperCaseUUID(t *testing.T) {
|
||||
if _, err := parseCallbackData("ack:" + strings.ToUpper(goodUUID)); err != nil {
|
||||
t.Errorf("верхній регістр uuid має прийматись: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Кабінет береться з чату, а не з кнопки
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
func tgChannel(id, tenant string, cfg string) store.Channel {
|
||||
return store.Channel{ID: id, TenantID: tenant, Kind: "telegram",
|
||||
Name: id, Enabled: true, Config: json.RawMessage(cfg)}
|
||||
}
|
||||
|
||||
func TestMatchChannelFindsTenantByChat(t *testing.T) {
|
||||
chans := []store.Channel{
|
||||
tgChannel("c1", "t1", `{"chat_id":-1001111111111}`),
|
||||
tgChannel("c2", "t2", `{"chat_id":"-1002222222222"}`),
|
||||
}
|
||||
|
||||
got, ok := matchChannel(chans, -1002222222222, 0)
|
||||
if !ok || got.TenantID != "t2" {
|
||||
t.Fatalf("очікували канал t2, отримали %+v (ok=%v)", got.ID, ok)
|
||||
}
|
||||
|
||||
// chat_id пишуть і числом, і рядком — на пошук кабінету це впливати
|
||||
// не має (див. TestTelegramChatIDAcceptsBothForms).
|
||||
if got, ok := matchChannel(chans, -1001111111111, 0); !ok || got.TenantID != "t1" {
|
||||
t.Fatalf("числова форма chat_id не знайшлася: %+v (ok=%v)", got.ID, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// Головна перевірка приймача: натискання з чату, якого немає в жодному
|
||||
// каналі, не має давати кабінету взагалі. Інакше досить було б знати
|
||||
// формат кнопки, щоб підтвердити чужий алерт зі свого чату.
|
||||
func TestMatchChannelRejectsUnknownChat(t *testing.T) {
|
||||
chans := []store.Channel{tgChannel("c1", "t1", `{"chat_id":-1001111111111}`)}
|
||||
|
||||
if _, ok := matchChannel(chans, -1009999999999, 0); ok {
|
||||
t.Fatal("чужий чат не має зіставлятися з каналом")
|
||||
}
|
||||
if _, ok := matchChannel(nil, -1001111111111, 0); ok {
|
||||
t.Fatal("порожній перелік каналів не має нічого знаходити")
|
||||
}
|
||||
}
|
||||
|
||||
// Кривий config каналу не має ламати пошук для решти: один зіпсований
|
||||
// рядок гасив би кнопки всім, хто ділить із ним бота.
|
||||
func TestMatchChannelSkipsBrokenConfig(t *testing.T) {
|
||||
chans := []store.Channel{
|
||||
tgChannel("broken", "t1", `{"chat_id":`),
|
||||
tgChannel("good", "t2", `{"chat_id":-1002222222222}`),
|
||||
}
|
||||
got, ok := matchChannel(chans, -1002222222222, 0)
|
||||
if !ok || got.ID != "good" {
|
||||
t.Fatalf("очікували good, отримали %+v (ok=%v)", got.ID, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// Той самий чат із гілками: точніший канал (із message_thread_id) має
|
||||
// вигравати, інакше алерт із гілки «Мережа» підтверджувався б від імені
|
||||
// каналу, налаштованого на весь чат.
|
||||
func TestMatchChannelPrefersThread(t *testing.T) {
|
||||
chans := []store.Channel{
|
||||
tgChannel("whole", "t1", `{"chat_id":-100777}`),
|
||||
tgChannel("thread", "t1", `{"chat_id":-100777,"thread_id":42}`),
|
||||
}
|
||||
if got, _ := matchChannel(chans, -100777, 42); got.ID != "thread" {
|
||||
t.Errorf("для гілки 42 очікували канал thread, отримали %s", got.ID)
|
||||
}
|
||||
if got, _ := matchChannel(chans, -100777, 0); got.ID != "whole" {
|
||||
t.Errorf("поза гілкою очікували канал whole, отримали %s", got.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Видима ідемпотентність
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// Друге натискання має привести повідомлення в той самий вигляд, що й
|
||||
// перше. Другий рядок «Підтверджено» під тим самим алертом читався б як
|
||||
// два різні підтвердження.
|
||||
func TestWithStatusAddsLineOnlyOnce(t *testing.T) {
|
||||
line := ackLine("ops@example.com", time.Date(2026, 8, 27, 3, 12, 0, 0, time.UTC))
|
||||
first := withStatus("🔴 HIGH: sw1 не відповідає", line)
|
||||
|
||||
if !strings.Contains(first, "ops@example.com") {
|
||||
t.Fatalf("у підсумку немає автора: %q", first)
|
||||
}
|
||||
if second := withStatus(first, line); second != first {
|
||||
t.Errorf("повторне дописування змінило текст:\n%q\n%q", first, second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithStatusKeepsWithinTelegramLimit(t *testing.T) {
|
||||
long := strings.Repeat("щ", maxMessageText)
|
||||
got := withStatus(long, muteLine("ops@example.com", time.Now().Add(time.Hour)))
|
||||
|
||||
if len(got) > maxMessageText {
|
||||
t.Fatalf("довжина %d перевищує стелю %d", len(got), maxMessageText)
|
||||
}
|
||||
if !strings.Contains(got, "Заглушено до") {
|
||||
t.Errorf("обрізали не з того боку — підсумок зник:\n%q", got[:80])
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Команда прив'язки
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
func TestParseLinkCommand(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
code string
|
||||
isLink bool
|
||||
}{
|
||||
{"/link ABCD2345", "ABCD2345", true},
|
||||
// У групі Telegram дописує ім'я бота до команди сам.
|
||||
{"/link@netpulse_bot ABCD2345", "ABCD2345", true},
|
||||
// Телефон охоче виправляє набране на нижній регістр.
|
||||
{"/link abcd2345", "ABCD2345", true},
|
||||
{" /link ABCD2345 ", "ABCD2345", true},
|
||||
// Команда без коду — привід підказати, а не мовчати.
|
||||
{"/link", "", true},
|
||||
// Усе інше бот ігнорує: він часто стоїть у робочому чаті.
|
||||
{"привіт", "", false},
|
||||
{"", "", false},
|
||||
{"/start", "", false},
|
||||
{"скажи /link ABCD2345", "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
code, isLink := parseLinkCommand(c.in)
|
||||
if code != c.code || isLink != c.isLink {
|
||||
t.Errorf("%q: отримали (%q, %v), очікували (%q, %v)",
|
||||
c.in, code, isLink, c.code, c.isLink)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Прив'язки немає — у відмові має бути видно, ЯКИЙ саме акаунт стукав:
|
||||
// інакше адміністратор не зрозуміє, кого шукати.
|
||||
func TestDisplayNameFallsBackToIdentity(t *testing.T) {
|
||||
if got := tgDisplayName(tgUser{ID: 77, Username: "ops"}); got != "@ops" {
|
||||
t.Errorf("отримали %q", got)
|
||||
}
|
||||
if got := tgDisplayName(tgUser{ID: 77, FirstName: "Іван", LastName: "П."}); got != "Іван П." {
|
||||
t.Errorf("отримали %q", got)
|
||||
}
|
||||
if got := tgDisplayName(tgUser{ID: 77}); got != "77" {
|
||||
t.Errorf("отримали %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Розбір оновлення цілком
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// Перевірка на справжній формі відповіді Bot API: поля, за якими
|
||||
// вирішується кабінет і особа, мають доїжджати повністю.
|
||||
func TestUpdateDecodesFieldsUsedForAuthorisation(t *testing.T) {
|
||||
raw := `{
|
||||
"update_id": 500123,
|
||||
"callback_query": {
|
||||
"id": "4382bfdwdsb323b2d9",
|
||||
"from": {"id": 9876543210, "is_bot": false, "username": "ops"},
|
||||
"message": {
|
||||
"message_id": 4210,
|
||||
"chat": {"id": -1001111111111, "type": "supergroup"},
|
||||
"message_thread_id": 42,
|
||||
"text": "🔴 HIGH: sw1 не відповідає"
|
||||
},
|
||||
"data": "ack:` + goodUUID + `"
|
||||
}
|
||||
}`
|
||||
|
||||
var u tgUpdate
|
||||
if err := json.Unmarshal([]byte(raw), &u); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cq := u.CallbackQuery
|
||||
if cq == nil || cq.Message == nil {
|
||||
t.Fatal("callback_query не розібрався")
|
||||
}
|
||||
// 64-бітний id: на нових акаунтах Telegram він уже не вміщається в
|
||||
// int32, і мовчазне обрізання тут означало б прив'язку не до тієї
|
||||
// людини.
|
||||
if cq.From.ID != 9876543210 {
|
||||
t.Errorf("from.id = %d", cq.From.ID)
|
||||
}
|
||||
if cq.Message.Chat.ID != -1001111111111 || cq.Message.ThreadID != 42 {
|
||||
t.Errorf("чат розібрався як %d/%d", cq.Message.Chat.ID, cq.Message.ThreadID)
|
||||
}
|
||||
if act, err := parseCallbackData(cq.Data); err != nil || act.Kind != "ack" {
|
||||
t.Errorf("дія розібралась як %+v (%v)", act, err)
|
||||
}
|
||||
}
|
||||
142
server/internal/alerting/traps_test.go
Normal file
142
server/internal/alerting/traps_test.go
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
package alerting
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/netpulse/netpulse/server/internal/store"
|
||||
)
|
||||
|
||||
func trapRule(cond store.Condition, scope map[string]bool) compiledRule {
|
||||
return compiledRule{
|
||||
rule: store.Rule{ID: "r1", Source: "trap", Condition: cond},
|
||||
scope: scope,
|
||||
}
|
||||
}
|
||||
|
||||
var linkDown = TrapEvent{
|
||||
DeviceID: "d1",
|
||||
SourceIP: "10.20.0.7",
|
||||
TrapOID: "1.3.6.1.6.3.1.1.5.3",
|
||||
Varbinds: []TrapVarbind{
|
||||
{OID: "1.3.6.1.2.1.1.3.0", Value: "12345"},
|
||||
{OID: "1.3.6.1.2.1.2.2.1.1.7", Value: "7"},
|
||||
{OID: "1.3.6.1.2.1.2.2.1.8.7", Value: "2"},
|
||||
},
|
||||
}
|
||||
|
||||
// Найпростіша умова, заради якої все й робилось: «цей OID».
|
||||
func TestTrapMatchesByOID(t *testing.T) {
|
||||
if !trapMatches(trapRule(store.Condition{TrapOID: "1.3.6.1.6.3.1.1.5.3"}, nil), linkDown) {
|
||||
t.Fatal("linkDown не збігся з власним OID")
|
||||
}
|
||||
if trapMatches(trapRule(store.Condition{TrapOID: "1.3.6.1.6.3.1.1.5.4"}, nil), linkDown) {
|
||||
t.Fatal("linkUp зловив linkDown")
|
||||
}
|
||||
// Провідна крапка не має вирішувати долю правила.
|
||||
if !trapMatches(trapRule(store.Condition{TrapOID: ".1.3.6.1.6.3.1.1.5.3"}, nil), linkDown) {
|
||||
t.Fatal("крапка на початку зламала збіг")
|
||||
}
|
||||
}
|
||||
|
||||
// Умова на varbind порівнюється зі СТОВПЦЕМ, а не з конкретним рядком:
|
||||
// у трапі приходить ifOperStatus.7, а людина пише ifOperStatus, бо
|
||||
// номера порту наперед не знає.
|
||||
func TestTrapMatchesVarbindByColumn(t *testing.T) {
|
||||
c := trapRule(store.Condition{
|
||||
TrapOID: "1.3.6.1.6.3.1.1.5.3",
|
||||
VarbindOID: "1.3.6.1.2.1.2.2.1.8",
|
||||
VarbindValue: "2",
|
||||
}, nil)
|
||||
if !trapMatches(c, linkDown) {
|
||||
t.Fatal("ifOperStatus=down не збігся")
|
||||
}
|
||||
|
||||
c.rule.Condition.VarbindValue = "1"
|
||||
if trapMatches(c, linkDown) {
|
||||
t.Fatal("ifOperStatus=up збігся з трапом, у якому down")
|
||||
}
|
||||
|
||||
// Порожнє значення означає «щоб такий varbind узагалі був».
|
||||
c.rule.Condition.VarbindValue = ""
|
||||
if !trapMatches(c, linkDown) {
|
||||
t.Fatal("умова «є такий varbind» не спрацювала")
|
||||
}
|
||||
c.rule.Condition.VarbindOID = "1.3.6.1.4.1.9.9.999"
|
||||
if trapMatches(c, linkDown) {
|
||||
t.Fatal("умова спрацювала на varbind, якого в трапі немає")
|
||||
}
|
||||
}
|
||||
|
||||
// Адреса в умові приймає і одну адресу, і підмережу.
|
||||
func TestTrapMatchesBySourceIP(t *testing.T) {
|
||||
if !trapMatches(trapRule(store.Condition{SourceIP: "10.20.0.7"}, nil), linkDown) {
|
||||
t.Fatal("точна адреса не збіглася")
|
||||
}
|
||||
if !trapMatches(trapRule(store.Condition{SourceIP: "10.20.0.0/24"}, nil), linkDown) {
|
||||
t.Fatal("підмережа не збіглася")
|
||||
}
|
||||
if trapMatches(trapRule(store.Condition{SourceIP: "10.30.0.0/24"}, nil), linkDown) {
|
||||
t.Fatal("чужа підмережа збіглася")
|
||||
}
|
||||
}
|
||||
|
||||
// Головне рішення цього джерела: трап від адреси, яку не впізнали,
|
||||
// піднімає алерт ЛИШЕ якщо правило назвало адресу явно. Інакше він
|
||||
// лишається в переліку невідомих джерел, а не на дошці алертів.
|
||||
func TestTrapWithoutDeviceNeedsExplicitSource(t *testing.T) {
|
||||
stranger := linkDown
|
||||
stranger.DeviceID = ""
|
||||
stranger.SourceIP = "10.20.0.250"
|
||||
|
||||
if trapMatches(trapRule(store.Condition{TrapOID: "1.3.6.1.6.3.1.1.5.3"}, nil), stranger) {
|
||||
t.Fatal("трап від невідомої адреси підняв алерт без явної адреси в умові")
|
||||
}
|
||||
c := trapRule(store.Condition{
|
||||
TrapOID: "1.3.6.1.6.3.1.1.5.3",
|
||||
SourceIP: "10.20.0.0/24",
|
||||
}, nil)
|
||||
if !trapMatches(c, stranger) {
|
||||
t.Fatal("правило з явною адресою не спрацювало на незнайомця")
|
||||
}
|
||||
}
|
||||
|
||||
// Селектор звужує правило до хостів. До трапа від адреси поза
|
||||
// інвентарем його застосувати неможливо — інакше правило з адресою
|
||||
// джерела не спрацювало б ніколи.
|
||||
func TestTrapSelectorAppliesOnlyToKnownHosts(t *testing.T) {
|
||||
scope := map[string]bool{"d2": true}
|
||||
if trapMatches(trapRule(store.Condition{TrapOID: "1.3.6.1.6.3.1.1.5.3"}, scope), linkDown) {
|
||||
t.Fatal("хост поза селектором підпав під правило")
|
||||
}
|
||||
|
||||
stranger := linkDown
|
||||
stranger.DeviceID = ""
|
||||
c := trapRule(store.Condition{SourceIP: "10.20.0.7"}, scope)
|
||||
if !trapMatches(c, stranger) {
|
||||
t.Fatal("селектор заблокував правило про адресу поза інвентарем")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVarbindPrefixMatchIsNotSubstring(t *testing.T) {
|
||||
// 1.3.6.1.2.1.2.2.1.10 (ifInOctets) не має вважатись збігом для
|
||||
// 1.3.6.1.2.1.2.2.1.1 (ifIndex) — інакше умова про індекс порту
|
||||
// зривалась би на лічильнику байтів.
|
||||
if varbindIs("1.3.6.1.2.1.2.2.1.10", "1.3.6.1.2.1.2.2.1.1") {
|
||||
t.Fatal("сусідній стовпець зарахований як збіг")
|
||||
}
|
||||
if !varbindIs("1.3.6.1.2.1.2.2.1.1.7", "1.3.6.1.2.1.2.2.1.1") {
|
||||
t.Fatal("рядок стовпця не зарахований")
|
||||
}
|
||||
if !varbindIs("1.3.6.1.2.1.1.3.0", "1.3.6.1.2.1.1.3.0") {
|
||||
t.Fatal("точний збіг не зарахований")
|
||||
}
|
||||
}
|
||||
|
||||
// Текст алерту має пояснювати подію, а не перелічувати OID-и.
|
||||
func TestTrapDetailShowsNamedFields(t *testing.T) {
|
||||
view := &tenantView{}
|
||||
got := trapDetail(view, linkDown)
|
||||
if got != "ifIndex=7, ifOperStatus=down" {
|
||||
t.Fatalf("подробиці трапа: %q", got)
|
||||
}
|
||||
}
|
||||
180
server/internal/gitstore/branches.go
Normal file
180
server/internal/gitstore/branches.go
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
package gitstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/config"
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
)
|
||||
|
||||
// Видалення гілки пристрою — локально й на дзеркалі.
|
||||
//
|
||||
// Поруч із RenameBranch і з тієї ж причини: ім'я гілки складається з
|
||||
// імені та адреси хоста, тож усе, що відбувається з хостом, рано чи
|
||||
// пізно доходить сюди. Перейменування вже дійшло; видалення — друга
|
||||
// половина тієї ж думки.
|
||||
//
|
||||
// ГОЛОВНЕ РІШЕННЯ ФАЙЛУ — те, чого тут немає: prune.
|
||||
//
|
||||
// Спокуса очевидна: дзеркалення й так знає обидва боки, і один прапорець
|
||||
// `--prune` прибирав би на тому кінці все, чого немає тут. Саме тому
|
||||
// його тут і немає. Дзеркало заводять на випадок втрати локального
|
||||
// диска. Репозиторій, який пошкодився або відкотився до вчорашнього
|
||||
// стану, з prune миттєво знищив би віддалену копію — тобто рівно ту, по
|
||||
// яку в цей момент прийшли. Механізм, здатний стерти копію на підставі
|
||||
// стану оригіналу, копією не є.
|
||||
//
|
||||
// Тому видалення адресне: система знає ім'я гілки, яку щойно прибрала,
|
||||
// і надсилає видалення рівно цієї гілки. Усе, чого вона не називала
|
||||
// явно, на дзеркалі лишається — включно з гілками, про які вона нічого
|
||||
// не знає.
|
||||
|
||||
// DeleteBranch прибирає локальну гілку.
|
||||
//
|
||||
// Повертає false, якщо гілки не було. Це не помилка: пристрій міг ніколи
|
||||
// не комітитись, а видалення — повторюватись (черга видалень повторює
|
||||
// спробу, доки не дійде до дзеркала).
|
||||
//
|
||||
// Історія при цьому не зникає з диска одразу: Git тримає об'єкти доти,
|
||||
// доки їх не збере gc. Це не недоробка, а властивість формату — обіцяти
|
||||
// «конфіги стерто з диска» на підставі знятого посилання було б
|
||||
// неправдою. Тіла конфігів прибираються там, де вони справді лежать, —
|
||||
// у core.secrets (див. store.deleteConfigRows).
|
||||
func (s *Store) DeleteBranch(repoName, branch string) (bool, error) {
|
||||
if s.root == "" {
|
||||
return false, ErrDisabled
|
||||
}
|
||||
if strings.TrimSpace(branch) == "" {
|
||||
return false, nil
|
||||
}
|
||||
path := filepath.Join(s.root, repoName)
|
||||
|
||||
mu := s.lockFor(path)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
repo, err := open(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, git.ErrRepositoryNotExists) {
|
||||
// Репозиторію немає — прибирати нічого. Так виглядає
|
||||
// інсталяція, у якій жодного конфігу ще не зібрано.
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
ref := plumbing.NewBranchReferenceName(branch)
|
||||
if _, err := repo.Reference(ref, false); err != nil {
|
||||
if errors.Is(err, plumbing.ErrReferenceNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("гілка %s: %w", branch, err)
|
||||
}
|
||||
if err := repo.Storer.RemoveReference(ref); err != nil {
|
||||
return false, fmt.Errorf("прибрати гілку %s: %w", branch, err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// DeleteRemoteRefs просить дзеркало прибрати названі гілки.
|
||||
//
|
||||
// Refspec виду ":refs/heads/<гілка>" — порожній лівий бік і є
|
||||
// видаленням. Кожна гілка називається окремо: шаблон тут неможливий за
|
||||
// побудовою, і це та сама відмінність від mirrorRefSpec, заради якої
|
||||
// написано весь файл.
|
||||
//
|
||||
// Atomic НЕ вмикається, на відміну від звичайного push. Там атомарність
|
||||
// потрібна, бо наполовину доїхане дзеркало виглядає як ціле. Тут
|
||||
// навпаки: видалення незалежні одне від одного, і відмова по одній
|
||||
// гілці (наприклад, її на тому кінці вже немає) не має скасовувати
|
||||
// решту.
|
||||
//
|
||||
// Відсутність гілки на дзеркалі помилкою не вважається — саме так
|
||||
// виглядає повтор після часткового успіху, а він тут звичайна річ.
|
||||
func (s *Store) DeleteRemoteRefs(ctx context.Context, repoName string, r Remote, branches []string) error {
|
||||
if s.root == "" {
|
||||
return ErrDisabled
|
||||
}
|
||||
specs := make([]config.RefSpec, 0, len(branches))
|
||||
for _, b := range branches {
|
||||
b = strings.TrimSpace(b)
|
||||
if b == "" {
|
||||
continue
|
||||
}
|
||||
specs = append(specs, config.RefSpec(":"+plumbing.NewBranchReferenceName(b).String()))
|
||||
}
|
||||
if len(specs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
full := filepath.Join(s.root, repoName)
|
||||
repo, err := git.PlainOpen(full)
|
||||
if err != nil {
|
||||
if errors.Is(err, git.ErrRepositoryNotExists) {
|
||||
return ErrNoRepo
|
||||
}
|
||||
return fmt.Errorf("gitstore: відкриття %s: %w", repoName, err)
|
||||
}
|
||||
|
||||
auth, err := r.authMethod()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
remote := git.NewRemote(repo.Storer, &config.RemoteConfig{
|
||||
Name: "mirror",
|
||||
URLs: []string{r.URL},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, r.timeout())
|
||||
defer cancel()
|
||||
|
||||
err = remote.PushContext(ctx, &git.PushOptions{
|
||||
RemoteName: "mirror",
|
||||
RefSpecs: specs,
|
||||
Auth: auth,
|
||||
// Прапорець Prune тут не ставиться ніколи — див. коментар до
|
||||
// файлу. Видаляє саме перелік specs, і нічого крім нього.
|
||||
})
|
||||
switch {
|
||||
case err == nil, errors.Is(err, git.NoErrAlreadyUpToDate):
|
||||
return nil
|
||||
case isMissingRemoteRef(err):
|
||||
// Гілки там уже немає. Для видалення це і є успіх.
|
||||
return nil
|
||||
default:
|
||||
return r.explain(err)
|
||||
}
|
||||
}
|
||||
|
||||
// isMissingRemoteRef — чи каже помилка «такого посилання там немає».
|
||||
//
|
||||
// Текстом, бо go-git і сервери на тому кінці повідомляють про це
|
||||
// по-різному й жодного окремого типу помилки для цього не мають.
|
||||
// Помилитись тут дешево в один бік і дорого в інший: прийняти чужу
|
||||
// помилку за «уже видалено» означало б зняти рядок із черги, не
|
||||
// видаливши гілку. Тому перелік вузький — рівно ті формулювання, які
|
||||
// означають відсутність посилання.
|
||||
func isMissingRemoteRef(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
for _, s := range []string{
|
||||
"reference not found",
|
||||
"remote ref does not exist",
|
||||
"deleting a non-existent ref",
|
||||
"unable to delete",
|
||||
"no such ref",
|
||||
} {
|
||||
if strings.Contains(msg, s) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
143
server/internal/gitstore/branches_test.go
Normal file
143
server/internal/gitstore/branches_test.go
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
package gitstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Видалення гілки: локально й на «дзеркалі».
|
||||
//
|
||||
// Дзеркалом тут працює звичайний голий репозиторій на диску, до якого
|
||||
// ходимо по file://. Це не спрощення заради тесту: у справжньому push
|
||||
// відрізняється лише транспорт, а перевіряється тут інше — що видалення
|
||||
// адресне. Тобто що після нього на тому кінці зникає РІВНО названа
|
||||
// гілка, а сусідня, про яку ніхто не говорив, лишається на місці. Саме
|
||||
// це відрізняє нашу поведінку від prune, якого ми свідомо не робимо.
|
||||
|
||||
func gitOut(t *testing.T, dir string, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func TestDeleteBranchLocal(t *testing.T) {
|
||||
s := New(t.TempDir())
|
||||
|
||||
write(t, s, "device/sw-01-10.0.0.1", "sw-01/running.cfg", "hostname sw-01\n")
|
||||
write(t, s, "device/sw-02-10.0.0.2", "sw-02/running.cfg", "hostname sw-02\n")
|
||||
|
||||
removed, err := s.DeleteBranch("t.git", "device/sw-01-10.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatalf("видалення: %v", err)
|
||||
}
|
||||
if !removed {
|
||||
t.Fatal("гілка була — мало повернутись true")
|
||||
}
|
||||
|
||||
refs := gitOut(t, filepath.Join(s.Root(), "t.git"), "branch", "--list")
|
||||
if strings.Contains(refs, "sw-01") {
|
||||
t.Errorf("гілка лишилась: %s", refs)
|
||||
}
|
||||
if !strings.Contains(refs, "sw-02") {
|
||||
t.Errorf("сусідню гілку не мало зачепити: %s", refs)
|
||||
}
|
||||
|
||||
// Повтор — не помилка: черга видалень повторює спробу, доки не
|
||||
// дійде до дзеркала, і другий прохід не має падати.
|
||||
removed, err = s.DeleteBranch("t.git", "device/sw-01-10.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatalf("повторне видалення: %v", err)
|
||||
}
|
||||
if removed {
|
||||
t.Error("гілки вже не було — мало повернутись false")
|
||||
}
|
||||
|
||||
// Репозиторію немає взагалі — теж не помилка: хост могли видалити
|
||||
// до першого зібраного конфігу.
|
||||
if _, err := s.DeleteBranch("немає.git", "device/x"); err != nil {
|
||||
t.Errorf("відсутній репозиторій: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// localURLPath робить із шляху файлової системи шлях для file://.
|
||||
func localURLPath(dir string) string {
|
||||
p := filepath.ToSlash(dir)
|
||||
if !strings.HasPrefix(p, "/") {
|
||||
p = "/" + p
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestDeleteRemoteRefIsTargeted(t *testing.T) {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("для file:// потрібен git у PATH")
|
||||
}
|
||||
root := t.TempDir()
|
||||
s := New(root)
|
||||
|
||||
write(t, s, "device/sw-01-10.0.0.1", "sw-01/running.cfg", "hostname sw-01\n")
|
||||
write(t, s, "device/sw-02-10.0.0.2", "sw-02/running.cfg", "hostname sw-02\n")
|
||||
|
||||
// «Дзеркало».
|
||||
remoteDir := filepath.Join(root, "mirror.git")
|
||||
gitOut(t, root, "init", "--bare", remoteDir)
|
||||
// Приймати push у поточну гілку голого репозиторію нікому не
|
||||
// заважає, але Forgejo цього не вимагає — вирівнюємо поведінку.
|
||||
gitOut(t, remoteDir, "config", "receive.denyCurrentBranch", "ignore")
|
||||
|
||||
// Три скісні, не дві: у file:// одразу за ними йде ХОСТ, і на
|
||||
// Windows "file://C:/..." означає репозиторій на машині "C:".
|
||||
// На Linux провідна скісна в шляху вже є, тому помилки там не
|
||||
// видно — тест зеленів, доки його не прогнали на Windows.
|
||||
rem := Remote{URL: "file://" + localURLPath(remoteDir), Auth: AuthNone}
|
||||
if _, err := s.Mirror(context.Background(), "t.git", rem); err != nil {
|
||||
t.Fatalf("перший push: %v", err)
|
||||
}
|
||||
if out := gitOut(t, remoteDir, "branch", "--list"); !strings.Contains(out, "sw-01") ||
|
||||
!strings.Contains(out, "sw-02") {
|
||||
t.Fatalf("дзеркало не отримало обидві гілки: %s", out)
|
||||
}
|
||||
|
||||
// Хост видалено: локальна гілка знята, і на дзеркало йде видалення
|
||||
// рівно її.
|
||||
if _, err := s.DeleteBranch("t.git", "device/sw-01-10.0.0.1"); err != nil {
|
||||
t.Fatalf("локальне видалення: %v", err)
|
||||
}
|
||||
if err := s.DeleteRemoteRefs(context.Background(), "t.git", rem,
|
||||
[]string{"device/sw-01-10.0.0.1"}); err != nil {
|
||||
t.Fatalf("видалення на дзеркалі: %v", err)
|
||||
}
|
||||
|
||||
out := gitOut(t, remoteDir, "branch", "--list")
|
||||
if strings.Contains(out, "sw-01") {
|
||||
t.Errorf("гілка лишилась на дзеркалі: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "sw-02") {
|
||||
t.Errorf("сусідня гілка мала лишитись — ми її не називали: %s", out)
|
||||
}
|
||||
|
||||
// Повтор після успіху. Черга повторює спробу після будь-якої
|
||||
// невизначеності (обрив на відповіді, перезапуск процесу), і
|
||||
// «такої гілки там немає» для видалення означає успіх, а не збій.
|
||||
if err := s.DeleteRemoteRefs(context.Background(), "t.git", rem,
|
||||
[]string{"device/sw-01-10.0.0.1"}); err != nil {
|
||||
t.Errorf("повторне видалення на дзеркалі: %v", err)
|
||||
}
|
||||
|
||||
// І головне: звичайне дзеркалення після цього не намагається
|
||||
// повернути видалену гілку назад.
|
||||
if _, err := s.Mirror(context.Background(), "t.git", rem); err != nil {
|
||||
t.Fatalf("push після видалення: %v", err)
|
||||
}
|
||||
if out := gitOut(t, remoteDir, "branch", "--list"); strings.Contains(out, "sw-01") {
|
||||
t.Errorf("push повернув видалену гілку: %s", out)
|
||||
}
|
||||
}
|
||||
77
server/internal/gitstore/deploykey.go
Normal file
77
server/internal/gitstore/deploykey.go
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
package gitstore
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
cssh "golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// GenerateDeployKey створює пару ключів для дзеркалення.
|
||||
//
|
||||
// НАВІЩО СЕРВЕР, А НЕ ЛЮДИНА. Дзеркалення й раніше вміло ходити по SSH,
|
||||
// але приватний ключ треба було зробити самому й вставити в поле. Це
|
||||
// працює й це неправильно: приватний ключ мандрує через ssh-keygen,
|
||||
// буфер обміну, історію команд і, як щастить, через месенджер до
|
||||
// колеги. Найцінніший секрет системи проходить рівно тим шляхом, яким
|
||||
// секрети й витікають.
|
||||
//
|
||||
// Тому пару робить сервер: приватна половина одразу лягає зашифрованою
|
||||
// й назовні не виходить ніколи, а людині показується ПУБЛІЧНА — та, яку
|
||||
// й треба віднести в репозиторій.
|
||||
//
|
||||
// НАВІЩО САМЕ КЛЮЧ РОЗГОРТАННЯ. Особистий токен відкриває всі
|
||||
// репозиторії власника; ключ розгортання — рівно один. Для системи, яка
|
||||
// щоночі складає туди конфіги всієї мережі, різниця між «доступ до
|
||||
// одного репозиторію» і «доступ до всього, що є в акаунта» — це
|
||||
// різниця між інцидентом і катастрофою.
|
||||
//
|
||||
// ed25519, а не RSA: коротший, швидший, підтримується Forgejo, Gitea,
|
||||
// GitHub і GitLab, і не має параметра «скільки біт», у якому можна
|
||||
// помилитись у бік 1024.
|
||||
func GenerateDeployKey(comment string) (privatePEM string, publicKey string, err error) {
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("генерація ключа: %w", err)
|
||||
}
|
||||
|
||||
block, err := cssh.MarshalPrivateKey(priv, comment)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("серіалізація ключа: %w", err)
|
||||
}
|
||||
|
||||
sshPub, err := cssh.NewPublicKey(pub)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("публічна частина: %w", err)
|
||||
}
|
||||
|
||||
// Коментар у кінці рядка — те, що людина побачить у переліку ключів
|
||||
// репозиторію. Без нього там стоїть безіменний ключ, і за півроку
|
||||
// ніхто не згадає, чий він і чи можна його прибрати.
|
||||
authorized := strings.TrimSpace(string(cssh.MarshalAuthorizedKey(sshPub)))
|
||||
if comment != "" {
|
||||
authorized += " " + comment
|
||||
}
|
||||
return string(pem.EncodeToMemory(block)), authorized, nil
|
||||
}
|
||||
|
||||
// PublicKeyOf відновлює публічну частину зі збереженого приватного ключа.
|
||||
//
|
||||
// Потрібне, щоб показати ключ ще раз: людина заводить його в репозиторії
|
||||
// не тієї ж хвилини, коли створює, а публічну половину ми ніде не
|
||||
// зберігаємо — вона повністю виводиться з приватної, і друга копія
|
||||
// означала б два джерела однієї істини.
|
||||
func PublicKeyOf(privatePEM, comment string) (string, error) {
|
||||
signer, err := cssh.ParsePrivateKey([]byte(privatePEM))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("розбір ключа: %w", err)
|
||||
}
|
||||
authorized := strings.TrimSpace(string(cssh.MarshalAuthorizedKey(signer.PublicKey())))
|
||||
if comment != "" {
|
||||
authorized += " " + comment
|
||||
}
|
||||
return authorized, nil
|
||||
}
|
||||
56
server/internal/gitstore/deploykey_test.go
Normal file
56
server/internal/gitstore/deploykey_test.go
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
package gitstore
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
cssh "golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// Згенерована пара має бути придатною там, куди її понесуть: приватна —
|
||||
// для go-git, публічна — у рядок authorized_keys репозиторію.
|
||||
func TestGenerateDeployKeyUsable(t *testing.T) {
|
||||
priv, pub, err := GenerateDeployKey("netpulse: дзеркало")
|
||||
if err != nil {
|
||||
t.Fatalf("генерація: %v", err)
|
||||
}
|
||||
|
||||
if _, err := cssh.ParsePrivateKey([]byte(priv)); err != nil {
|
||||
t.Fatalf("приватний ключ не розбирається: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(pub, "ssh-ed25519 ") {
|
||||
t.Fatalf("публічний ключ не у форматі authorized_keys: %q", pub)
|
||||
}
|
||||
if !strings.HasSuffix(pub, " netpulse: дзеркало") {
|
||||
t.Fatalf("публічний ключ без коментаря: %q", pub)
|
||||
}
|
||||
if _, _, _, _, err := cssh.ParseAuthorizedKey([]byte(pub)); err != nil {
|
||||
t.Fatalf("публічний ключ не приймається як authorized_key: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Публічну половину має бути видно ще раз — людина заводить ключ у
|
||||
// репозиторії не тієї ж хвилини, коли створює.
|
||||
func TestPublicKeyOfMatchesGenerated(t *testing.T) {
|
||||
priv, pub, err := GenerateDeployKey("np")
|
||||
if err != nil {
|
||||
t.Fatalf("генерація: %v", err)
|
||||
}
|
||||
again, err := PublicKeyOf(priv, "np")
|
||||
if err != nil {
|
||||
t.Fatalf("відновлення: %v", err)
|
||||
}
|
||||
if again != pub {
|
||||
t.Fatalf("відновлена публічна половина інша:\n%s\n%s", pub, again)
|
||||
}
|
||||
}
|
||||
|
||||
// Дві пари не можуть збігтись — інакше «свій» ключ відкривав би чужий
|
||||
// репозиторій.
|
||||
func TestGenerateDeployKeyIsRandom(t *testing.T) {
|
||||
_, a, _ := GenerateDeployKey("x")
|
||||
_, b, _ := GenerateDeployKey("x")
|
||||
if a == b {
|
||||
t.Fatal("дві згенеровані пари однакові")
|
||||
}
|
||||
}
|
||||
624
server/internal/gitstore/mirror.go
Normal file
624
server/internal/gitstore/mirror.go
Normal file
|
|
@ -0,0 +1,624 @@
|
|||
// Дзеркалення локального репозиторію на зовнішній Git.
|
||||
//
|
||||
// Що це таке в одному реченні: `git push --all` на Forgejo/Gitea,
|
||||
// GitHub або GitLab, зроблений чистим Go (go-git), без виклику
|
||||
// системного git і без cgo — образ і далі збирається без C-тулчейна.
|
||||
//
|
||||
// Три рішення, які тут зафіксовані.
|
||||
//
|
||||
// ПЕРШЕ: push ніколи не входить у шлях збору конфігу. Цей файл не
|
||||
// викликається ані зі StoreConfig, ані з диспетчера завдань. Його
|
||||
// смикає окремий фоновий такт (grpcapi/ncm_mirror.go), і недоступний
|
||||
// віддалений сервер коштує рівно нічого — бекап уже ліг у базу й у
|
||||
// локальний Git до того, як хтось згадав про дзеркало.
|
||||
//
|
||||
// ДРУГЕ: без -f. Refspec навмисно без плюса, тобто push або
|
||||
// перемотується вперед, або відмовляється. Дзеркало заводять як копію
|
||||
// на випадок втрати; механізм, який уміє мовчки переписати віддалену
|
||||
// історію, — це не копія, а другий спосіб її втратити. Перезапис
|
||||
// існує, але окремою свідомою дією людини (Remote.Force).
|
||||
//
|
||||
// ТРЕТЄ: ключ SSH-сервера звіряється завжди. Для мережевого заліза в
|
||||
// цьому продукті ухвалено протилежне (agent/internal/ncmx/transport.go),
|
||||
// і там воно правильне: сотня коробок, кожна перегенеровує ключ після
|
||||
// прошивки. Тут один сервер, і везе він за периметр архів конфігів із
|
||||
// ключами та хешами паролів. Довіритись першому, хто відповів на порту
|
||||
// 22, означало б віддати цей архів тому, хто перехопив DNS.
|
||||
package gitstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/config"
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
"github.com/go-git/go-git/v5/plumbing/transport"
|
||||
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
|
||||
gitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh"
|
||||
"github.com/go-git/go-git/v5/storage/memory"
|
||||
cssh "golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// Remote — куди й чим пушити.
|
||||
//
|
||||
// Secret ніколи не потрапляє ні в структуру відповіді API, ні в журнал,
|
||||
// ні в текст помилки: усе, що йде назовні з цього файлу, проходить
|
||||
// через Scrub.
|
||||
type Remote struct {
|
||||
URL string
|
||||
// Auth: "https" — токен у Secret; "ssh" — приватний ключ у Secret;
|
||||
// "none" — анонімно (буває лише для перевірки читання).
|
||||
Auth string
|
||||
User string
|
||||
Secret string
|
||||
HostKey string
|
||||
|
||||
// Force дозволяє перезаписати віддалену гілку, яка розійшлася з
|
||||
// локальною. Ніколи не вмикається само: див. коментар до пакета.
|
||||
Force bool
|
||||
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
const (
|
||||
AuthNone = "none"
|
||||
AuthHTTPS = "https"
|
||||
AuthSSH = "ssh"
|
||||
)
|
||||
|
||||
// PushStat — підсумок такту дзеркалення.
|
||||
type PushStat struct {
|
||||
// Refs — скільки гілок поїхало (або вже там було).
|
||||
Refs int
|
||||
// StateHash — відбиток локальних посилань на момент push. Поки він
|
||||
// не змінився, у мережу ходити немає по що.
|
||||
StateHash string
|
||||
UpToDate bool
|
||||
Took time.Duration
|
||||
}
|
||||
|
||||
// ProbeStat — що видно на тому кінці до будь-якого запису.
|
||||
type ProbeStat struct {
|
||||
// Refs — скільки посилань уже є у віддаленому репозиторії. Нуль —
|
||||
// порожній репозиторій, найчастіший і найпростіший випадок.
|
||||
Refs int
|
||||
Empty bool
|
||||
// HostKey — ключ, який показав SSH-сервер. Порожній для HTTPS.
|
||||
HostKey string
|
||||
// HostKeyKnown — чи збігся він із закріпленим.
|
||||
HostKeyKnown bool
|
||||
}
|
||||
|
||||
var (
|
||||
ErrNoRepo = errors.New("локального репозиторію ще немає — жодного конфігу не зібрано")
|
||||
ErrHostKeyUnset = errors.New("ключ SSH-сервера не закріплено: спершу виконайте перевірку з'єднання")
|
||||
ErrDiverged = errors.New("віддалене дерево розійшлося з локальним")
|
||||
ErrNoRemoteURL = errors.New("не задано адресу віддаленого репозиторію")
|
||||
ErrPlainHTTP = errors.New("http дозволено лише до адрес усередині мережі")
|
||||
ErrBadRemoteURL = errors.New("адресу репозиторію не розібрано")
|
||||
ErrUnsupportedTr = errors.New("підтримуються лише https:// і ssh:// (або git@хост:шлях)")
|
||||
)
|
||||
|
||||
// mirrorRefSpec — усі локальні гілки в однойменні віддалені.
|
||||
//
|
||||
// Шаблон, а не перелік. Це і є відповідь на питання «що дзеркалити»:
|
||||
// не «конфіги», а «все, що лежить у репозиторії тенанта». Гілка
|
||||
// device/<uuid> сьогодні одна за видом, але наступний вид даних, який
|
||||
// хтось покладе в цей самий репозиторій, поїде без жодної правки тут —
|
||||
// саме тому перелік гілок не зашитий.
|
||||
//
|
||||
// refs/heads/*, а не refs/*: службові простори (refs/notes,
|
||||
// refs/pull/*, які створює сам Forgejo) дзеркало не чіпає.
|
||||
const mirrorRefSpec = "refs/heads/*:refs/heads/*"
|
||||
|
||||
// Mirror пушить усі гілки репозиторію тенанта на віддалений сервер.
|
||||
//
|
||||
// Блокування репозиторію НЕ береться. Push читає об'єкти й посилання,
|
||||
// а lockFor охороняє запис; тримати його на весь час мережевої операції
|
||||
// означало б, що повільний Forgejo зупиняє нічний збір конфігів — рівно
|
||||
// та залежність, якої весь цей модуль уникає.
|
||||
func (s *Store) Mirror(ctx context.Context, repoName string, r Remote) (PushStat, error) {
|
||||
var stat PushStat
|
||||
if s.root == "" {
|
||||
return stat, ErrDisabled
|
||||
}
|
||||
|
||||
started := time.Now()
|
||||
full := filepath.Join(s.root, repoName)
|
||||
repo, err := git.PlainOpen(full)
|
||||
if err != nil {
|
||||
if errors.Is(err, git.ErrRepositoryNotExists) {
|
||||
return stat, ErrNoRepo
|
||||
}
|
||||
return stat, fmt.Errorf("gitstore: відкриття %s: %w", repoName, err)
|
||||
}
|
||||
|
||||
refs, hash, err := localRefs(repo)
|
||||
if err != nil {
|
||||
return stat, err
|
||||
}
|
||||
stat.Refs, stat.StateHash = len(refs), hash
|
||||
if len(refs) == 0 {
|
||||
// Порожній репозиторій пушити нічим, і це не помилка: так
|
||||
// виглядає інсталяція, де дзеркало ввімкнули до першого бекапу.
|
||||
stat.UpToDate = true
|
||||
stat.Took = time.Since(started)
|
||||
return stat, nil
|
||||
}
|
||||
|
||||
auth, err := r.authMethod()
|
||||
if err != nil {
|
||||
return stat, err
|
||||
}
|
||||
|
||||
spec := mirrorRefSpec
|
||||
if r.Force {
|
||||
spec = "+" + spec
|
||||
}
|
||||
|
||||
remote := git.NewRemote(repo.Storer, &config.RemoteConfig{
|
||||
Name: "mirror",
|
||||
URLs: []string{r.URL},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, r.timeout())
|
||||
defer cancel()
|
||||
|
||||
err = remote.PushContext(ctx, &git.PushOptions{
|
||||
RemoteName: "mirror",
|
||||
RefSpecs: []config.RefSpec{config.RefSpec(spec)},
|
||||
Auth: auth,
|
||||
Force: r.Force,
|
||||
// Atomic: віддалений сервер або приймає весь набір гілок, або
|
||||
// жодної. Наполовину доїхане дзеркало виглядає як ціле — і саме
|
||||
// так його й прочитають у момент, коли воно знадобиться.
|
||||
Atomic: true,
|
||||
})
|
||||
stat.Took = time.Since(started)
|
||||
|
||||
switch {
|
||||
case err == nil:
|
||||
case errors.Is(err, git.NoErrAlreadyUpToDate):
|
||||
stat.UpToDate = true
|
||||
default:
|
||||
return stat, r.explain(err)
|
||||
}
|
||||
return stat, nil
|
||||
}
|
||||
|
||||
// LocalState — відбиток посилань локального репозиторію.
|
||||
//
|
||||
// Потрібен фоновому такту, щоб не ходити в мережу, коли нічого не
|
||||
// змінилось: дзеркалення не має бути постійним трафіком на чужий
|
||||
// сервер, а нічний збір міняє репозиторій раз на добу.
|
||||
func (s *Store) LocalState(repoName string) (refs int, hash string, err error) {
|
||||
if s.root == "" {
|
||||
return 0, "", ErrDisabled
|
||||
}
|
||||
repo, err := git.PlainOpen(filepath.Join(s.root, repoName))
|
||||
if err != nil {
|
||||
if errors.Is(err, git.ErrRepositoryNotExists) {
|
||||
return 0, "", ErrNoRepo
|
||||
}
|
||||
return 0, "", err
|
||||
}
|
||||
list, h, err := localRefs(repo)
|
||||
return len(list), h, err
|
||||
}
|
||||
|
||||
// localRefs повертає гілки й відбиток їхнього стану.
|
||||
func localRefs(repo *git.Repository) ([]string, string, error) {
|
||||
iter, err := repo.References()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer iter.Close()
|
||||
|
||||
var lines []string
|
||||
err = iter.ForEach(func(ref *plumbing.Reference) error {
|
||||
if ref.Type() != plumbing.HashReference || !ref.Name().IsBranch() {
|
||||
return nil
|
||||
}
|
||||
lines = append(lines, ref.Name().String()+" "+ref.Hash().String())
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
// Порядок обходу посилань не гарантований, а відбиток мусить
|
||||
// залежати лише від вмісту: інакше такт «бачив би зміну» щоразу й
|
||||
// ходив би в мережу кожну хвилину.
|
||||
sort.Strings(lines)
|
||||
sum := sha256.Sum256([]byte(strings.Join(lines, "\n")))
|
||||
return lines, hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
// Probe перевіряє з'єднання, не змінюючи нічого — ні тут, ні там.
|
||||
//
|
||||
// Окрема дія існує через дуже конкретний сценарій: людина вводить
|
||||
// адресу й токен і має дізнатись про помилку зараз, а не завтра з
|
||||
// журналу. Читання посилань (ls-remote) для цього достатньо: воно
|
||||
// проходить ту саму автентифікацію, що й push.
|
||||
//
|
||||
// Що воно НЕ доводить: право на запис. Токен «лише читання» проходить
|
||||
// перевірку й падає на першому push. Сказати про це чесно дешевше, ніж
|
||||
// створювати сміттєву гілку заради доведення.
|
||||
func Probe(ctx context.Context, r Remote) (ProbeStat, error) {
|
||||
var stat ProbeStat
|
||||
|
||||
if strings.TrimSpace(r.URL) == "" {
|
||||
return stat, ErrNoRemoteURL
|
||||
}
|
||||
if err := ValidateRemoteURL(r.URL); err != nil {
|
||||
return stat, err
|
||||
}
|
||||
|
||||
// Для SSH спершу дивимось на ключ сервера: людині треба показати
|
||||
// його ДО того, як вона щось збереже, інакше закріплювати нічого.
|
||||
if r.transportKind() == AuthSSH {
|
||||
key, err := ScanHostKey(ctx, r.URL, r.timeout())
|
||||
if err != nil {
|
||||
return stat, Scrub(err, r.Secret)
|
||||
}
|
||||
stat.HostKey = key
|
||||
stat.HostKeyKnown = r.HostKey != "" && sameHostKey(r.HostKey, key)
|
||||
if r.HostKey == "" {
|
||||
// Перший контакт: ключ показано, закріплення — за людиною.
|
||||
// Пробувати автентифікуватись до нього нема сенсу, бо саме
|
||||
// цей крок вона ще не підтвердила.
|
||||
return stat, nil
|
||||
}
|
||||
if !stat.HostKeyKnown {
|
||||
return stat, fmt.Errorf(
|
||||
"ключ сервера змінився: закріплено %s, показано %s",
|
||||
Fingerprint(r.HostKey), Fingerprint(key))
|
||||
}
|
||||
}
|
||||
|
||||
auth, err := r.authMethod()
|
||||
if err != nil {
|
||||
return stat, err
|
||||
}
|
||||
|
||||
remote := git.NewRemote(memory.NewStorage(), &config.RemoteConfig{
|
||||
Name: "probe",
|
||||
URLs: []string{r.URL},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, r.timeout())
|
||||
defer cancel()
|
||||
|
||||
refs, err := remote.ListContext(ctx, &git.ListOptions{Auth: auth})
|
||||
switch {
|
||||
case err == nil:
|
||||
stat.Refs = len(refs)
|
||||
case errors.Is(err, transport.ErrEmptyRemoteRepository):
|
||||
// Свіжостворений репозиторій у Forgejo. Найчастіший і
|
||||
// найкращий випадок — саме в такий і треба пушити.
|
||||
stat.Empty = true
|
||||
default:
|
||||
return stat, r.explain(err)
|
||||
}
|
||||
return stat, nil
|
||||
}
|
||||
|
||||
// ScanHostKey відкриває TCP-з'єднання й повертає ключ, який показує
|
||||
// SSH-сервер, у форматі authorized_keys.
|
||||
//
|
||||
// Аналог `ssh-keyscan`. Ключ не приймається автоматично: він
|
||||
// повертається людині, і закріплює його вона, натиснувши «зберегти».
|
||||
// Автоматичне TOFU виглядало б так само зручно й нічого б не
|
||||
// перевіряло — довіра тоді дістається тому, хто відповів першим.
|
||||
func ScanHostKey(ctx context.Context, rawURL string, timeout time.Duration) (string, error) {
|
||||
ep, err := transport.NewEndpoint(rawURL)
|
||||
if err != nil {
|
||||
return "", ErrBadRemoteURL
|
||||
}
|
||||
port := ep.Port
|
||||
if port == 0 {
|
||||
port = 22
|
||||
}
|
||||
addr := net.JoinHostPort(ep.Host, fmt.Sprint(port))
|
||||
|
||||
if timeout <= 0 {
|
||||
timeout = 15 * time.Second
|
||||
}
|
||||
d := net.Dialer{Timeout: timeout}
|
||||
conn, err := d.DialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("з'єднання з %s: %w", addr, err)
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(timeout))
|
||||
|
||||
var seen cssh.PublicKey
|
||||
cfg := &cssh.ClientConfig{
|
||||
User: "netpulse-keyscan",
|
||||
HostKeyCallback: func(_ string, _ net.Addr, key cssh.PublicKey) error {
|
||||
seen = key
|
||||
return nil
|
||||
},
|
||||
Timeout: timeout,
|
||||
}
|
||||
// Рукостискання свідомо доводиться до відмови в автентифікації:
|
||||
// ключ сервера показується ДО неї, а користувача з таким іменем на
|
||||
// сервері немає й не має бути.
|
||||
c, _, _, err := cssh.NewClientConn(conn, addr, cfg)
|
||||
if c != nil {
|
||||
_ = c.Close()
|
||||
}
|
||||
if seen == nil {
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("не вдалося прочитати ключ %s: %w", addr, err)
|
||||
}
|
||||
return "", fmt.Errorf("сервер %s не показав ключа", addr)
|
||||
}
|
||||
return strings.TrimSpace(string(cssh.MarshalAuthorizedKey(seen))), nil
|
||||
}
|
||||
|
||||
// Fingerprint — короткий відбиток ключа для показу людині.
|
||||
func Fingerprint(authorizedKey string) string {
|
||||
pub, _, _, _, err := cssh.ParseAuthorizedKey([]byte(authorizedKey))
|
||||
if err != nil {
|
||||
return "невідомий ключ"
|
||||
}
|
||||
return pub.Type() + " " + cssh.FingerprintSHA256(pub)
|
||||
}
|
||||
|
||||
func sameHostKey(a, b string) bool {
|
||||
pa, _, _, _, ea := cssh.ParseAuthorizedKey([]byte(a))
|
||||
pb, _, _, _, eb := cssh.ParseAuthorizedKey([]byte(b))
|
||||
if ea != nil || eb != nil {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare(pa.Marshal(), pb.Marshal()) == 1
|
||||
}
|
||||
|
||||
// ValidateRemoteURL відсіює адреси, якими дзеркало бути не може.
|
||||
//
|
||||
// file:// і локальні шляхи заборонені окремо й навмисно: адресу задає
|
||||
// користувач тенанта, а процес сервера має право писати на свій диск.
|
||||
// «Дзеркало» в /var/lib було б не копією за периметром, а способом
|
||||
// покласти дані туди, куди попросили.
|
||||
func ValidateRemoteURL(raw string) error {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ErrNoRemoteURL
|
||||
}
|
||||
ep, err := transport.NewEndpoint(raw)
|
||||
if err != nil {
|
||||
return ErrBadRemoteURL
|
||||
}
|
||||
switch ep.Protocol {
|
||||
case "https", "ssh":
|
||||
return nil
|
||||
case "http":
|
||||
// Конфіги везуть community-рядки, ключі й хеші паролів. Віддати
|
||||
// їх у відкритому вигляді через інтернет не можна; всередині
|
||||
// своєї мережі — питання власника мережі, і саме там стоїть
|
||||
// більшість самостійно піднятих Forgejo.
|
||||
host := ep.Host
|
||||
ips, err := net.LookupIP(host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("не вдалося розв'язати %s", host)
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if !(ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast()) {
|
||||
return ErrPlainHTTP
|
||||
}
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return ErrUnsupportedTr
|
||||
}
|
||||
}
|
||||
|
||||
// --- автентифікація ---------------------------------------------------
|
||||
|
||||
func (r Remote) transportKind() string {
|
||||
ep, err := transport.NewEndpoint(r.URL)
|
||||
if err != nil {
|
||||
return AuthNone
|
||||
}
|
||||
if ep.Protocol == "ssh" {
|
||||
return AuthSSH
|
||||
}
|
||||
return AuthHTTPS
|
||||
}
|
||||
|
||||
func (r Remote) timeout() time.Duration {
|
||||
if r.Timeout > 0 {
|
||||
return r.Timeout
|
||||
}
|
||||
// Півгодини. Перший push інсталяції, яка рік збирала конфіги, —
|
||||
// це вся історія за раз: на стенді один ZTE дає 32 тисячі рядків
|
||||
// на версію, і сотня хостів за рік — гігабайти. Хвилинний таймаут
|
||||
// зробив би перше вмикання неможливим саме там, де воно найпотрібніше.
|
||||
return 30 * time.Minute
|
||||
}
|
||||
|
||||
func (r Remote) authMethod() (transport.AuthMethod, error) {
|
||||
switch r.transportKind() {
|
||||
case AuthSSH:
|
||||
if strings.TrimSpace(r.Secret) == "" {
|
||||
return nil, errors.New("для SSH потрібен приватний ключ")
|
||||
}
|
||||
if strings.TrimSpace(r.HostKey) == "" {
|
||||
return nil, ErrHostKeyUnset
|
||||
}
|
||||
|
||||
user := r.User
|
||||
if user == "" {
|
||||
if ep, err := transport.NewEndpoint(r.URL); err == nil && ep.User != "" {
|
||||
user = ep.User
|
||||
}
|
||||
}
|
||||
if user == "" {
|
||||
user = "git"
|
||||
}
|
||||
|
||||
keys, err := gitssh.NewPublicKeys(user, []byte(r.Secret), "")
|
||||
if err != nil {
|
||||
// Текст go-ssh про розбір ключа не містить самого ключа,
|
||||
// але Scrub тут стоїть як правило, а не як виняток.
|
||||
return nil, Scrub(fmt.Errorf("приватний ключ: %w", err), r.Secret)
|
||||
}
|
||||
|
||||
cb, algos, err := pinnedHostKey(r.HostKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys.HostKeyCallback = cb
|
||||
keys.HostKeyAlgorithms = algos
|
||||
return keys, nil
|
||||
|
||||
default:
|
||||
if strings.TrimSpace(r.Secret) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
user := r.User
|
||||
if user == "" {
|
||||
if ep, err := transport.NewEndpoint(r.URL); err == nil && ep.User != "" {
|
||||
user = ep.User
|
||||
}
|
||||
}
|
||||
if user == "" {
|
||||
// GitHub і Forgejo приймають будь-який непорожній логін,
|
||||
// коли пароль — токен. Порожній логін вони відхиляють, і
|
||||
// повідомлення при цьому нічого не пояснює.
|
||||
user = "netpulse"
|
||||
}
|
||||
return &githttp.BasicAuth{Username: user, Password: r.Secret}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// pinnedHostKey — перевірка ключа сервера за закріпленим значенням.
|
||||
func pinnedHostKey(line string) (cssh.HostKeyCallback, []string, error) {
|
||||
pub, _, _, _, err := cssh.ParseAuthorizedKey([]byte(line))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("закріплений ключ сервера не розібрано: %w", err)
|
||||
}
|
||||
want := pub.Marshal()
|
||||
|
||||
algos := []string{pub.Type()}
|
||||
if pub.Type() == cssh.KeyAlgoRSA {
|
||||
// Сервер із RSA-ключем підписує рукостискання алгоритмом
|
||||
// rsa-sha2-*; сам ключ при цьому той самий. Без цього переліку
|
||||
// сучасний OpenSSH просто не запропонував би закріплений ключ.
|
||||
algos = []string{cssh.KeyAlgoRSASHA512, cssh.KeyAlgoRSASHA256, cssh.KeyAlgoRSA}
|
||||
}
|
||||
|
||||
return func(hostname string, _ net.Addr, key cssh.PublicKey) error {
|
||||
if subtle.ConstantTimeCompare(key.Marshal(), want) == 1 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("ключ сервера %s не збігається із закріпленим: показано %s, очікували %s",
|
||||
hostname, cssh.FingerprintSHA256(key), cssh.FingerprintSHA256(pub))
|
||||
}, algos, nil
|
||||
}
|
||||
|
||||
// --- помилки ----------------------------------------------------------
|
||||
|
||||
// userInfoRe знаходить «схема://щось@» — саме туди go-git і http.Client
|
||||
// вкладають логін із токеном, коли адресу ввели у вигляді
|
||||
// https://user:token@forgejo.example/repo.git.
|
||||
var userInfoRe = regexp.MustCompile(`([a-zA-Z][a-zA-Z0-9+.\-]*://)[^/@\s]*@`)
|
||||
|
||||
// Scrub прибирає з тексту помилки все, що не має потрапити ні в журнал,
|
||||
// ні в базу, ні на екран.
|
||||
//
|
||||
// Дві різні витоки, і друга неочевидна. Перша — сам токен, якщо він
|
||||
// потрапив у повідомлення. Друга — URL із логіном усередині: go-git
|
||||
// охоче вкладає повну адресу в текст помилки транспорту, а люди так
|
||||
// само охоче вставляють у поле адреси рядок, скопійований із інструкції
|
||||
// «git clone https://токен@…». Тому чиститься і те, і те.
|
||||
func Scrub(err error, secrets ...string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, s := range secrets {
|
||||
s = strings.TrimSpace(s)
|
||||
// Коротке значення замінювати небезпечно: воно збіглося б із
|
||||
// випадковим підрядком і перетворило повідомлення на кашу.
|
||||
if len(s) < 8 {
|
||||
continue
|
||||
}
|
||||
msg = strings.ReplaceAll(msg, s, "«секрет»")
|
||||
// Приватний ключ багаторядковий: у повідомленні від нього
|
||||
// зазвичай лишається лише перший рядок.
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
if line = strings.TrimSpace(line); len(line) >= 16 {
|
||||
msg = strings.ReplaceAll(msg, line, "«секрет»")
|
||||
}
|
||||
}
|
||||
}
|
||||
msg = userInfoRe.ReplaceAllString(msg, "$1")
|
||||
return errors.New(msg)
|
||||
}
|
||||
|
||||
// explain перекладає типові відмови транспорту зрозумілою мовою.
|
||||
//
|
||||
// Не косметика: «authentication required» нічого не каже людині, яка
|
||||
// щойно вставила токен і не знає, чи він не той, чи його не бачать, чи
|
||||
// репозиторію взагалі немає. Кожен із трьох випадків лікується різним
|
||||
// рухом.
|
||||
func (r Remote) explain(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, transport.ErrAuthenticationRequired):
|
||||
// RedactURL, а не r.URL: у поле адреси регулярно вставляють
|
||||
// рядок виду https://токен@forgejo/… з чужої інструкції, і без
|
||||
// цього виклику текст помилки поніс би токен у базу й на екран.
|
||||
return fmt.Errorf("сервер вимагає автентифікації: для %s потрібні логін і токен",
|
||||
RedactURL(r.URL))
|
||||
// Закріплений ключ звужує перелік алгоритмів до типу самого ключа —
|
||||
// інакше сучасний OpenSSH запропонував би ed25519 там, де ми
|
||||
// закріпили rsa, і збіг був би неможливий. Зворотний бік: коли ключ
|
||||
// сервера СПРАВДІ замінили на інший тип, рукостискання падає ще до
|
||||
// нашої перевірки, і go-ssh каже про алгоритми. Перевірено наживо на
|
||||
// Gitea з rsa-ключем і закріпленим ed25519 — саме цей текст.
|
||||
case strings.Contains(err.Error(), "no common algorithm for host key"):
|
||||
return errors.New("сервер показав ключ іншого типу, ніж закріплений — " +
|
||||
"найімовірніше, ключ сервера замінили. Виконайте перевірку з'єднання " +
|
||||
"й звірте відбиток, перш ніж закріплювати новий")
|
||||
case errors.Is(err, transport.ErrAuthorizationFailed):
|
||||
return errors.New("доступ відхилено: токен не має права запису в цей репозиторій")
|
||||
case errors.Is(err, transport.ErrRepositoryNotFound):
|
||||
return fmt.Errorf("репозиторію за адресою %s немає — створіть його на сервері",
|
||||
RedactURL(r.URL))
|
||||
// ErrForceNeeded і текстове «non-fast-forward update: <ref>» — це
|
||||
// два боки однієї відмови: перший приходить із перевірки набору
|
||||
// команд, другий із відповіді сервера на конкретну гілку.
|
||||
case errors.Is(err, git.ErrForceNeeded) ||
|
||||
strings.Contains(err.Error(), "non-fast-forward"):
|
||||
return fmt.Errorf("%w: у ньому є коміти, яких немає тут. "+
|
||||
"Дзеркало не переписує чужу історію само — або вкажіть порожній репозиторій, "+
|
||||
"або підтвердьте перезапис вручну", ErrDiverged)
|
||||
}
|
||||
return Scrub(err, r.Secret)
|
||||
}
|
||||
|
||||
// RedactURL — адреса без логіна й пароля, придатна для журналу.
|
||||
func RedactURL(raw string) string {
|
||||
if u, err := url.Parse(raw); err == nil && u.User != nil {
|
||||
u.User = nil
|
||||
return u.String()
|
||||
}
|
||||
return userInfoRe.ReplaceAllString(raw, "$1")
|
||||
}
|
||||
201
server/internal/gitstore/mirror_test.go
Normal file
201
server/internal/gitstore/mirror_test.go
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
package gitstore
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-git/go-git/v5/plumbing/transport"
|
||||
cssh "golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// Токен не має витікати в текст помилки — ЖОДНИМ із трьох шляхів.
|
||||
//
|
||||
// Шлях перший: сам токен у повідомленні транспорту.
|
||||
// Шлях другий: адреса з логіном усередині, яку go-git вкладає в помилку
|
||||
// цілком.
|
||||
// Шлях третій — найпідступніший: наш власний текст, зібраний із r.URL,
|
||||
// у якому людина залишила токен, скопіювавши адресу з інструкції.
|
||||
//
|
||||
// Третій і був помилкою в першій редакції цього файлу: explain
|
||||
// підставляв r.URL напряму, і повідомлення «сервер вимагає
|
||||
// автентифікації для https://токен@forgejo/...» лягало б і в базу
|
||||
// (mirror_last_error), і на екран, і в журнал.
|
||||
|
||||
const fakeToken = "ghp_0123456789abcdefghijklmnopqrstuvwxyz"
|
||||
|
||||
func TestScrubRemovesTokenFromError(t *testing.T) {
|
||||
err := Scrub(fmt.Errorf(
|
||||
`unexpected client error: unexpected requesting "https://x:%s@forgejo.example/np.git/info/refs" status code: 403`,
|
||||
fakeToken), fakeToken)
|
||||
|
||||
if strings.Contains(err.Error(), fakeToken) {
|
||||
t.Fatalf("токен лишився в помилці: %s", err)
|
||||
}
|
||||
// Тут спрацювали обидві сітки одразу: заміна токена, а потім зняття
|
||||
// логіна з адреси разом із нею. Лишитись у тексті має читабельна
|
||||
// адреса — саме вона й пояснює, куди не поїхало.
|
||||
if !strings.Contains(err.Error(), "https://forgejo.example/np.git") {
|
||||
t.Errorf("адреса мала лишитись читабельною: %s", err)
|
||||
}
|
||||
|
||||
// А коли токен стоїть не в адресі, від нього лишається позначка:
|
||||
// прибрати мовчки означало б зробити повідомлення незрозумілим.
|
||||
plain := Scrub(fmt.Errorf("token %s rejected by server", fakeToken), fakeToken)
|
||||
if strings.Contains(plain.Error(), fakeToken) {
|
||||
t.Fatalf("токен лишився: %s", plain)
|
||||
}
|
||||
if !strings.Contains(plain.Error(), "«секрет»") {
|
||||
t.Errorf("очікували заміну на «секрет», отримали %s", plain)
|
||||
}
|
||||
}
|
||||
|
||||
// Логін в адресі прибирається навіть тоді, коли самого токена ми не
|
||||
// знаємо: у полі URL може лежати чужий рядок, а Secret — порожній.
|
||||
func TestScrubStripsUserInfoWithoutKnowingSecret(t *testing.T) {
|
||||
err := Scrub(errors.New(
|
||||
`Post "https://netpulse:s3cr3t-token-value@git.example/np.git/git-receive-pack": timeout`))
|
||||
|
||||
if strings.Contains(err.Error(), "s3cr3t-token-value") {
|
||||
t.Fatalf("логін з адреси лишився: %s", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "https://git.example/np.git") {
|
||||
t.Errorf("адреса має лишитись читабельною, отримали %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Наші власні пояснення збирають текст із r.URL — і саме там токен
|
||||
// найлегше пронести повз Scrub, бо Secret у цьому випадку порожній.
|
||||
func TestExplainNeverEchoesURLCredentials(t *testing.T) {
|
||||
r := Remote{URL: "https://netpulse:" + fakeToken + "@forgejo.example/np.git"}
|
||||
|
||||
for name, in := range map[string]error{
|
||||
"потрібна автентифікація": transport.ErrAuthenticationRequired,
|
||||
"репозиторію немає": transport.ErrRepositoryNotFound,
|
||||
"чужа помилка": fmt.Errorf("dial tcp: %s", r.URL),
|
||||
} {
|
||||
got := r.explain(in)
|
||||
if strings.Contains(got.Error(), fakeToken) {
|
||||
t.Errorf("%s: токен просочився: %s", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Приватний ключ у повідомленні — той самий клас витоку, що й токен,
|
||||
// тільки багаторядковий.
|
||||
func TestScrubRemovesPrivateKeyBody(t *testing.T) {
|
||||
key := "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmU\n-----END OPENSSH PRIVATE KEY-----"
|
||||
err := Scrub(fmt.Errorf("ssh: не вдалося розібрати %s", key), key)
|
||||
if strings.Contains(err.Error(), "b3BlbnNzaC1rZXktdjEAAAAABG5vbmU") {
|
||||
t.Fatalf("тіло ключа лишилось: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Короткий секрет не замінюється: інакше випадковий підрядок перетворив
|
||||
// би повідомлення на кашу.
|
||||
func TestScrubIgnoresShortSecrets(t *testing.T) {
|
||||
err := Scrub(errors.New("connection to git refused"), "git")
|
||||
if err.Error() != "connection to git refused" {
|
||||
t.Fatalf("коротке значення не мало замінюватись: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- ключ хоста -------------------------------------------------------
|
||||
|
||||
func newHostKey(t *testing.T) (cssh.Signer, string) {
|
||||
t.Helper()
|
||||
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
signer, err := cssh.NewSignerFromKey(priv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
line := strings.TrimSpace(string(cssh.MarshalAuthorizedKey(signer.PublicKey())))
|
||||
return signer, line
|
||||
}
|
||||
|
||||
func TestPinnedHostKeyAcceptsOnlyPinned(t *testing.T) {
|
||||
ours, line := newHostKey(t)
|
||||
other, _ := newHostKey(t)
|
||||
|
||||
cb, algos, err := pinnedHostKey(line)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(algos) == 0 || algos[0] != cssh.KeyAlgoED25519 {
|
||||
t.Errorf("алгоритм ключа має бути закріплений, отримали %v", algos)
|
||||
}
|
||||
addr := &net.TCPAddr{IP: net.IPv4(10, 0, 0, 1), Port: 22}
|
||||
|
||||
if err := cb("git.example:22", addr, ours.PublicKey()); err != nil {
|
||||
t.Fatalf("закріплений ключ мав пройти: %v", err)
|
||||
}
|
||||
err = cb("git.example:22", addr, other.PublicKey())
|
||||
if err == nil {
|
||||
t.Fatal("чужий ключ пройшов перевірку")
|
||||
}
|
||||
// Повідомлення має нести обидва відбитки: людині треба звірити те,
|
||||
// що вона бачить, із тим, що показує сервер, а не гадати.
|
||||
if !strings.Contains(err.Error(), "SHA256:") {
|
||||
t.Errorf("у помилці немає відбитка: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Без закріпленого ключа push по SSH не збирається взагалі. Це і є
|
||||
// рішення, протилежне до того, що ухвалено для мережевого заліза.
|
||||
func TestSSHRefusesWithoutPinnedHostKey(t *testing.T) {
|
||||
r := Remote{
|
||||
URL: "ssh://git@forgejo.example/np/configs.git",
|
||||
Secret: "-----BEGIN OPENSSH PRIVATE KEY-----\nx\n-----END OPENSSH PRIVATE KEY-----",
|
||||
}
|
||||
if _, err := r.authMethod(); !errors.Is(err, ErrHostKeyUnset) {
|
||||
t.Fatalf("очікували ErrHostKeyUnset, отримали %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- адреси -----------------------------------------------------------
|
||||
|
||||
func TestValidateRemoteURL(t *testing.T) {
|
||||
ok := []string{
|
||||
"https://forgejo.example/np/configs.git",
|
||||
"ssh://git@forgejo.example:2222/np/configs.git",
|
||||
"git@github.com:np/configs.git",
|
||||
// http всередину мережі — типовий самостійно піднятий Forgejo.
|
||||
"http://127.0.0.1:3000/np/configs.git",
|
||||
"http://10.8.0.5/np/configs.git",
|
||||
}
|
||||
for _, u := range ok {
|
||||
if err := ValidateRemoteURL(u); err != nil {
|
||||
t.Errorf("%s мала пройти: %v", u, err)
|
||||
}
|
||||
}
|
||||
|
||||
bad := map[string]error{
|
||||
"": ErrNoRemoteURL,
|
||||
"file:///var/lib/x.git": ErrUnsupportedTr,
|
||||
"/var/lib/netpulse/x.git": ErrUnsupportedTr,
|
||||
// Відкритий http назовні везе ключі й хеші паролів як є.
|
||||
"http://8.8.8.8/np/configs.git": ErrPlainHTTP,
|
||||
}
|
||||
for u, want := range bad {
|
||||
if err := ValidateRemoteURL(u); !errors.Is(err, want) {
|
||||
t.Errorf("%q: очікували %v, отримали %v", u, want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactURL(t *testing.T) {
|
||||
got := RedactURL("https://netpulse:" + fakeToken + "@forgejo.example/np.git")
|
||||
if strings.Contains(got, fakeToken) {
|
||||
t.Fatalf("токен лишився: %s", got)
|
||||
}
|
||||
if got != "https://forgejo.example/np.git" {
|
||||
t.Errorf("отримали %s", got)
|
||||
}
|
||||
}
|
||||
|
|
@ -458,3 +458,49 @@ func short(sha string) string {
|
|||
}
|
||||
return sha
|
||||
}
|
||||
|
||||
// RenameBranch переносить гілку під нове ім'я.
|
||||
//
|
||||
// Потрібне, коли хост перейменували або змінили йому адресу: ім'я гілки
|
||||
// складається з них, і без переносу історія пристрою розпалась би на
|
||||
// дві — половина під старим іменем, половина під новим, і знайти першу
|
||||
// можна лише знаючи, як хост звався колись.
|
||||
//
|
||||
// Сама історія при цьому не змінюється: переставляється лише посилання.
|
||||
// Відсутність старої гілки помилкою не вважається — пристрій міг ще не
|
||||
// комітитись, або перенос уже відбувся раніше.
|
||||
func (s *Store) RenameBranch(repoName, from, to string) error {
|
||||
if from == to || from == "" || to == "" {
|
||||
return nil
|
||||
}
|
||||
path := filepath.Join(s.root, repoName)
|
||||
|
||||
mu := s.lockFor(path)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
repo, err := open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
oldRef := plumbing.NewBranchReferenceName(from)
|
||||
ref, err := repo.Reference(oldRef, true)
|
||||
if err != nil {
|
||||
if errors.Is(err, plumbing.ErrReferenceNotFound) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("гілка %s: %w", from, err)
|
||||
}
|
||||
|
||||
newRef := plumbing.NewBranchReferenceName(to)
|
||||
if err := repo.Storer.SetReference(plumbing.NewHashReference(newRef, ref.Hash())); err != nil {
|
||||
return fmt.Errorf("нова гілка %s: %w", to, err)
|
||||
}
|
||||
// Стару прибираємо ПІСЛЯ того, як нова вказала на той самий коміт:
|
||||
// обрив між двома діями лишає історію досяжною хоч під одним іменем.
|
||||
if err := repo.Storer.RemoveReference(oldRef); err != nil {
|
||||
return fmt.Errorf("прибрати гілку %s: %w", from, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -277,10 +277,25 @@ func TestControlHandshake(t *testing.T) {
|
|||
if plan == nil {
|
||||
t.Fatal("план задач не надійшов")
|
||||
}
|
||||
if len(plan.Tasks) != 1 || plan.Tasks[0].CheckId != f.checkID {
|
||||
t.Fatalf("план зібрано неправильно: %+v", plan.Tasks)
|
||||
// У плані не один чек, а два. Другий — `topology.identify`, і його
|
||||
// заводить сам сервер при підключенні зонда (service.go,
|
||||
// EnsureIdentifyChecks): хост зі SNMP-доступом отримує розпізнавання
|
||||
// без жодного натискання.
|
||||
//
|
||||
// Тест писався до появи розпізнавання й перевіряв рівність одиниці.
|
||||
// Півтора року він цього не помічав, бо мовчки пропускався без
|
||||
// NETPULSE_TEST_DSN — перший же справжній прогін його завалив.
|
||||
// Шукаємо СВІЙ чек серед решти, а не покладаємось на їхню кількість:
|
||||
// наступний автоматичний чек інакше завалить його знову.
|
||||
var task *npv1.Task
|
||||
for _, tk := range plan.Tasks {
|
||||
if tk.CheckId == f.checkID {
|
||||
task = tk
|
||||
}
|
||||
}
|
||||
if task == nil {
|
||||
t.Fatalf("свого чека в плані немає: %+v", plan.Tasks)
|
||||
}
|
||||
task := plan.Tasks[0]
|
||||
if task.CheckType != "icmp.ping" {
|
||||
t.Fatalf("check_type = %q", task.CheckType)
|
||||
}
|
||||
|
|
@ -1088,6 +1103,22 @@ func TestPlanHashSkipsResend(t *testing.T) {
|
|||
ctx, cancel := context.WithTimeout(f.authCtx(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Спершу даємо серверу завести автоматичні чеки, і лише потім
|
||||
// рахуємо хеш.
|
||||
//
|
||||
// Інакше тест перевіряє не те, що збирався. Розпізнавання заводиться
|
||||
// при підключенні зонда, тобто МІЖ нашим BuildPlan і Hello: хеш, з
|
||||
// яким ми прийшли, застаріває дорогою, сервер чесно вирішує
|
||||
// переслати план — і тест звинувачує його в тому, що зробив сам.
|
||||
//
|
||||
// Виклик ідемпотентний: другий раз чек не заводиться, тож після
|
||||
// нього хеш уже стабільний — саме та властивість, яку тест і
|
||||
// перевіряє.
|
||||
if _, err := f.store.EnsureIdentifyChecks(f.ctx,
|
||||
&store.Agent{ID: f.agentID, TenantID: f.tenantID}); err != nil {
|
||||
t.Fatalf("EnsureIdentifyChecks: %v", err)
|
||||
}
|
||||
|
||||
// Перше підключення: дізнаємось хеш.
|
||||
plan, err := f.store.BuildPlan(f.ctx, &store.Agent{ID: f.agentID, TenantID: f.tenantID})
|
||||
if err != nil {
|
||||
|
|
|
|||
216
server/internal/grpcapi/ncm_commands.go
Normal file
216
server/internal/grpcapi/ncm_commands.go
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
package grpcapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
|
||||
"github.com/netpulse/netpulse/server/internal/store"
|
||||
)
|
||||
|
||||
// Масове виконання команд: серверна половина.
|
||||
//
|
||||
// Транспорту тут свого немає жодного — і в цьому вся суть. Завдання
|
||||
// їде тим самим ConfigJob, вивід повертається тим самим стрімом
|
||||
// UploadConfig, диспетчер працює в тому самому такті. Різниця в одному
|
||||
// полі (config_type) і в тому, куди лягає результат.
|
||||
|
||||
// commandOutcome — те, що надсилає зонд (див. ncmx.CommandReport).
|
||||
type commandOutcome struct {
|
||||
Command string `json:"command"`
|
||||
Output string `json:"output"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type commandReport struct {
|
||||
Outcomes []commandOutcome `json:"outcomes"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// isCommandUpload розрізняє вивід команд і зібраний конфіг.
|
||||
//
|
||||
// Заголовок від зонда — підказка, а не доказ: він приходить із поля, яке
|
||||
// агент лише переспівує з завдання, і зонд старішої версії заповнить
|
||||
// його як завгодно. Тому вирішує база: id завдання або є рядком прогону
|
||||
// команд, або ні. Ціна — один запит за первинним ключем; ціна помилки —
|
||||
// вивід `display cpu` у ролі версії конфігу в архіві.
|
||||
func (s *Service) isCommandUpload(ctx context.Context, header *npv1.ConfigHeader) bool {
|
||||
if header.GetJobId() == "" {
|
||||
return false
|
||||
}
|
||||
if header.GetConfigType() != store.CommandConfigType {
|
||||
// Швидкий шлях для звичайного бекапу: зайвий запит на кожен
|
||||
// зібраний конфіг нічого не дав би.
|
||||
return false
|
||||
}
|
||||
ok, err := s.store.IsCommandTarget(ctx, header.GetJobId())
|
||||
if err != nil {
|
||||
s.log.Warn("звірка завдання з прогоном команд", "job", header.GetJobId(), "err", err)
|
||||
return false
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
// storeCommandResult кладе вивід команд у рядок прогону.
|
||||
//
|
||||
// Повертає готову квитанцію, а не помилку: зонд має отримати відповідь
|
||||
// у будь-якому разі — інакше він вважатиме вивантаження незавершеним і
|
||||
// повторить його разом із новою сесією до пристрою.
|
||||
func (s *Service) storeCommandResult(ctx context.Context, header *npv1.ConfigHeader,
|
||||
body []byte, tr *npv1.ConfigTrailer) *npv1.ConfigReceipt {
|
||||
|
||||
targetID := header.GetJobId()
|
||||
|
||||
if !tr.GetSuccess() {
|
||||
// Зонд не дійшов до команд: не під'єднався, не дочекався
|
||||
// привітання. Показувати нічого, крім причини й стенограми, —
|
||||
// і саме вони тут найпотрібніші.
|
||||
msg := tr.GetError().GetMessage()
|
||||
if msg == "" {
|
||||
msg = "зонд не зміг виконати команди"
|
||||
}
|
||||
if err := s.store.FinishCommandTarget(ctx, targetID, "failed", msg, nil,
|
||||
tr.GetTranscript()); err != nil {
|
||||
s.log.Error("запис невдалого виконання команд", "target", targetID, "err", err)
|
||||
}
|
||||
return &npv1.ConfigReceipt{JobId: targetID, Accepted: true, Error: tr.GetError()}
|
||||
}
|
||||
|
||||
plain, err := decodeBody(body, header.GetEncoding())
|
||||
if err != nil {
|
||||
_ = s.store.FinishCommandTarget(ctx, targetID, "failed",
|
||||
"не вдалося розпакувати вивід: "+err.Error(), nil, tr.GetTranscript())
|
||||
return &npv1.ConfigReceipt{
|
||||
JobId: targetID, Accepted: false,
|
||||
Error: &npv1.Error{Code: "bad_encoding", Message: err.Error()},
|
||||
}
|
||||
}
|
||||
|
||||
// Сума звіряється так само, як для конфігу: обірваний посеред
|
||||
// передачі JSON розібрався б із помилкою, а обірваний на межі
|
||||
// елемента — мовчки, і хост лишився б із половиною виводу, схожою
|
||||
// на цілий.
|
||||
if want := tr.GetContentSha256(); len(want) > 0 {
|
||||
got := sha256.Sum256(plain)
|
||||
if !bytes.Equal(got[:], want) {
|
||||
_ = s.store.FinishCommandTarget(ctx, targetID, "failed",
|
||||
"тіло не відповідає заявленому sha256", nil, tr.GetTranscript())
|
||||
return &npv1.ConfigReceipt{
|
||||
JobId: targetID, Accepted: false,
|
||||
Error: &npv1.Error{
|
||||
Code: "checksum_mismatch", Retryable: true,
|
||||
Message: "тіло не відповідає заявленому sha256",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var rep commandReport
|
||||
if err := json.Unmarshal(plain, &rep); err != nil {
|
||||
_ = s.store.FinishCommandTarget(ctx, targetID, "failed",
|
||||
"незрозумілий формат виводу від зонда: "+err.Error(), nil, tr.GetTranscript())
|
||||
return &npv1.ConfigReceipt{
|
||||
JobId: targetID, Accepted: false,
|
||||
Error: &npv1.Error{Code: "bad_body", Message: err.Error()},
|
||||
}
|
||||
}
|
||||
|
||||
// Підготовчі команди профілю приїхали разом із командами людини —
|
||||
// зонд про різницю не знає й знати не мусить. Розмічаємо тут, за
|
||||
// переліком самого прогону.
|
||||
userCmds, err := s.store.CommandRunCommands(ctx, targetID)
|
||||
if err != nil {
|
||||
s.log.Warn("перелік команд прогону", "target", targetID, "err", err)
|
||||
}
|
||||
isUser := make(map[string]bool, len(userCmds))
|
||||
for _, c := range userCmds {
|
||||
isUser[c] = true
|
||||
}
|
||||
|
||||
outcomes := make([]store.CommandOutcome, 0, len(rep.Outcomes))
|
||||
for _, o := range rep.Outcomes {
|
||||
outcomes = append(outcomes, store.CommandOutcome{
|
||||
Command: o.Command,
|
||||
Output: o.Output,
|
||||
Error: o.Error,
|
||||
Prep: len(isUser) > 0 && !isUser[o.Command],
|
||||
})
|
||||
}
|
||||
|
||||
status := "success"
|
||||
if rep.Error != "" {
|
||||
status = "failed"
|
||||
}
|
||||
if err := s.store.FinishCommandTarget(ctx, targetID, status, rep.Error,
|
||||
outcomes, tr.GetTranscript()); err != nil {
|
||||
s.log.Error("запис результату команд", "target", targetID, "err", err)
|
||||
}
|
||||
|
||||
s.log.Info("вивід команд прийнято",
|
||||
"target", targetID, "команд", len(outcomes), "стан", status)
|
||||
|
||||
return &npv1.ConfigReceipt{JobId: targetID, Accepted: true}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Диспетчер
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// dispatchCommandJobs роздає хости прогонів живим сесіям.
|
||||
//
|
||||
// Їде в такті збору конфігів, а не власним таймером: це той самий шлях
|
||||
// «база → жива сесія», і другий цикл із власним інтервалом додав би ще
|
||||
// одне місце, де налаштування розходяться.
|
||||
func (s *Service) dispatchCommandJobs(ctx context.Context, online []string) {
|
||||
jobs, err := s.store.ClaimCommandJobs(ctx, online, 16, s.ring)
|
||||
if err != nil {
|
||||
s.log.Error("вибірка команд для виконання", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, j := range jobs {
|
||||
ok := s.PushToAgent(j.AgentID, &npv1.ControlDown{
|
||||
Payload: &npv1.ControlDown_ConfigJob{ConfigJob: j.Job},
|
||||
})
|
||||
if !ok {
|
||||
// Сесія обірвалась між вибіркою й відправкою. Хост падає, а
|
||||
// не повертається в чергу: на пристрої ще нічого не
|
||||
// відбувалось, але мовчазний повтор на зонді, який зараз
|
||||
// відвалюється, крутив би прогін нескінченно. Один рядок із
|
||||
// причиною — і людина сама вирішує, чи повторювати.
|
||||
_ = s.store.FinishCommandTarget(ctx, j.TargetID, "failed",
|
||||
"зонд відключився до надсилання завдання", nil, "")
|
||||
continue
|
||||
}
|
||||
if err := s.store.MarkCommandTargetSent(ctx, j.TargetID); err != nil {
|
||||
s.log.Warn("позначка про надсилання команд", "target", j.TargetID, "err", err)
|
||||
}
|
||||
s.log.Info("команди надіслано зонду",
|
||||
"target", j.TargetID, "agent", j.AgentID, "device", j.DeviceName)
|
||||
}
|
||||
}
|
||||
|
||||
// reapCommandRuns прибирає зависле.
|
||||
//
|
||||
// Рідкісним тактом навмисно: обидва випадки — це хвилини очікування, і
|
||||
// перевіряти їх щоп'ять секунд означало б платити запитом за подію, якої
|
||||
// в нормальній роботі не буває.
|
||||
func (s *Service) reapCommandRuns(ctx context.Context) {
|
||||
if n, err := s.store.ReapStuckCommandTargets(ctx, 2*time.Minute); err != nil {
|
||||
s.log.Warn("прибирання завислих команд", "err", err)
|
||||
} else if n > 0 {
|
||||
s.log.Warn("зависле виконання команд перезаведено або закрито", "рядків", n)
|
||||
}
|
||||
}
|
||||
|
||||
// settleCommandRuns закриває прогони, у яких не лишилось роботи.
|
||||
func (s *Service) settleCommandRuns(ctx context.Context) {
|
||||
if n, err := s.store.SettleCommandRuns(ctx); err != nil {
|
||||
s.log.Warn("закриття прогонів команд", "err", err)
|
||||
} else if n > 0 {
|
||||
s.log.Info("прогони команд завершено", "прогонів", n)
|
||||
}
|
||||
}
|
||||
|
|
@ -40,13 +40,48 @@ func (s *Service) DispatchConfigJobs(ctx context.Context, every time.Duration) {
|
|||
} else if n > 0 {
|
||||
s.log.Warn("завдання збору зависли й позначені як невдалі", "рядків", n)
|
||||
}
|
||||
s.reapCommandRuns(ctx)
|
||||
// Відкат прибирається в тому ж такті, але його «зависло»
|
||||
// означає інше, ніж у решти: зонд міг зникнути ПОСЕРЕД
|
||||
// заливки, і на пристрої лишився стан, якого не описує
|
||||
// жоден із двох конфігів. Тому там не відмова, а
|
||||
// контрольний збір — див. store.ReapStuckRollbacks.
|
||||
s.reapRollbacks(ctx)
|
||||
|
||||
case <-t.C:
|
||||
// Закриття доведених до кінця прогонів — на швидкому такті
|
||||
// й ДО перевірки, чи є живі зонди. Прогін, у якому останній
|
||||
// хост щойно відзвітував (або жоден і не мав зонда),
|
||||
// інакше висів би «у роботі» до прибиральника, а сторінка
|
||||
// весь цей час перечитувала б його щодві секунди.
|
||||
s.settleCommandRuns(ctx)
|
||||
// Підсумок контрольних зборів після відкатів — теж на
|
||||
// швидкому такті й теж до перевірки живих зондів: намір,
|
||||
// у якого перевірка вже зібралась, інакше висів би
|
||||
// «перевіряємо» до наступного прибиральника, а сторінка
|
||||
// весь цей час його перечитувала б.
|
||||
s.settleRollbacks(ctx)
|
||||
|
||||
online := s.onlineAgentIDs()
|
||||
if len(online) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Прохання «розпізнати зараз» їде тим самим тактом:
|
||||
// це той самий шлях REST → база → жива сесія, і власний
|
||||
// таймер задля кількох рядків був би зайвою деталлю.
|
||||
s.dispatchIdentify(ctx, online)
|
||||
|
||||
// Масове виконання команд — так само: та сама черга в базі,
|
||||
// та сама жива сесія. Власний такт додав би ще один
|
||||
// інтервал, який довелося б тримати узгодженим із цим.
|
||||
s.dispatchCommandJobs(ctx, online)
|
||||
|
||||
// Відкат — тим самим тактом і тією ж живою сесією. Власної
|
||||
// черги йому не потрібно: у .proto для нього є окреме
|
||||
// повідомлення, а шлях «база → сесія» той самий.
|
||||
s.dispatchRollbacks(ctx, online)
|
||||
|
||||
jobs, err := s.store.ClaimConfigJobs(ctx, online, 16, s.ring)
|
||||
if err != nil {
|
||||
s.log.Error("вибірка завдань збору", "err", err)
|
||||
|
|
@ -84,3 +119,43 @@ func (s *Service) onlineAgentIDs() []string {
|
|||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// dispatchIdentify роздає прохання розпізнати хост.
|
||||
//
|
||||
// Зонд не виконує все, що прилетіло: DiscoveryRequest лише зрушує
|
||||
// задачі, які вже є в його розкладі. Тому чек розпізнавання створюється
|
||||
// раніше — у RequestIdentify, — а сюди доходить саме поштовх.
|
||||
func (s *Service) dispatchIdentify(ctx context.Context, online []string) {
|
||||
reqs, err := s.store.ClaimIdentifyRequests(ctx, online, 64)
|
||||
if err != nil {
|
||||
s.log.Warn("вибірка прохань розпізнати", "err", err)
|
||||
return
|
||||
}
|
||||
if len(reqs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Спершу план, потім поштовх. RequestIdentify міг щойно завести
|
||||
// чек, а зонд зрушує лише те, що вже має в розкладі: без цього
|
||||
// рядка кнопка мовчки нічого не робила б саме там, де вона
|
||||
// найпотрібніша — на щойно заведеному хості.
|
||||
s.syncPlansTick(ctx)
|
||||
|
||||
for _, r := range reqs {
|
||||
ok := s.PushToAgent(r.AgentID, &npv1.ControlDown{
|
||||
Payload: &npv1.ControlDown_DiscoveryRequest{
|
||||
DiscoveryRequest: &npv1.DiscoveryRequest{
|
||||
RunId: "identify:" + r.DeviceID,
|
||||
DeviceIds: []string{r.DeviceID},
|
||||
},
|
||||
},
|
||||
})
|
||||
if !ok {
|
||||
s.log.Warn("зонд відключився до надсилання прохання розпізнати",
|
||||
"agent", r.AgentID, "device", r.DeviceID)
|
||||
continue
|
||||
}
|
||||
s.log.Info("розпізнавання зрушено вручну",
|
||||
"agent", r.AgentID, "device", r.DeviceID)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
335
server/internal/grpcapi/ncm_mirror.go
Normal file
335
server/internal/grpcapi/ncm_mirror.go
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
package grpcapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/netpulse/netpulse/server/internal/gitstore"
|
||||
"github.com/netpulse/netpulse/server/internal/store"
|
||||
)
|
||||
|
||||
// Фоновий такт дзеркалення архіву конфігів на зовнішній Git.
|
||||
//
|
||||
// ГОЛОВНЕ РІШЕННЯ ЦЬОГО ФАЙЛУ — те, що його немає в шляху збору.
|
||||
//
|
||||
// Був простіший варіант: пушити одразу після коміту, у StoreConfig.
|
||||
// Він на десять рядків коротший і має рівно один недолік — робить
|
||||
// доступність чужого сервера умовою успішного бекапу. Збір конфігів
|
||||
// тут — нічний розклад по сотнях пристроїв через живі сесії зондів; на
|
||||
// кожному з них push додав би секунди мережевого очікування, а на
|
||||
// недоступному Forgejo — таймаут. Тобто зламаний бекап там, де все
|
||||
// залізо відповіло, конфіг зібрано й у базі він уже лежить.
|
||||
//
|
||||
// Дзеркало — копія. Копія не має права ламати оригінал.
|
||||
//
|
||||
// Звідси решта: власний такт, власне advisory-блокування, витримка
|
||||
// повтору в БАЗІ (а не в пам'яті процесу), і жодного зворотного зв'язку
|
||||
// в ncm.jobs.
|
||||
//
|
||||
// Живе в колекторі поруч із прибиральником (ncm_retention.go) і з тих
|
||||
// самих причин: тут стоїть той, хто версії створює, тут уже є ключ
|
||||
// шифрування, і це не процес, який обслуговує запити людини.
|
||||
|
||||
// mirrorLockKey — «npmr». Своє, не спільне з прибиральником і
|
||||
// планувальником: перший повний push інсталяції з річною історією може
|
||||
// тривати десятки хвилин, і зупиняти на цей час чистку чи розклад збору
|
||||
// не має жодних підстав.
|
||||
const mirrorLockKey = 0x6e70_6d72
|
||||
|
||||
// MirrorInterval — як часто дивитись, чи є що дзеркалити.
|
||||
//
|
||||
// Хвилина. Такт дешевий: якщо локальні посилання не змінились із
|
||||
// останнього вдалого push, у мережу він не йде взагалі (див. нижче).
|
||||
// Рідший такт означав би, що ручне «надіслати зараз» після зміни
|
||||
// налаштувань чекає невідомо скільки.
|
||||
const MirrorInterval = time.Minute
|
||||
|
||||
// mirrorVerifyEvery — як часто ходити на сервер, навіть коли локально
|
||||
// нічого не змінилось.
|
||||
//
|
||||
// Відбиток посилань каже, чи змінилось у НАС. Він нічого не каже про
|
||||
// те, чи ціле дзеркало: репозиторій на тому кінці могли видалити,
|
||||
// перестворити порожнім або відкликати токен. Без цієї перевірки
|
||||
// інсталяція, де конфіги стабільні тижнями, вважала б дзеркало живим
|
||||
// рівно доти, доки воно не знадобиться.
|
||||
const mirrorVerifyEvery = 6 * time.Hour
|
||||
|
||||
// mirrorAlertAfter — скільки невдач поспіль до алерту.
|
||||
//
|
||||
// Три. З витримкою повтору це приблизно вісім хвилин — досить, щоб
|
||||
// пережити перезавантаження Forgejo, і замало, щоб відмова прожила
|
||||
// непоміченою день.
|
||||
const mirrorAlertAfter = 3
|
||||
|
||||
// MirrorGit тримає такт дзеркалення.
|
||||
//
|
||||
// Кілька екземплярів колектора безпечні: тік бере advisory-блокування,
|
||||
// тож пушить рівно один. Два одночасні push у той самий репозиторій
|
||||
// не зіпсували б віддалене дерево (без -f воно або перемотується, або
|
||||
// відмовляє), але дали б два з'єднання й два суперечливі записи стану.
|
||||
func (s *Service) MirrorGit(ctx context.Context) {
|
||||
t := time.NewTicker(MirrorInterval)
|
||||
defer t.Stop()
|
||||
|
||||
s.log.Info("дзеркалення конфігів у зовнішній Git запущено", "інтервал", MirrorInterval)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
s.mirrorTick(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) mirrorTick(ctx context.Context) {
|
||||
conn, err := s.store.WorkerPool().Acquire(ctx)
|
||||
if err != nil {
|
||||
s.log.Error("дзеркало: з'єднання", "err", err)
|
||||
return
|
||||
}
|
||||
defer conn.Release()
|
||||
|
||||
var got bool
|
||||
if err := conn.QueryRow(ctx, `SELECT pg_try_advisory_lock($1)`,
|
||||
int64(mirrorLockKey)).Scan(&got); err != nil {
|
||||
s.log.Error("дзеркало: блокування", "err", err)
|
||||
return
|
||||
}
|
||||
if !got {
|
||||
// Пушить інший інстанс — штатний стан.
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_, _ = conn.Exec(context.WithoutCancel(ctx),
|
||||
`SELECT pg_advisory_unlock($1)`, int64(mirrorLockKey))
|
||||
}()
|
||||
|
||||
// Спершу видалення гілок, потім звичайний push.
|
||||
//
|
||||
// Порядок не косметичний: шаблонний refspec дзеркалення (`*:*`)
|
||||
// зниклі гілки не прибирає й ніколи не прибиратиме — див. чому в
|
||||
// store/ncm_refqueue.go. Тобто це єдине місце, де видалений хост
|
||||
// зникає з дзеркала, і робити його після push означало б лишати
|
||||
// гілку живою на тому кінці ще на такт.
|
||||
s.drainRefDeletes(ctx)
|
||||
|
||||
targets, err := s.store.MirrorTargets(ctx, s.ring)
|
||||
if err != nil {
|
||||
s.log.Error("дзеркало: перелік кабінетів", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, t := range targets {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
s.mirrorOne(ctx, t)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) mirrorOne(ctx context.Context, t store.MirrorTarget) {
|
||||
refs, hash, err := s.store.LocalMirrorState(t.TenantID)
|
||||
switch {
|
||||
case errors.Is(err, gitstore.ErrNoRepo), err == nil && refs == 0:
|
||||
// Дзеркало ввімкнули до першого бекапу. Це не відмова й не
|
||||
// успіх: позначити його вдалим означало б поставити на сторінці
|
||||
// час «останньої синхронізації», якої не було.
|
||||
return
|
||||
case errors.Is(err, gitstore.ErrDisabled):
|
||||
s.mirrorFailed(ctx, t, errors.New("версіювання в Git вимкнено на цьому сервері"))
|
||||
return
|
||||
case err != nil:
|
||||
s.mirrorFailed(ctx, t, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Нічого не змінилось і нещодавно перевіряли — у мережу не йдемо.
|
||||
// Дзеркалення не має бути постійним трафіком на чужий сервер:
|
||||
// архів конфігів міняється раз на добу, а такт тікає щохвилини.
|
||||
if hash == t.StateHash && t.LastOkAt != nil && time.Since(*t.LastOkAt) < mirrorVerifyEvery {
|
||||
return
|
||||
}
|
||||
|
||||
stat, err := s.store.PushMirror(ctx, t.TenantID, t.Remote)
|
||||
if err != nil {
|
||||
s.mirrorFailed(ctx, t, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.MarkMirror(ctx, t.TenantID, t.RepoID, store.MirrorOutcome{
|
||||
OK: true,
|
||||
Refs: stat.Refs,
|
||||
StateHash: stat.StateHash,
|
||||
Duration: stat.Took,
|
||||
}); err != nil {
|
||||
s.log.Error("дзеркало: запис стану", "tenant", t.TenantID, "err", err)
|
||||
}
|
||||
|
||||
if t.FailCount > 0 {
|
||||
if err := s.store.ResolveMirrorAlert(ctx, t.TenantID, t.RepoID); err != nil {
|
||||
s.log.Error("дзеркало: зняття алерту", "tenant", t.TenantID, "err", err)
|
||||
}
|
||||
}
|
||||
if !stat.UpToDate {
|
||||
s.log.Info("дзеркало оновлено", "tenant", t.TenantID,
|
||||
"гілок", stat.Refs, "тривалість", stat.Took.Round(time.Millisecond))
|
||||
}
|
||||
}
|
||||
|
||||
// refDeleteBatch — скільки гілок розглядати за один такт.
|
||||
//
|
||||
// Хвилинний такт і сто гілок означають, що масове видалення двохсот
|
||||
// хостів доїде до дзеркала за дві хвилини. Брати всі одразу немає
|
||||
// сенсу: кожна гілка — окрема мережева операція, і черга, яка займає
|
||||
// такт на десять хвилин, затримує сам push.
|
||||
const refDeleteBatch = 100
|
||||
|
||||
// drainRefDeletes прибирає гілки видалених хостів.
|
||||
//
|
||||
// Два кроки на рядок, і другий може не відбутись роками, не заважаючи
|
||||
// першому:
|
||||
//
|
||||
// 1. локальна гілка — на нашому ж диску, тож або зникає, або її вже
|
||||
// немає; помилка тут означає зламаний репозиторій, і рядок лишається
|
||||
// в черзі з поясненням;
|
||||
// 2. гілка на дзеркалі — по мережі, з витримкою повтору в базі.
|
||||
//
|
||||
// Дзеркало не налаштоване — рядок знімається після першого кроку: на
|
||||
// тому кінці цієї гілки не було ніколи.
|
||||
func (s *Service) drainRefDeletes(ctx context.Context) {
|
||||
tasks, err := s.store.PendingRefDeletes(ctx, s.ring, refDeleteBatch)
|
||||
if err != nil {
|
||||
s.log.Error("черга видалень гілок", "err", err)
|
||||
return
|
||||
}
|
||||
for _, t := range tasks {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if !t.LocalDone {
|
||||
removed, err := s.store.DeleteLocalBranch(t.TenantID, t.Branch)
|
||||
switch {
|
||||
case errors.Is(err, gitstore.ErrDisabled):
|
||||
// Версіювання вимкнено на цьому сервері — гілок немає
|
||||
// ні тут, ні на дзеркалі. Рядок нема кому виконувати.
|
||||
_ = s.store.FinishRefDelete(ctx, t.ID)
|
||||
continue
|
||||
case err != nil:
|
||||
s.failRefDelete(ctx, t, err)
|
||||
continue
|
||||
}
|
||||
if removed {
|
||||
s.log.Info("гілку видаленого хоста прибрано локально",
|
||||
"гілка", t.Branch, "хост", t.DeviceName)
|
||||
}
|
||||
if err := s.store.MarkRefDeleteLocalDone(ctx, t.ID); err != nil {
|
||||
s.log.Error("черга видалень гілок: позначка", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
if !t.MirrorReady {
|
||||
_ = s.store.FinishRefDelete(ctx, t.ID)
|
||||
continue
|
||||
}
|
||||
if err := s.store.DeleteMirrorRefs(ctx, t.TenantID, t.Remote, []string{t.Branch}); err != nil {
|
||||
s.failRefDelete(ctx, t, err)
|
||||
continue
|
||||
}
|
||||
s.log.Info("гілку видаленого хоста прибрано на дзеркалі",
|
||||
"гілка", t.Branch, "хост", t.DeviceName,
|
||||
"url", gitstore.RedactURL(t.Remote.URL))
|
||||
if err := s.store.FinishRefDelete(ctx, t.ID); err != nil {
|
||||
s.log.Error("черга видалень гілок: зняття", "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) failRefDelete(ctx context.Context, t store.RefDeleteTask, cause error) {
|
||||
wait := mirrorBackoff(t.Attempts + 1)
|
||||
// Витримка та сама, що й у дзеркалення: недоступний сервер не має
|
||||
// отримувати з'єднання щохвилини цілий тиждень. Окремого алерту тут
|
||||
// немає навмисно — його вже піднімає сам push, і другий про той
|
||||
// самий сервер лише розмив би перший.
|
||||
s.log.Warn("гілку видаленого хоста прибрати не вдалось",
|
||||
"гілка", t.Branch, "хост", t.DeviceName,
|
||||
"спроб_поспіль", t.Attempts+1, "повтор_через", wait, "err", cause)
|
||||
if err := s.store.FailRefDelete(ctx, t.ID, cause, time.Now().Add(wait)); err != nil {
|
||||
s.log.Error("черга видалень гілок: запис відмови", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// mirrorFailed записує відмову, призначає наступну спробу й, коли
|
||||
// відмов уже забагато, піднімає алерт.
|
||||
func (s *Service) mirrorFailed(ctx context.Context, t store.MirrorTarget, cause error) {
|
||||
fails := t.FailCount + 1
|
||||
wait := mirrorBackoff(fails)
|
||||
|
||||
// Текст помилки вже очищений від секрету в gitstore (Scrub), але
|
||||
// журнал отримує ще й адресу без логіна: у полі URL цілком може
|
||||
// лежати https://токен@forgejo/…, вставлений із інструкції.
|
||||
s.log.Warn("дзеркало: push не вдався",
|
||||
"tenant", t.TenantID, "url", gitstore.RedactURL(t.Remote.URL),
|
||||
"спроб_поспіль", fails, "повтор_через", wait, "err", cause)
|
||||
|
||||
if err := s.store.MarkMirror(ctx, t.TenantID, t.RepoID, store.MirrorOutcome{
|
||||
OK: false,
|
||||
Err: cause.Error(),
|
||||
NextAt: time.Now().Add(wait),
|
||||
}); err != nil {
|
||||
s.log.Error("дзеркало: запис стану", "tenant", t.TenantID, "err", err)
|
||||
}
|
||||
|
||||
if fails < mirrorAlertAfter {
|
||||
return
|
||||
}
|
||||
|
||||
// Дві градації, і різниця між ними — не в кількості спроб, а в
|
||||
// тому, скільки часу вже немає копії. Годину без дзеркала переживе
|
||||
// будь-хто; доба без нього означає, що вчорашніх конфігів за
|
||||
// периметром немає взагалі.
|
||||
severity, since := "average", "жодного разу"
|
||||
if t.LastOkAt == nil || time.Since(*t.LastOkAt) > 24*time.Hour {
|
||||
severity = "high"
|
||||
}
|
||||
if t.LastOkAt != nil {
|
||||
since = t.LastOkAt.Format("2006-01-02 15:04")
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf(
|
||||
"Архів конфігів не їде на %s. Спроб поспіль: %d. Останній вдалий обмін: %s. Причина: %s",
|
||||
gitstore.RedactURL(t.Remote.URL), fails, since, cause)
|
||||
|
||||
if err := s.store.RaiseMirrorAlert(ctx, t.TenantID, t.RepoID, severity,
|
||||
"Дзеркало архіву конфігів не працює", msg); err != nil {
|
||||
s.log.Error("дзеркало: алерт", "tenant", t.TenantID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// mirrorBackoff — витримка перед наступною спробою.
|
||||
//
|
||||
// Наростає й упирається в годину. Недоступний сервер не має отримувати
|
||||
// з'єднання щохвилини цілий тиждень: це і марний трафік, і — коли він
|
||||
// повернеться — сплеск від усіх інсталяцій одночасно. Стеля саме
|
||||
// година, а не доба: копія, яка відновлюється через добу після
|
||||
// полагодження мережі, спізнюється рівно на ту добу, за яку її й могли
|
||||
// б утратити.
|
||||
func mirrorBackoff(fails int) time.Duration {
|
||||
switch {
|
||||
case fails <= 1:
|
||||
return time.Minute
|
||||
case fails == 2:
|
||||
return 2 * time.Minute
|
||||
case fails == 3:
|
||||
return 5 * time.Minute
|
||||
case fails == 4:
|
||||
return 15 * time.Minute
|
||||
case fails == 5:
|
||||
return 30 * time.Minute
|
||||
default:
|
||||
return time.Hour
|
||||
}
|
||||
}
|
||||
98
server/internal/grpcapi/ncm_retention.go
Normal file
98
server/internal/grpcapi/ncm_retention.go
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
package grpcapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// retentionLockKey — окреме advisory-блокування під прибиральника.
|
||||
//
|
||||
// Своє, а не спільне з планувальником бекапів: обидва тікають незалежно,
|
||||
// і спільний ключ означав би, що довгий перший прохід очистки заодно
|
||||
// зупиняє розклад збору.
|
||||
const retentionLockKey = 0x6e70_7263 // "nprc"
|
||||
|
||||
// RetentionInterval — як часто прибирати старі версії.
|
||||
//
|
||||
// Година. Найдрібніша одиниця політики — доба, тож частіше немає чого
|
||||
// шукати; рідше — означало б, що на інсталяції, яка вперше вмикає
|
||||
// чистку, звільнення місця починається невідомо коли.
|
||||
const RetentionInterval = time.Hour
|
||||
|
||||
// SweepRetention прибирає старі версії конфігів за політикою.
|
||||
//
|
||||
// Живе в колекторі, а не в REST-процесі, і це не байдуже.
|
||||
//
|
||||
// По-перше, тут уже стоїть той, хто версії СТВОРЮЄ: планувальник
|
||||
// бекапів і диспетчер збору. Механізм, що прибирає, доречно тримати
|
||||
// поруч із тим, що накопичує, — інакше два процеси зі спільною
|
||||
// відповідальністю за одну таблицю живуть у різних релізних циклах.
|
||||
//
|
||||
// По-друге, REST-процес — шлях запитів людини, і його масштабують
|
||||
// копіями. Довге видалення в тому самому процесі конкурувало б за
|
||||
// з'єднання з тим, хто зараз відкриває сторінку конфігів.
|
||||
//
|
||||
// По-третє, ключ шифрування тут не потрібен зовсім: видалення не читає
|
||||
// тіл. Тому прохід безпечний і на інсталяції, де REST запущено без DEK.
|
||||
//
|
||||
// Кілька екземплярів колектора безпечні: тік бере advisory-блокування,
|
||||
// тож прибирає рівно один.
|
||||
func (s *Service) SweepRetention(ctx context.Context) {
|
||||
t := time.NewTicker(RetentionInterval)
|
||||
defer t.Stop()
|
||||
|
||||
s.log.Info("прибиральник старих конфігів запущено", "інтервал", RetentionInterval)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
s.retentionTick(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) retentionTick(ctx context.Context) {
|
||||
conn, err := s.store.WorkerPool().Acquire(ctx)
|
||||
if err != nil {
|
||||
s.log.Error("прибиральник: з'єднання", "err", err)
|
||||
return
|
||||
}
|
||||
defer conn.Release()
|
||||
|
||||
var got bool
|
||||
if err := conn.QueryRow(ctx, `SELECT pg_try_advisory_lock($1)`,
|
||||
int64(retentionLockKey)).Scan(&got); err != nil {
|
||||
s.log.Error("прибиральник: блокування", "err", err)
|
||||
return
|
||||
}
|
||||
if !got {
|
||||
// Прибирає інший інстанс — штатний стан.
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_, _ = conn.Exec(context.WithoutCancel(ctx),
|
||||
`SELECT pg_advisory_unlock($1)`, int64(retentionLockKey))
|
||||
}()
|
||||
|
||||
tenants, err := s.store.TenantsWithRetention(ctx)
|
||||
if err != nil {
|
||||
s.log.Error("прибиральник: перелік тенантів", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, tenantID := range tenants {
|
||||
stat, err := s.store.SweepRetention(ctx, tenantID)
|
||||
if err != nil {
|
||||
// Один тенант не має зупиняти решту: політика в кожного
|
||||
// своя, і збій на чужих даних тут нічого не означає.
|
||||
s.log.Error("прибиральник: прохід", "tenant", tenantID, "err", err)
|
||||
continue
|
||||
}
|
||||
if stat.Deleted > 0 {
|
||||
s.log.Info("прибрано старі версії конфігів",
|
||||
"tenant", tenantID, "версій", stat.Deleted, "байтів", stat.FreedBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
139
server/internal/grpcapi/ncm_rollback.go
Normal file
139
server/internal/grpcapi/ncm_rollback.go
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
package grpcapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
|
||||
"github.com/netpulse/netpulse/server/internal/store"
|
||||
)
|
||||
|
||||
// Відкат конфігурації: серверна половина.
|
||||
//
|
||||
// Транспорту тут свого немає: ConfigApplyJob і ConfigApplyResult лежали
|
||||
// в .proto від першого дня, у ControlDown/ControlUp для них були
|
||||
// зарезервовані гілки, і бракувало рівно коду по обидва боки. Диспетчер
|
||||
// іде в тому самому такті, що збір конфігів і масові команди, — це той
|
||||
// самий шлях «база → жива сесія зонда», і власний таймер додав би ще
|
||||
// один інтервал, який довелось би тримати узгодженим із цим.
|
||||
|
||||
// rollbackBatch — скільки відкатів віддаємо за один такт.
|
||||
//
|
||||
// Свідомо мало, на порядок менше за збір конфігів. Заливка конфігу —
|
||||
// не читання: десять одночасних відкатів означають десять вузлів, що
|
||||
// одночасно перебудовуються, і якщо в плані була помилка, вона
|
||||
// повторюється десять разів, перш ніж хтось устигне натиснути «стоп».
|
||||
const rollbackBatch = 4
|
||||
|
||||
// dispatchRollbacks роздає погоджені наміри живим сесіям.
|
||||
func (s *Service) dispatchRollbacks(ctx context.Context, online []string) {
|
||||
jobs, err := s.store.ClaimRollbacks(ctx, online, rollbackBatch, s.ring)
|
||||
if err != nil {
|
||||
s.log.Error("вибірка відкатів", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, j := range jobs {
|
||||
ok := s.PushToAgent(j.AgentID, &npv1.ControlDown{
|
||||
Payload: &npv1.ControlDown_ConfigApplyJob{ConfigApplyJob: j.Job},
|
||||
})
|
||||
if !ok {
|
||||
// Сесія обірвалась між вибіркою й відправкою. Намір падає, а
|
||||
// не повертається в чергу: на пристрої ще нічого не
|
||||
// відбувалось, і мовчазний повтор означав би, що заливка
|
||||
// колись поїде сама — без людини, яка на це дивиться.
|
||||
// Погодження при цьому лишається в журналі, тож повторити
|
||||
// відкат коштує одного натискання.
|
||||
_ = s.store.FailRollback(ctx, j.RollbackID,
|
||||
"зонд відключився до надсилання завдання — відкат не почався")
|
||||
continue
|
||||
}
|
||||
s.log.Warn("відкат конфігу надіслано зонду",
|
||||
"rollback", j.RollbackID, "agent", j.AgentID, "device", j.DeviceName,
|
||||
"команд", len(j.Job.GetCommands()))
|
||||
}
|
||||
}
|
||||
|
||||
// storeApplyResult приймає звіт про заливку.
|
||||
//
|
||||
// Тут навмисно немає жодного рішення про долю відкату. Усе, що знає це
|
||||
// місце, — «зонд щось відповів»; чи став пристрій таким, як хотіли,
|
||||
// вирішує контрольний збір, який ставить FinishApply. Вивід CLI на це
|
||||
// питання не відповідає: пристрій відповідає рядком тексту, і мовчання
|
||||
// означає «прочитав», а не «застосував».
|
||||
func (s *Service) storeApplyResult(ctx context.Context, res *npv1.ConfigApplyResult) {
|
||||
id := res.GetRollbackId()
|
||||
if id == "" {
|
||||
s.log.Warn("результат заливки без ідентифікатора відкату")
|
||||
return
|
||||
}
|
||||
|
||||
tenantID, redact, err := s.store.RollbackRedactPatterns(ctx, id)
|
||||
if err != nil {
|
||||
s.log.Error("невідомий відкат у результаті заливки", "rollback", id, "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
outcomes := make([]store.ApplyOutcome, 0, len(res.GetOutcomes()))
|
||||
for _, o := range res.GetOutcomes() {
|
||||
outcomes = append(outcomes, store.ApplyOutcome{
|
||||
Index: int(o.GetIndex()),
|
||||
Command: o.GetCommand(),
|
||||
Output: o.GetOutput(),
|
||||
Success: o.GetSuccess(),
|
||||
ErrorLine: o.GetErrorLine(),
|
||||
})
|
||||
}
|
||||
|
||||
rep := store.ApplyReport{
|
||||
RollbackID: id,
|
||||
Success: res.GetSuccess(),
|
||||
Committed: res.GetCommitted(),
|
||||
Outcomes: outcomes,
|
||||
Transcript: res.GetTranscript(),
|
||||
Error: res.GetError().GetMessage(),
|
||||
}
|
||||
if !rep.Success && rep.Error == "" {
|
||||
rep.Error = "заливка не завершилась, а причини зонд не назвав"
|
||||
}
|
||||
|
||||
if err := s.store.FinishApply(ctx, tenantID, rep, redact); err != nil {
|
||||
s.log.Error("запис результату відкату", "rollback", id, "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Warn, а не Info, і за будь-якого результату. Запис на живе залізо
|
||||
// — подія, яку шукають у журналі через тиждень після аварії, і
|
||||
// шукають її разом із рештою тривожного.
|
||||
s.log.Warn("відкат залито, призначено контрольний збір",
|
||||
"rollback", id, "успіх", rep.Success, "збережено", rep.Committed,
|
||||
"команд", len(outcomes))
|
||||
}
|
||||
|
||||
// settleRollbacks підбиває підсумок контрольних зборів.
|
||||
func (s *Service) settleRollbacks(ctx context.Context) {
|
||||
n, err := s.store.SettleRollbackVerification(ctx, s.ring)
|
||||
if err != nil {
|
||||
s.log.Warn("підсумок перевірки відкатів", "err", err)
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
s.log.Info("перевірку відкатів завершено", "намірів", n)
|
||||
}
|
||||
}
|
||||
|
||||
// reapRollbacks розбирається з тими, від кого немає відповіді.
|
||||
func (s *Service) reapRollbacks(ctx context.Context) {
|
||||
n, err := s.store.ReapStuckRollbacks(ctx)
|
||||
if err != nil {
|
||||
s.log.Warn("прибирання завислих відкатів", "err", err)
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
// Саме Warn: зонд зник посеред заливки, і на пристрої лишився
|
||||
// стан, якого не описує жоден із двох конфігів. Контрольний
|
||||
// збір уже призначено, але подія варта того, щоб її помітили
|
||||
// без нагадування.
|
||||
s.log.Warn("зонд не відповів під час заливки — перевіряємо стан пристроїв",
|
||||
"намірів", n)
|
||||
}
|
||||
}
|
||||
|
|
@ -35,7 +35,7 @@ func (s *Service) ScheduleBackups(ctx context.Context) {
|
|||
}
|
||||
|
||||
func (s *Service) scheduleTick(ctx context.Context) int {
|
||||
conn, err := s.store.Pool().Acquire(ctx)
|
||||
conn, err := s.store.WorkerPool().Acquire(ctx)
|
||||
if err != nil {
|
||||
s.log.Error("планувальник: з'єднання", "err", err)
|
||||
return 0
|
||||
|
|
|
|||
|
|
@ -29,12 +29,50 @@ func (s *Service) SyncPlans(ctx context.Context) {
|
|||
t := time.NewTicker(PlanSyncInterval)
|
||||
defer t.Stop()
|
||||
|
||||
id := time.NewTicker(IdentifySyncInterval)
|
||||
defer id.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
s.syncPlansTick(ctx)
|
||||
case <-id.C:
|
||||
s.ensureIdentifyTick(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// IdentifySyncInterval — як часто перевіряти, кому бракує розпізнавання.
|
||||
//
|
||||
// Окремий, повільніший такт, ніж у звірки планів. Звірка читає хеш,
|
||||
// а це — запит із записом; ганяти його щоп'ять секунд на кожен зонд
|
||||
// заради події, яка трапляється при заведенні хоста, не варто. Хвилини
|
||||
// досить: рівно стільки чекатиме людина, що допіру дала хосту SNMP.
|
||||
const IdentifySyncInterval = time.Minute
|
||||
|
||||
// ensureIdentifyTick заводить чеки розпізнавання новим хостам.
|
||||
//
|
||||
// Самої лише появи чека досить: він змінює хеш плану, і найближча
|
||||
// звірка сама донесе його до зонда.
|
||||
func (s *Service) ensureIdentifyTick(ctx context.Context) {
|
||||
s.mu.RLock()
|
||||
sessions := make([]*agentSession, 0, len(s.sessions))
|
||||
for _, sess := range s.sessions {
|
||||
sessions = append(sessions, sess)
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
for _, sess := range sessions {
|
||||
made, err := s.store.EnsureIdentifyChecks(ctx, sess.agent)
|
||||
if err != nil {
|
||||
s.log.Warn("чек розпізнавання", "agent", sess.agent.ID, "err", err)
|
||||
continue
|
||||
}
|
||||
if made > 0 {
|
||||
s.log.Info("заведено чеки розпізнавання",
|
||||
"agent", sess.agent.ID, "кількість", made)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import (
|
|||
"time"
|
||||
|
||||
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
|
||||
"github.com/netpulse/netpulse/server/internal/alerting"
|
||||
"github.com/netpulse/netpulse/server/internal/crypto"
|
||||
"github.com/netpulse/netpulse/server/internal/store"
|
||||
"google.golang.org/grpc"
|
||||
|
|
@ -38,6 +39,14 @@ type Service struct {
|
|||
ring *crypto.Keyring
|
||||
log *slog.Logger
|
||||
|
||||
// Приймач подій для правил джерел `syslog` і `ncm`.
|
||||
//
|
||||
// Живе саме тут, бо саме сюди приходять рядки журналу й зібрані
|
||||
// конфіги: правило на подію не має де спрацювати, крім тієї миті,
|
||||
// коли подія надійшла. nil означає інсталяцію з вимкненими
|
||||
// алертами — тоді приймач просто мовчить.
|
||||
events *alerting.EventSink
|
||||
|
||||
// Живі сесії за agent_id. Потрібні, щоб штовхнути зонду
|
||||
// TaskDelta або ConfigJob, коли щось змінилось в UI.
|
||||
mu sync.RWMutex
|
||||
|
|
@ -84,6 +93,16 @@ func New(st *store.Store, ring *crypto.Keyring, log *slog.Logger) *Service {
|
|||
}
|
||||
}
|
||||
|
||||
// WithEventAlerts вмикає подієві алерти на журналі й конфігах.
|
||||
//
|
||||
// Окремим методом, а не аргументом New: приймач подій потрібен не
|
||||
// кожній збірці (тести, читальні інстанси), і вимагати його від них
|
||||
// означало б тягнути пакет алертів туди, де алертів немає.
|
||||
func (s *Service) WithEventAlerts(sink *alerting.EventSink) *Service {
|
||||
s.events = sink
|
||||
return s
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Автентифікація
|
||||
// ---------------------------------------------------------------------
|
||||
|
|
@ -203,6 +222,12 @@ func (s *Service) Control(stream npv1.AgentService_ControlServer) error {
|
|||
}
|
||||
}()
|
||||
|
||||
// До побудови плану, а не після: інакше щойно заведений чек
|
||||
// розпізнавання поїхав би до зонда лише наступною звіркою.
|
||||
if _, err := s.store.EnsureIdentifyChecks(ctx, agent); err != nil {
|
||||
s.log.Warn("чек розпізнавання", "agent", agent.ID, "err", err)
|
||||
}
|
||||
|
||||
plan, err := s.store.BuildPlan(ctx, agent)
|
||||
if err != nil {
|
||||
return status.Errorf(codes.Internal, "побудова плану: %v", err)
|
||||
|
|
@ -321,6 +346,16 @@ func (s *Service) readControl(ctx context.Context, stream npv1.AgentService_Cont
|
|||
Payload: &npv1.ControlDown_Credentials{Credentials: bundle},
|
||||
})
|
||||
|
||||
case *npv1.ControlUp_ConfigApplyResult:
|
||||
// Результат заливки конфігу. Обробляється в окремій
|
||||
// горутині: FinishApply ставить контрольний збір і чекає
|
||||
// на кілька запитів до бази, а контрольний цикл цієї сесії
|
||||
// тим часом має відповідати на ping — інакше зонд, який
|
||||
// щойно зробив найнебезпечнішу роботу, буде визнаний
|
||||
// мертвим саме через неї.
|
||||
result := p.ConfigApplyResult
|
||||
go s.storeApplyResult(context.WithoutCancel(ctx), result)
|
||||
|
||||
case *npv1.ControlUp_Event:
|
||||
s.log.Info("подія зонда",
|
||||
"agent", agent.ID, "kind", p.Event.GetKind().String(),
|
||||
|
|
|
|||
155
server/internal/grpcapi/storage_retention.go
Normal file
155
server/internal/grpcapi/storage_retention.go
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
package grpcapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// dataRetentionLockKey — окреме advisory-блокування під прибирання
|
||||
// даних.
|
||||
//
|
||||
// Своє, а не спільне з очисткою конфігів (retentionLockKey): обидва
|
||||
// такти тікають незалежно, і спільний ключ означав би, що довгий перший
|
||||
// прохід по прогонах команд заодно зупиняє прибирання версій конфігів.
|
||||
const dataRetentionLockKey = 0x6e70_6472 // "npdr"
|
||||
|
||||
// DataRetentionInterval — як часто прибирати й міряти.
|
||||
//
|
||||
// Година. Найдрібніша одиниця строку — доба, тож частіше шукати нема
|
||||
// чого. Рідше — означало б, що інсталяція, яка вперше поставила строк,
|
||||
// починає звільняти місце невідомо коли; а на переповненому диску
|
||||
// «невідомо коли» — це вже після аварії.
|
||||
const DataRetentionInterval = time.Hour
|
||||
|
||||
// SweepDataRetention прибирає застарілі дані за налаштованими строками
|
||||
// й веде ряд спостережень за розміром.
|
||||
//
|
||||
// Живе в колекторі, а не в REST-процесі, з тих самих трьох міркувань,
|
||||
// що й очистка конфігів (ncm_retention.go): тут стоїть той, хто дані
|
||||
// СТВОРЮЄ; REST-процес масштабують копіями й він же обслуговує запити
|
||||
// людини; ключ шифрування тут не потрібен зовсім.
|
||||
//
|
||||
// Кілька екземплярів колектора безпечні: тік бере advisory-блокування,
|
||||
// тож працює рівно один.
|
||||
func (s *Service) SweepDataRetention(ctx context.Context) {
|
||||
t := time.NewTicker(DataRetentionInterval)
|
||||
defer t.Stop()
|
||||
|
||||
s.log.Info("прибиральник даних запущено", "інтервал", DataRetentionInterval)
|
||||
|
||||
// Перший знімок розміру знімається одразу, не чекаючи години.
|
||||
// Приріст рахується з РІЗНИЦІ спостережень, тож перше з них має
|
||||
// з'явитись якомога раніше: до нього сторінка не має що сказати про
|
||||
// швидкість, а саме заради швидкості її й відкривають.
|
||||
s.dataRetentionTick(ctx)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
s.dataRetentionTick(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) dataRetentionTick(ctx context.Context) {
|
||||
conn, err := s.store.Pool().Acquire(ctx)
|
||||
if err != nil {
|
||||
s.log.Error("прибиральник даних: з'єднання", "err", err)
|
||||
return
|
||||
}
|
||||
defer conn.Release()
|
||||
|
||||
var got bool
|
||||
if err := conn.QueryRow(ctx, `SELECT pg_try_advisory_lock($1)`,
|
||||
int64(dataRetentionLockKey)).Scan(&got); err != nil {
|
||||
s.log.Error("прибиральник даних: блокування", "err", err)
|
||||
return
|
||||
}
|
||||
if !got {
|
||||
// Прибирає інший інстанс — штатний стан.
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_, _ = conn.Exec(context.WithoutCancel(ctx),
|
||||
`SELECT pg_advisory_unlock($1)`, int64(dataRetentionLockKey))
|
||||
}()
|
||||
|
||||
// 1. Звірка політик TimescaleDB зі збереженими строками.
|
||||
//
|
||||
// Політики накладаються одразу при збереженні, тож у нормальному
|
||||
// житті тут завжди нуль. Такт потрібен для іншого: політику знімають
|
||||
// руками під час обслуговування бази, а pg_dump не везе фонових
|
||||
// задач TimescaleDB узагалі — тобто після відновлення з дампа
|
||||
// строки в таблиці є, а видалення не працює, і дізнаються про це
|
||||
// лише тоді, коли диск уже закінчився. Один запит на годину — мала
|
||||
// ціна за те, щоб цей стан не пережив першої ж години.
|
||||
if n, err := s.store.SyncRetentionPolicies(ctx); err != nil {
|
||||
s.log.Error("прибиральник даних: політики TimescaleDB", "err", err)
|
||||
} else if n > 0 {
|
||||
s.log.Warn("політики зберігання розійшлися зі строками й виправлені",
|
||||
"політик", n)
|
||||
}
|
||||
|
||||
// 2. Пакетне прибирання звичайних таблиць.
|
||||
stats, err := s.store.SweepDataRetention(ctx)
|
||||
if err != nil {
|
||||
s.log.Error("прибиральник даних: прохід", "err", err)
|
||||
}
|
||||
for _, st := range stats {
|
||||
if st.Deleted == 0 {
|
||||
// Підпадало, а не прибралось. Помилки немає, і саме тому це
|
||||
// найнебезпечніший зі станів: тиша читається як «усе
|
||||
// гаразд». Майже завжди причина одна — з'єднання відкрито
|
||||
// роллю під RLS без заданого кабінету, тобто NETPULSE_DSN
|
||||
// колектора не веде на роль netpulse_worker (0063).
|
||||
s.log.Warn("прибирання не зачепило нічого, хоча дані під строком є",
|
||||
"вид", st.Kind, "підпадало", st.Due,
|
||||
"ймовірна_причина", "з'єднання під RLS без BYPASSRLS — перевірте DSN колектора")
|
||||
continue
|
||||
}
|
||||
s.log.Info("прибрано застарілі дані",
|
||||
"вид", st.Kind, "рядків", st.Deleted, "лишилось_ще", st.More)
|
||||
}
|
||||
|
||||
// 3. Знімок розмірів.
|
||||
//
|
||||
// Після прибирання, а не до: інакше ряд спостережень описував би
|
||||
// базу, якої вже немає, і приріст за добу вважався б із піків перед
|
||||
// кожним проходом.
|
||||
if err := s.store.SampleStorage(ctx); err != nil {
|
||||
s.log.Error("прибиральник даних: знімок розмірів", "err", err)
|
||||
}
|
||||
|
||||
// 4. Попередження про вичерпання місця.
|
||||
//
|
||||
// Тут, а не в движку правил, і не окремим тактом.
|
||||
//
|
||||
// Не в движку — бо метричне правило обчислюється запитом із JOIN
|
||||
// inv.devices, тобто вимагає хоста, а хоста для тому бази не існує
|
||||
// (повний розбір — у 0067_storage_alert.sql і store/storage_alert.go).
|
||||
//
|
||||
// Не окремим тактом — бо перевірка спирається рівно на ті числа, які
|
||||
// щойно ліг у ряд знімок вище. Свій такт означав би, що алерт іноді
|
||||
// рахується з даних, старших за годину, і найчастіше — саме тоді,
|
||||
// коли швидкість щойно змінилась.
|
||||
//
|
||||
// Одразу ПІСЛЯ знімка, а не до: інакше перша година життя інсталяції
|
||||
// проходила б без жодного спостереження, тобто перевірка на першому
|
||||
// такті була б сліпою за побудовою.
|
||||
if st, err := s.store.CheckStorageAlert(ctx); err != nil {
|
||||
s.log.Error("прибиральник даних: попередження про місце", "err", err)
|
||||
} else if st.Level != "" {
|
||||
s.log.Warn("місце на томі бази закінчується",
|
||||
"рівень", st.Level, "причина", st.Reason, "текст", st.Message)
|
||||
} else if st.Blind != "" && st.Enabled {
|
||||
// Найнебезпечніший зі станів і тому не Debug: попередження
|
||||
// увімкнене, але спертись йому нема на що. Ззовні це
|
||||
// невідрізнимо від «усе гаразд» — рівно те, чого ця робота мала
|
||||
// позбутись.
|
||||
s.log.Warn("попередження про місце не має на що спертись",
|
||||
"причина", st.Blind,
|
||||
"що_зробити", "вказати ємність тому або змонтувати том бази в збірник")
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"io"
|
||||
|
||||
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
|
||||
"github.com/netpulse/netpulse/server/internal/alerting"
|
||||
"github.com/netpulse/netpulse/server/internal/store"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
|
|
@ -87,6 +88,27 @@ func (s *Service) StreamTelemetry(stream npv1.AgentService_StreamTelemetryServer
|
|||
s.log.Debug("батч записано", "agent", agent.ID, "batch", batch.GetBatchId(),
|
||||
"samples", st.Samples, "icmp", st.Icmp, "interfaces", st.Interfaces)
|
||||
|
||||
// Перелік рядків динамічних таблиць їде в payload результатів
|
||||
// snmp.walk — тим самим батчем, що й метрики, бо збирає його та
|
||||
// сама задача зонда.
|
||||
//
|
||||
// Окремо від WriteBatch навмисно: там усе лягає одним pgx.Batch
|
||||
// без жодного читання, а тут — читання, порівняння зі станом і
|
||||
// перебудова чеків. Помилка тут не має нікачити батч: метрики
|
||||
// вже записані, і просити зонд переслати їх заради рядків
|
||||
// означало б подвоїти телеметрію через таблицю дисків.
|
||||
//
|
||||
// Штовхати зонду новий план звідси не треба: звірка планів
|
||||
// (SyncPlans) щоп'ять секунд помітить інший хеш і перезаллє
|
||||
// його сама — тим самим шляхом, яким доїжджають чеки, створені
|
||||
// у вебі.
|
||||
if changed, err := s.store.ApplyWalkResults(ctx, agent, batch.GetCheckResults()); err != nil {
|
||||
s.log.Warn("рядки прототипів не застосувались", "agent", agent.ID, "err", err)
|
||||
} else if changed > 0 {
|
||||
s.log.Info("склад рядків прототипів змінився",
|
||||
"agent", agent.ID, "хостів", changed)
|
||||
}
|
||||
|
||||
if err := stream.Send(&npv1.TelemetryAck{
|
||||
AckedThroughBatchId: acked,
|
||||
MaxInFlight: uint32(agent.Limits.MaxInFlight),
|
||||
|
|
@ -134,6 +156,8 @@ func (s *Service) StreamLogs(stream npv1.AgentService_StreamLogsServer) error {
|
|||
}
|
||||
|
||||
s.triggerSyslogBackups(ctx, agent, batch.GetSyslog())
|
||||
s.raiseSyslogAlerts(ctx, agent, batch.GetSyslog())
|
||||
s.handleTraps(ctx, agent, batch.GetTraps())
|
||||
if batch.GetBatchId() > acked {
|
||||
acked = batch.GetBatchId()
|
||||
}
|
||||
|
|
@ -179,6 +203,98 @@ func (s *Service) triggerSyslogBackups(ctx context.Context, agent *store.Agent,
|
|||
}
|
||||
}
|
||||
|
||||
// raiseSyslogAlerts звіряє щойно прийняті рядки з подієвими правилами.
|
||||
//
|
||||
// Робиться тут, одразу після запису, і саме з тієї ж причини, що й
|
||||
// позачерговий бекап поруч: правило «у журналі зʼявилось %LINK-3-UPDOWN»
|
||||
// має спрацювати за секунди, а не тоді, коли хтось наступного разу
|
||||
// відкриє журнал. Опитувати ts.syslog розкладом було б і дорожче
|
||||
// (гіпертаблиця на мільярд рядків), і брехливіше — вікно опитування
|
||||
// завжди або пропускає події, або рахує їх двічі.
|
||||
//
|
||||
// Помилка тут не зриває приймання: рядки вже записані, і втратити
|
||||
// журнал через проблему з алертами було б обміном гіршим за пропущений
|
||||
// алерт. Тому весь розбір мовчить у сам приймач, а гучний він усередині.
|
||||
func (s *Service) raiseSyslogAlerts(ctx context.Context, agent *store.Agent, entries []*npv1.SyslogEntry) {
|
||||
if s.events == nil || len(entries) == 0 {
|
||||
return
|
||||
}
|
||||
evs := make([]alerting.SyslogEvent, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
evs = append(evs, alerting.SyslogEvent{
|
||||
DeviceID: e.GetDeviceId(),
|
||||
Message: e.GetMessage(),
|
||||
Tag: e.GetTag(),
|
||||
Severity: int(e.GetSeverity()),
|
||||
})
|
||||
}
|
||||
s.events.OnSyslog(ctx, agent.TenantID, evs)
|
||||
}
|
||||
|
||||
// handleTraps робить із щойно прийнятих трапів дві речі.
|
||||
//
|
||||
// Перша — алерти, тим самим шляхом і з тих самих міркувань, що й для
|
||||
// журналу: правило «linkDown на магістральному порту» має спрацювати за
|
||||
// секунди, а не тоді, коли хтось наступного разу відкриє журнал.
|
||||
//
|
||||
// Друга — облік відправників, яких зонд не зміг зіставити з хостом.
|
||||
// Це не побічний ефект, а половина сенсу приймача. Трап від адреси,
|
||||
// якої немає в інвентарі, — найчастіше перший слід нового заліза в
|
||||
// мережі, і рівно він губиться у всіх системах, де подія без хоста
|
||||
// просто відкидається. Алертом його не зробиш (алерт без хоста нікуди
|
||||
// не маршрутизується), тому він потрапляє в окремий перелік, який видно
|
||||
// на сторінці трапів.
|
||||
//
|
||||
// Обидві дії гучні всередині й мовчазні назовні: трапи вже записані, і
|
||||
// втратити стрім через проблему з алертами було б обміном гіршим за
|
||||
// пропущений алерт.
|
||||
func (s *Service) handleTraps(ctx context.Context, agent *store.Agent, traps []*npv1.SnmpTrap) {
|
||||
if len(traps) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.NoteUnknownTrapSources(ctx, agent.TenantID, agent.ID, traps); err != nil {
|
||||
s.log.Error("облік невідомих джерел трапів", "agent", agent.ID, "err", err)
|
||||
}
|
||||
|
||||
if s.events == nil {
|
||||
return
|
||||
}
|
||||
evs := make([]alerting.TrapEvent, 0, len(traps))
|
||||
for _, t := range traps {
|
||||
ev := alerting.TrapEvent{
|
||||
DeviceID: t.GetDeviceId(),
|
||||
SourceIP: t.GetSourceIp(),
|
||||
TrapOID: t.GetTrapOid(),
|
||||
}
|
||||
for _, vb := range t.GetVarbinds() {
|
||||
ev.Varbinds = append(ev.Varbinds, alerting.TrapVarbind{
|
||||
OID: vb.GetOid(), Value: vb.GetValue(),
|
||||
})
|
||||
}
|
||||
evs = append(evs, ev)
|
||||
}
|
||||
s.events.OnTrap(ctx, agent.TenantID, evs)
|
||||
}
|
||||
|
||||
// raiseConfigAlert доводить долю збору конфігу до подієвих правил.
|
||||
//
|
||||
// Дві події, а не одна: «конфіг змінився» і «конфіг не зібрався» —
|
||||
// різні новини для різних людей. Перша цікавить того, хто відповідає за
|
||||
// зміни; друга — того, хто відповідає за те, щоб бекапи взагалі були.
|
||||
// Звести їх в одну означало б, що ввімкнувши потрібну, отримуєш і зайву.
|
||||
func (s *Service) raiseConfigAlert(ctx context.Context, tenantID, deviceID, configType, kind, detail string) {
|
||||
if s.events == nil || deviceID == "" {
|
||||
return
|
||||
}
|
||||
s.events.OnConfig(ctx, tenantID, alerting.ConfigEvent{
|
||||
DeviceID: deviceID,
|
||||
ConfigType: configType,
|
||||
Kind: kind,
|
||||
Detail: detail,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Автовиявлення
|
||||
// ---------------------------------------------------------------------
|
||||
|
|
@ -208,11 +324,21 @@ func (s *Service) ReportDiscovery(ctx context.Context, rep *npv1.DiscoveryReport
|
|||
// Системна інформація приїжджає тим самим звітом і дає найдешевший
|
||||
// онбординг з можливих: пристрій сам сказав, що він таке, і шаблон
|
||||
// причепився без жодного натискання.
|
||||
if info, err := s.store.ApplySystemInfo(ctx, agent.TenantID, rep.GetDevices()); err != nil {
|
||||
info, err := s.store.ApplySystemInfo(ctx, agent.TenantID, rep.GetDevices())
|
||||
switch {
|
||||
case err != nil:
|
||||
s.log.Error("системна інформація не застосувалась", "agent", agent.ID, "err", err)
|
||||
} else if info.Assigned > 0 {
|
||||
s.log.Info("шаблони призначено за sysObjectID",
|
||||
"agent", agent.ID, "хостів", info.Described, "призначено", info.Assigned)
|
||||
case info.Failed > 0:
|
||||
// Не Error: решта хостів у звіті оброблена, і зупиняти на цьому
|
||||
// онбординг немає підстав. Але й ховати не можна — причина
|
||||
// лежить у картці кожного з них, а тут видно масштаб.
|
||||
s.log.Warn("частину хостів не розпізнано",
|
||||
"agent", agent.ID, "хостів", info.Described,
|
||||
"невдач", info.Failed, "err", info.LastError)
|
||||
case info.Assigned > 0 || info.HardwareChanged > 0:
|
||||
s.log.Info("розпізнавання застосовано",
|
||||
"agent", agent.ID, "хостів", info.Described,
|
||||
"шаблонів", info.Assigned, "заміна_заліза", info.HardwareChanged)
|
||||
}
|
||||
|
||||
return &npv1.DiscoveryAck{
|
||||
|
|
@ -340,6 +466,17 @@ func (s *Service) UploadConfig(stream npv1.AgentService_UploadConfigServer) erro
|
|||
}
|
||||
tr := p.Trailer
|
||||
|
||||
// Масове виконання команд повертається цим самим стрімом:
|
||||
// шлях сервер→зонд→сервер уже є, і другий такий самий
|
||||
// заради іншого призначення виводу був би копією з власними
|
||||
// помилками. Розвилка стоїть саме тут, до розбору тіла, бо
|
||||
// далі йде логіка бекапу — звірка з попередньою версією,
|
||||
// коміт у Git, — якої для `show version` не існує.
|
||||
if s.isCommandUpload(ctx, header) {
|
||||
return stream.SendAndClose(
|
||||
s.storeCommandResult(ctx, header, body, tr))
|
||||
}
|
||||
|
||||
if !tr.GetSuccess() {
|
||||
s.log.Warn("зонд не зміг зібрати конфіг",
|
||||
"agent", agent.ID, "job", header.GetJobId(),
|
||||
|
|
@ -349,6 +486,8 @@ func (s *Service) UploadConfig(stream npv1.AgentService_UploadConfigServer) erro
|
|||
// вважатиметься таким, що збирається.
|
||||
_ = s.store.FinishConfigJob(ctx, header.GetJobId(), "failed",
|
||||
tr.GetError().GetMessage(), tr.GetTranscript())
|
||||
s.raiseConfigAlert(ctx, agent.TenantID, header.GetDeviceId(),
|
||||
header.GetConfigType(), "backup_failed", tr.GetError().GetMessage())
|
||||
return stream.SendAndClose(&npv1.ConfigReceipt{
|
||||
JobId: header.GetJobId(), Accepted: false, Error: tr.GetError(),
|
||||
})
|
||||
|
|
@ -364,6 +503,8 @@ func (s *Service) UploadConfig(stream npv1.AgentService_UploadConfigServer) erro
|
|||
"agent", agent.ID, "encoding", header.GetEncoding(), "err", err)
|
||||
_ = s.store.FinishConfigJob(ctx, header.GetJobId(), "failed", err.Error(),
|
||||
tr.GetTranscript())
|
||||
s.raiseConfigAlert(ctx, agent.TenantID, header.GetDeviceId(),
|
||||
header.GetConfigType(), "backup_failed", err.Error())
|
||||
return stream.SendAndClose(&npv1.ConfigReceipt{
|
||||
JobId: header.GetJobId(),
|
||||
Accepted: false,
|
||||
|
|
@ -375,6 +516,38 @@ func (s *Service) UploadConfig(stream npv1.AgentService_UploadConfigServer) erro
|
|||
}
|
||||
body = plain
|
||||
|
||||
// Набір конфіг-файлів сервера несе відбиток машини, з якої
|
||||
// його знято. Звіряємо ДО збереження.
|
||||
//
|
||||
// Перевірка стоїть тут, а не в StoreConfig: той нічого не
|
||||
// знає про зонди й машини й не має починати. А знати про це
|
||||
// мусить рівно одне місце — те, куди приходять набори.
|
||||
//
|
||||
// Ціна помилки саме тут найвища: історія конфігів сервера
|
||||
// живе в одній гілці Git, і файли іншої машини, дописані в
|
||||
// неї, виглядають звичайною зміною конфігу. Помітити таке
|
||||
// можна хіба через півроку — коли з архіву треба
|
||||
// відновлюватись.
|
||||
if mid := header.GetMachineId(); mid != "" {
|
||||
if err := s.store.PinSelfMachine(ctx, agent.TenantID,
|
||||
header.GetDeviceId(), mid); err != nil {
|
||||
s.log.Warn("набір конфіг-файлів відхилено: не та машина",
|
||||
"agent", agent.ID, "device", header.GetDeviceId(), "err", err)
|
||||
_ = s.store.FinishConfigJob(ctx, header.GetJobId(), "failed",
|
||||
err.Error(), tr.GetTranscript())
|
||||
s.raiseConfigAlert(ctx, agent.TenantID, header.GetDeviceId(),
|
||||
header.GetConfigType(), "backup_failed", err.Error())
|
||||
return stream.SendAndClose(&npv1.ConfigReceipt{
|
||||
JobId: header.GetJobId(),
|
||||
Accepted: false,
|
||||
Error: &npv1.Error{
|
||||
Code: "machine_mismatch",
|
||||
Message: err.Error(),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
outcome, err := s.store.StoreConfig(ctx, agent, store.ConfigSubmission{
|
||||
JobID: header.GetJobId(),
|
||||
DeviceID: header.GetDeviceId(),
|
||||
|
|
@ -390,6 +563,9 @@ func (s *Service) UploadConfig(stream npv1.AgentService_UploadConfigServer) erro
|
|||
"agent", agent.ID, "device", header.GetDeviceId())
|
||||
_ = s.store.FinishConfigJob(ctx, header.GetJobId(), "failed",
|
||||
"тіло не відповідає заявленому sha256", tr.GetTranscript())
|
||||
s.raiseConfigAlert(ctx, agent.TenantID, header.GetDeviceId(),
|
||||
header.GetConfigType(), "backup_failed",
|
||||
"тіло не відповідає заявленому sha256")
|
||||
return stream.SendAndClose(&npv1.ConfigReceipt{
|
||||
JobId: header.GetJobId(),
|
||||
Accepted: false,
|
||||
|
|
@ -414,6 +590,15 @@ func (s *Service) UploadConfig(stream npv1.AgentService_UploadConfigServer) erro
|
|||
}
|
||||
_ = s.store.FinishConfigJob(ctx, header.GetJobId(), finalStatus, "", tr.GetTranscript())
|
||||
|
||||
// Подія рівно тоді, коли конфіг СПРАВДІ інший. Збіг хеша —
|
||||
// не зміна, і алертувати на кожен нічний збір означало б
|
||||
// щоранку віддавати черговому сорок повідомлень «усе як
|
||||
// було».
|
||||
if outcome.Accepted && !outcome.Unchanged {
|
||||
s.raiseConfigAlert(ctx, agent.TenantID, header.GetDeviceId(),
|
||||
header.GetConfigType(), "changed", outcome.CommitSHA)
|
||||
}
|
||||
|
||||
return stream.SendAndClose(&npv1.ConfigReceipt{
|
||||
JobId: header.GetJobId(),
|
||||
Accepted: outcome.Accepted,
|
||||
|
|
|
|||
|
|
@ -11,12 +11,9 @@ import (
|
|||
"github.com/netpulse/netpulse/server/internal/store"
|
||||
)
|
||||
|
||||
// maxMute — стеля ручного заглушення.
|
||||
//
|
||||
// Безстрокове «не турбувати» — найпоширеніший спосіб тихо вимкнути
|
||||
// моніторинг назавжди: людина глушить на час аварії й забуває. Тиждень
|
||||
// достатньо для будь-яких робіт і замало, щоб забути назовсім.
|
||||
const maxMute = 7 * 24 * time.Hour
|
||||
// Стеля ручного заглушення живе в шарі store: ту саму кнопку тепер
|
||||
// натискають і з інтерфейсу, і з Telegram, а політика в них одна.
|
||||
const maxMute = store.MaxMute
|
||||
|
||||
func (s *Server) handleListAlerts(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, "alerts:read") {
|
||||
|
|
@ -168,14 +165,9 @@ func (s *Server) handleListAlertRules(w http.ResponseWriter, r *http.Request, p
|
|||
writeJSON(w, http.StatusOK, map[string]any{"rules": rules})
|
||||
}
|
||||
|
||||
// validSources і validSeverities дублюють ENUM у БД навмисно: помилка
|
||||
// в назві джерела має давати 400 з зрозумілим текстом, а не 500 від
|
||||
// невдалого приведення типу в глибині запиту.
|
||||
var validSources = map[string]bool{
|
||||
"metric": true, "icmp": true, "interface": true, "link": true,
|
||||
"syslog": true, "trap": true, "ncm": true, "agent": true, "compliance": true,
|
||||
}
|
||||
|
||||
// validSeverities дублює ENUM alr.severity навмисно: помилка в назві
|
||||
// має давати 400 зі зрозумілим текстом, а не 500 від невдалого
|
||||
// приведення типу в глибині запиту.
|
||||
var validSeverities = map[string]bool{
|
||||
"info": true, "warning": true, "average": true, "high": true, "disaster": true,
|
||||
}
|
||||
|
|
@ -199,6 +191,13 @@ func (s *Server) handleCreateAlertRule(w http.ResponseWriter, r *http.Request, p
|
|||
ChannelIDs []string `json:"channel_ids"`
|
||||
NotifySchedule json.RawMessage `json:"notify_schedule"`
|
||||
NotifyOnResolve *bool `json:"notify_on_resolve"`
|
||||
// Тільки для подієвих джерел.
|
||||
AutoCloseSeconds *int `json:"auto_close_seconds"`
|
||||
MinIntervalSeconds *int `json:"min_interval_seconds"`
|
||||
// Драбина ескалації. Порожньо — без ескалації, і це типове
|
||||
// значення: правило, яке нікого не будить понад перше
|
||||
// сповіщення, поводиться рівно так, як поводилось до 0066.
|
||||
EscalationPolicyID string `json:"escalation_policy_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "некоректний JSON")
|
||||
|
|
@ -209,8 +208,14 @@ func (s *Server) handleCreateAlertRule(w http.ResponseWriter, r *http.Request, p
|
|||
writeError(w, http.StatusBadRequest, "bad_request", "потрібна назва правила")
|
||||
return
|
||||
}
|
||||
if !validSources[req.Source] {
|
||||
writeError(w, http.StatusBadRequest, "bad_source", "невідоме джерело правила")
|
||||
// Джерело, якого движок не вміє, тепер не зберігається мовчки.
|
||||
//
|
||||
// Саме мовчання й було помилкою: правило з джерелом `trap` лягало в
|
||||
// базу, показувалось увімкненим і не спрацьовувало ніколи. Людина
|
||||
// читала нуль алертів як «подій не було» — і була впевнена в цьому
|
||||
// рівно доти, доки щось не згоріло.
|
||||
if reason := store.UnsupportedSourceReason(req.Source); reason != "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_source", reason)
|
||||
return
|
||||
}
|
||||
if req.Severity == "" {
|
||||
|
|
@ -224,10 +229,71 @@ func (s *Server) handleCreateAlertRule(w http.ResponseWriter, r *http.Request, p
|
|||
writeError(w, http.StatusBadRequest, "bad_request", "потрібна умова")
|
||||
return
|
||||
}
|
||||
// Умову подієвого правила перевіряємо тут, а не при спрацюванні:
|
||||
// зламаний регулярний вираз, помічений через пів року мовчання, —
|
||||
// це та сама порожня обіцянка, тільки в іншому місці.
|
||||
if err := store.ValidateRuleCondition(req.Source, req.Condition); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_condition", err.Error())
|
||||
return
|
||||
}
|
||||
if req.ForSeconds <= 0 {
|
||||
req.ForSeconds = 60
|
||||
}
|
||||
|
||||
// Подієвому алерту нема від чого «зникнути»: подія сталась і не
|
||||
// перестане. Тому в нього є строк — і за замовчуванням це доба,
|
||||
// тобто «встиг побачити на наступній зміні». Нуль лишається
|
||||
// дозволеним і означає «закриє людина».
|
||||
autoClose, minInterval := 0, 0
|
||||
if store.IsEventSource(req.Source) {
|
||||
autoClose, minInterval = 86400, 60
|
||||
if req.AutoCloseSeconds != nil {
|
||||
autoClose = *req.AutoCloseSeconds
|
||||
}
|
||||
if req.MinIntervalSeconds != nil {
|
||||
minInterval = *req.MinIntervalSeconds
|
||||
}
|
||||
if autoClose < 0 || autoClose > 30*86400 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request",
|
||||
"строк життя подієвого алерту — від 0 (тільки руками) до 30 діб")
|
||||
return
|
||||
}
|
||||
if minInterval < 0 || minInterval > 3600 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request",
|
||||
"проміжок між подіями — від 0 до 3600 секунд")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Драбина має належати цьому ж кабінету.
|
||||
//
|
||||
// Зовнішній ключ перевіряє лише те, що така політика існує десь у
|
||||
// базі, — не те, що вона ваша: перевірка FK іде повз RLS. Наслідок
|
||||
// був би не витоком (чужу драбину все одно не прочитати), а гіршим:
|
||||
// правило виглядало б налаштованим на ескалацію й не ескалювало б
|
||||
// ніколи. Тобто рівно та мовчазна обіцянка, яку 0058 вже одного разу
|
||||
// прибрала з тригерів.
|
||||
if id := strings.TrimSpace(req.EscalationPolicyID); id != "" {
|
||||
ps, err := s.store.ListEscalationPolicies(r.Context(), p.TenantID)
|
||||
if err != nil {
|
||||
s.log.Error("перевірка політики ескалації", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
||||
return
|
||||
}
|
||||
found := false
|
||||
for _, x := range ps {
|
||||
if x.ID == id {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
writeError(w, http.StatusBadRequest, "bad_escalation",
|
||||
"драбину ескалації не знайдено — оберіть її зі списку на сторінці «Ескалації»")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
in := store.RuleInput{
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
|
|
@ -241,6 +307,11 @@ func (s *Server) handleCreateAlertRule(w http.ResponseWriter, r *http.Request, p
|
|||
ChannelIDs: req.ChannelIDs,
|
||||
NotifySchedule: string(req.NotifySchedule),
|
||||
NotifyOnResolve: req.NotifyOnResolve == nil || *req.NotifyOnResolve,
|
||||
|
||||
AutoCloseSeconds: autoClose,
|
||||
MinIntervalSeconds: minInterval,
|
||||
|
||||
EscalationPolicyID: strings.TrimSpace(req.EscalationPolicyID),
|
||||
}
|
||||
if in.ChannelIDs == nil {
|
||||
in.ChannelIDs = []string{}
|
||||
|
|
@ -493,3 +564,122 @@ func isBadUUID(err error) bool {
|
|||
func isUniqueViolation(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), "SQLSTATE 23505")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Політики ескалації
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
func (s *Server) handleListEscalationPolicies(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, "alerts:read") {
|
||||
return
|
||||
}
|
||||
ps, err := s.store.ListEscalationPolicies(r.Context(), p.TenantID)
|
||||
if err != nil {
|
||||
s.log.Error("перелік політик ескалації", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
||||
return
|
||||
}
|
||||
if ps == nil {
|
||||
ps = []store.EscalationPolicy{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"policies": ps})
|
||||
}
|
||||
|
||||
// handleSaveEscalationPolicy приймає і створення, і зміну.
|
||||
//
|
||||
// Один обробник на обидва, як і в правил: форма показує драбину цілком,
|
||||
// і розводити її на дві гілки означало б розійтися в перевірках рівно
|
||||
// там, де вони найдорожчі — у тому, кого й коли будити вночі.
|
||||
func (s *Server) handleSaveEscalationPolicy(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, "alerts:write") {
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Steps []store.EscalationStep `json:"steps"`
|
||||
RepeatAfterMin int `json:"repeat_after_min"`
|
||||
MaxRepeats int `json:"max_repeats"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "некоректний JSON")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Name) == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "потрібна назва політики")
|
||||
return
|
||||
}
|
||||
// Перевірка драбини живе в store, а не тут: ту саму відмову має
|
||||
// давати і збереження з форми, і будь-який інший шлях запису.
|
||||
if err := store.ValidateEscalationSteps(req.Steps); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_steps", err.Error())
|
||||
return
|
||||
}
|
||||
// Повтор без стелі — це нескінченний дзвінок, тому обидва числа
|
||||
// перевіряються разом: «повторювати» без «скільки разів» не
|
||||
// зберігається взагалі.
|
||||
if req.RepeatAfterMin < 0 || req.RepeatAfterMin > 1440 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request",
|
||||
"повтор драбини — від 1 до 1440 хвилин (0 — не повторювати)")
|
||||
return
|
||||
}
|
||||
if req.MaxRepeats < 0 || req.MaxRepeats > store.MaxEscalationRepeats {
|
||||
writeError(w, http.StatusBadRequest, "bad_request",
|
||||
"повторів не більше "+strconv.Itoa(store.MaxEscalationRepeats))
|
||||
return
|
||||
}
|
||||
if req.RepeatAfterMin > 0 && req.MaxRepeats == 0 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request",
|
||||
"вказано проміжок повтору, але нуль повторів — драбина не повториться жодного разу")
|
||||
return
|
||||
}
|
||||
|
||||
id, err := s.store.SaveEscalationPolicy(r.Context(), p.TenantID, r.PathValue("id"),
|
||||
store.EscalationPolicy{
|
||||
Name: strings.TrimSpace(req.Name),
|
||||
Description: req.Description,
|
||||
Steps: req.Steps,
|
||||
RepeatAfterMin: req.RepeatAfterMin,
|
||||
MaxRepeats: req.MaxRepeats,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "політику не знайдено")
|
||||
return
|
||||
}
|
||||
if isUniqueViolation(err) {
|
||||
writeError(w, http.StatusConflict, "duplicate", "політика з такою назвою вже є")
|
||||
return
|
||||
}
|
||||
if isBadUUID(err) {
|
||||
writeError(w, http.StatusBadRequest, "bad_id", "некоректний ідентифікатор")
|
||||
return
|
||||
}
|
||||
s.log.Error("збереження політики ескалації", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"id": id})
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteEscalationPolicy(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, "alerts:write") {
|
||||
return
|
||||
}
|
||||
err := s.store.DeleteEscalationPolicy(r.Context(), p.TenantID, r.PathValue("id"))
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "політику не знайдено")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
if isBadUUID(err) {
|
||||
writeError(w, http.StatusBadRequest, "bad_id", "некоректний ідентифікатор")
|
||||
return
|
||||
}
|
||||
s.log.Error("видалення політики ескалації", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
|
|
|||
193
server/internal/httpapi/audit.go
Normal file
193
server/internal/httpapi/audit.go
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/netpulse/netpulse/server/internal/store"
|
||||
)
|
||||
|
||||
// Журнал аудиту: читання, і тільки читання.
|
||||
//
|
||||
// Тут навмисно немає ані PUT, ані PATCH, ані DELETE — і це не «поки що
|
||||
// не додали». Журнал, який можна виправити, не доводить нічого: перше,
|
||||
// що зробить той, чиї дії в ньому записані, — виправить запис. Тому в
|
||||
// цьому файлі три GET, а в базі поверх них стоїть тригер (0050), який
|
||||
// відмовляє в UPDATE/DELETE/TRUNCATE навіть власнику з'єднання.
|
||||
//
|
||||
// Право одне на всі три ручки: перелік, подробиці й довідники дають ту
|
||||
// саму інформацію, лише порціями різного розміру. Роздати «дивитись
|
||||
// перелік, але не подробиці» означало б залишити чорний хід — сам
|
||||
// перелік уже показує, хто, коли й над чим.
|
||||
|
||||
const auditPerm = "audit:read"
|
||||
|
||||
// Стеля глибини вибірки. Не налаштовується: період і фільтри звужують
|
||||
// запит, а «покажи все» на журналі, що росте вічно, — це не глибина, а
|
||||
// відсутність питання.
|
||||
const auditMaxRange = 366 * 24 * time.Hour
|
||||
|
||||
func (s *Server) handleListAudit(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, auditPerm) {
|
||||
return
|
||||
}
|
||||
|
||||
q := r.URL.Query()
|
||||
f := store.AuditFilter{
|
||||
Actions: splitParam(q, "action"),
|
||||
ObjectTypes: splitParam(q, "object_type"),
|
||||
ActorUserIDs: splitParam(q, "actor"),
|
||||
ActorTokenIDs: splitParam(q, "token"),
|
||||
ActorIP: strings.TrimSpace(q.Get("ip")),
|
||||
Query: strings.TrimSpace(q.Get("q")),
|
||||
Cursor: q.Get("cursor"),
|
||||
}
|
||||
// «-» замість ідентифікатора означає «будь-який машинний токен».
|
||||
// Окреме значення, а не порожній параметр: порожній губиться в
|
||||
// адресному рядку й у кожній другій бібліотеці.
|
||||
users := f.ActorUserIDs[:0]
|
||||
for _, a := range f.ActorUserIDs {
|
||||
if a == "-" {
|
||||
f.AnyToken = true
|
||||
continue
|
||||
}
|
||||
users = append(users, a)
|
||||
}
|
||||
f.ActorUserIDs = users
|
||||
|
||||
var err error
|
||||
if f.From, err = parseTimeParam(q.Get("from")); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid", "некоректна дата «від»")
|
||||
return
|
||||
}
|
||||
if f.To, err = parseTimeParam(q.Get("to")); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid", "некоректна дата «до»")
|
||||
return
|
||||
}
|
||||
// Верхня межа за замовчуванням — зараз, а не «без межі». Без неї
|
||||
// подія, записана між першою сторінкою й наступною, зсунула б усю
|
||||
// вибірку на рядок: курсор рахує від низу, а нове приходить згори.
|
||||
// Зафіксована «до» робить гортання відтворюваним.
|
||||
if f.To.IsZero() {
|
||||
f.To = time.Now().UTC()
|
||||
}
|
||||
if f.From.IsZero() {
|
||||
f.From = f.To.Add(-30 * 24 * time.Hour)
|
||||
}
|
||||
if !f.From.Before(f.To) {
|
||||
writeError(w, http.StatusBadRequest, "invalid", "початок періоду має бути раніше за кінець")
|
||||
return
|
||||
}
|
||||
if f.To.Sub(f.From) > auditMaxRange {
|
||||
writeError(w, http.StatusBadRequest, "invalid",
|
||||
"період не більший за рік — звузьте діапазон або скористайтесь фільтрами")
|
||||
return
|
||||
}
|
||||
|
||||
if n, err := strconv.Atoi(q.Get("limit")); err == nil {
|
||||
f.Limit = n
|
||||
}
|
||||
|
||||
// Обмеження видимості (Scope) тут не застосовується, і це рішення,
|
||||
// а не пропуск. Scope відповідає на «які ХОСТИ видно», а рядок
|
||||
// журналу — не про хост: у нього може не бути об'єкта взагалі
|
||||
// (зміна політики очистки), а може бути перелік із сорока хостів у
|
||||
// meta. Показати такий рядок наполовину означало б підсунути
|
||||
// неповну правду під виглядом повної. Право audit:read має лише
|
||||
// власник і адмін, а вони й так не обмежені групами.
|
||||
page, err := s.store.ListAudit(r.Context(), p.TenantID, f)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "перелік журналу аудиту", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, page)
|
||||
}
|
||||
|
||||
// handleGetAuditEvent віддає один запис разом із before/after/meta.
|
||||
//
|
||||
// Окремо від переліку, бо вміст важить непередбачувано: meta прогону
|
||||
// команд на п'ятсот хостів несе п'ятсот імен, і возити це в кожному
|
||||
// рядку переліку означало б платити мегабайтами за те, на що дивляться
|
||||
// раз.
|
||||
func (s *Server) handleGetAuditEvent(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, auditPerm) {
|
||||
return
|
||||
}
|
||||
ts, err := parseTimeParam(r.URL.Query().Get("ts"))
|
||||
if err != nil || ts.IsZero() {
|
||||
// ts обов'язковий: первинний ключ гіпертаблиці — (ts, id), і
|
||||
// без часу пошук за самим id означав би обхід усіх чанків.
|
||||
writeError(w, http.StatusBadRequest, "invalid", "потрібен параметр ts")
|
||||
return
|
||||
}
|
||||
ev, err := s.store.GetAuditEvent(r.Context(), p.TenantID, r.PathValue("id"), ts)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "запис журналу аудиту", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, ev)
|
||||
}
|
||||
|
||||
// handleAuditMeta — усе, що потрібно сторінці для фільтрів, одним
|
||||
// викликом.
|
||||
//
|
||||
// Одним, а не трьома: це стан сторінки на момент відкриття, і зібраний
|
||||
// із трьох відповідей у різні секунди він нічого не виграє, зате додає
|
||||
// два стани завантаження на екран.
|
||||
func (s *Server) handleAuditMeta(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, auditPerm) {
|
||||
return
|
||||
}
|
||||
|
||||
actors, err := s.store.AuditActors(r.Context(), p.TenantID)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "перелік акторів журналу", err)
|
||||
return
|
||||
}
|
||||
bounds, err := s.store.AuditBounds(r.Context(), p.TenantID)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "межі журналу", err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"actions": store.AuditActions(),
|
||||
"object_types": store.AuditObjectTypes(),
|
||||
"actors": actors,
|
||||
"bounds": bounds,
|
||||
"blind_spots": store.AuditBlindSpots(),
|
||||
// Стеля періоду — щоб сторінка могла сказати про неї заздалегідь,
|
||||
// а не відмовою після натискання.
|
||||
"max_range_days": int(auditMaxRange / (24 * time.Hour)),
|
||||
})
|
||||
}
|
||||
|
||||
// splitParam читає повторюваний параметр і в обох формах: ?action=a&action=b
|
||||
// та ?action=a,b. Друга потрібна тому, що посилання на відфільтровану
|
||||
// сторінку люди пересилають одне одному, і коротший рядок читабельніший.
|
||||
func splitParam(q map[string][]string, key string) []string {
|
||||
var out []string
|
||||
for _, v := range q[key] {
|
||||
for _, part := range strings.Split(v, ",") {
|
||||
if part = strings.TrimSpace(part); part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// parseTimeParam приймає RFC3339. Порожній рядок — не помилка, а
|
||||
// «не задано»: межі періоду мають типові значення.
|
||||
func parseTimeParam(s string) (time.Time, error) {
|
||||
if s = strings.TrimSpace(s); s == "" {
|
||||
return time.Time{}, nil
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return t.UTC(), nil
|
||||
}
|
||||
|
|
@ -590,3 +590,36 @@ func (s *Server) handleGetDeviceCredentials(w http.ResponseWriter, r *http.Reque
|
|||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"credential_ids": ids})
|
||||
}
|
||||
|
||||
// handleIdentifyDevice просить опитати хост по SNMP просто зараз.
|
||||
//
|
||||
// Відмова — це 200 з поясненням, а не 4xx: «немає SNMP-доступу» і
|
||||
// «зонд не на зв'язку» не помилки запиту, а стан системи, який людині
|
||||
// треба показати в тій самій формі, а не в діалозі про збій.
|
||||
func (s *Server) handleIdentifyDevice(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, "devices:write") {
|
||||
return
|
||||
}
|
||||
deviceID := r.PathValue("id")
|
||||
if !p.Scope().CanWrite(deviceID) {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "немає доступу на запис до цього хоста")
|
||||
return
|
||||
}
|
||||
|
||||
d, err := s.store.RequestIdentify(r.Context(), p.TenantID, deviceID)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "прохання розпізнати хост", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, d)
|
||||
}
|
||||
|
||||
// handleGetDevice — один хост.
|
||||
func (s *Server) handleGetDevice(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
d, err := s.store.GetDevice(r.Context(), p.TenantID, r.PathValue("id"), p.Scope())
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "хост", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"device": d})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/netpulse/netpulse/server/internal/alerting"
|
||||
"github.com/netpulse/netpulse/server/internal/store"
|
||||
)
|
||||
|
||||
|
|
@ -45,6 +46,12 @@ func (s *Server) handleSaveComplianceRule(w http.ResponseWriter, r *http.Request
|
|||
if in.Severity == "" {
|
||||
in.Severity = "medium"
|
||||
}
|
||||
// Типово — конфіг заліза. Правило без явного типу писали під нього,
|
||||
// і мовчки перевіряти ним набір файлів сервера означало б дати
|
||||
// впевнену неправильну відповідь замість жодної.
|
||||
if in.ConfigType == "" {
|
||||
in.ConfigType = "running"
|
||||
}
|
||||
|
||||
id, err := s.store.SaveComplianceRule(r.Context(), p.TenantID, in)
|
||||
if err != nil {
|
||||
|
|
@ -102,5 +109,23 @@ func (s *Server) handleRunCompliance(w http.ResponseWriter, r *http.Request, p *
|
|||
s.writeStoreError(w, "перевірка відповідності", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Прогін — єдина мить, коли відомі обидві половини правди:
|
||||
// які хости порушили вимогу й на яких вона більше не порушується.
|
||||
// Тому тригери джерела `compliance` обробляються рівно тут, а не
|
||||
// окремим опитуванням таблиці результатів, яке однаково не змогло б
|
||||
// відрізнити «стало погано» від «було погано вчора».
|
||||
if s.events != nil && len(stat.Findings) > 0 {
|
||||
evs := make([]alerting.ComplianceEvent, 0, len(stat.Findings))
|
||||
for _, f := range stat.Findings {
|
||||
evs = append(evs, alerting.ComplianceEvent{
|
||||
RuleID: f.RuleID, RuleName: f.RuleName, Severity: f.Severity,
|
||||
DeviceID: f.DeviceID, Passed: f.Passed,
|
||||
Line: f.Line, LineNumber: f.LineNumber,
|
||||
})
|
||||
}
|
||||
s.events.OnCompliance(r.Context(), p.TenantID, evs)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, stat)
|
||||
}
|
||||
|
|
|
|||
74
server/internal/httpapi/device_detail.go
Normal file
74
server/internal/httpapi/device_detail.go
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Ручки картки хоста.
|
||||
//
|
||||
// Три окремі, а не одна велика: вкладки відкривають по одній, і тягнути
|
||||
// сто портів разом із двомастами рядами заради вкладки «Огляд» означало
|
||||
// б платити за все щоразу. Спільне в них лише перевірка доступу — і вона
|
||||
// винесена в deviceReadable.
|
||||
|
||||
// deviceReadable — право на розділ і доступ саме до цього хоста.
|
||||
//
|
||||
// Двома окремими перевірками, бо це два незалежні виміри: право каже,
|
||||
// що людині взагалі можна дивитись на хости, scope — на які саме.
|
||||
func deviceReadable(w http.ResponseWriter, r *http.Request, p *Principal) (string, bool) {
|
||||
if !requirePerm(w, p, "devices:read") {
|
||||
return "", false
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
if !p.Scope().CanRead(id) {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "немає доступу до цього хоста")
|
||||
return "", false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
// handleDeviceOverview — паспорт хоста й зміст решти вкладок.
|
||||
func (s *Server) handleDeviceOverview(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
id, ok := deviceReadable(w, r, p)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
o, err := s.store.DeviceOverviewData(r.Context(), p.TenantID, id)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "огляд хоста", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"overview": o})
|
||||
}
|
||||
|
||||
// handleDeviceLatest — усі метрики хоста з останнім значенням і походженням.
|
||||
//
|
||||
// Перевірки віддаються тією ж відповіддю, а не окремою ручкою: походження
|
||||
// метрики посилається на чек, і два незалежні запити давали б на екрані
|
||||
// посилання в нікуди рівно в той момент, коли чек щойно прибрали.
|
||||
func (s *Server) handleDeviceLatest(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
id, ok := deviceReadable(w, r, p)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
res, err := s.store.DeviceLatestData(r.Context(), p.TenantID, id)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "останні дані хоста", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, res)
|
||||
}
|
||||
|
||||
// handleDeviceInterfaces — порти хоста з останніми лічильниками.
|
||||
func (s *Server) handleDeviceInterfaces(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
id, ok := deviceReadable(w, r, p)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
list, err := s.store.DeviceInterfaces(r.Context(), p.TenantID, id)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "порти хоста", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"interfaces": list})
|
||||
}
|
||||
628
server/internal/httpapi/devices_bulk.go
Normal file
628
server/internal/httpapi/devices_bulk.go
Normal file
|
|
@ -0,0 +1,628 @@
|
|||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/netpulse/netpulse/server/internal/cronx"
|
||||
"github.com/netpulse/netpulse/server/internal/store"
|
||||
)
|
||||
|
||||
// Масова правка й масове видалення хостів.
|
||||
//
|
||||
// Запобіжники тут ті самі, що й у масовому виконанні команд, і саме
|
||||
// тому, що вони ті самі: дві масові операції в одній системі мусять
|
||||
// поводитись однаково, інакше людина, яка звикла до однієї, помилиться
|
||||
// в другій.
|
||||
//
|
||||
// - право devices:write, а не лише devices:read;
|
||||
// - у набір потрапляє тільки те, що людині дозволено ЗМІНЮВАТИ
|
||||
// (Scope.Writable), а не те, що вона бачить;
|
||||
// - клієнт шле перелік id разом із фільтром, сервер бере ПЕРЕТИН, а
|
||||
// те, що відпало, повертає окремим списком — мовчазна різниця між
|
||||
// підтвердженим і зробленим тут неприпустима;
|
||||
// - стеля на кількість за раз (store.MaxBulkDevices);
|
||||
// - кожна масова дія — рядок в аудиті з іменами хостів.
|
||||
//
|
||||
// Окремим правом, як ncm:exec, ця дія не стала свідомо. ncm:exec
|
||||
// закриває те, чого інакше не зробити взагалі, — виконання довільної
|
||||
// команди на залізі. Тут же немає жодної зміни, якої не можна було б
|
||||
// зробити картками хостів по одній: масова форма економить час, а не
|
||||
// відмикає нові можливості. Заводити під економію часу окреме право
|
||||
// означало б, що інженер із devices:write, який має видаляти хости,
|
||||
// видаляє їх поштучно й довше — з тим самим результатом.
|
||||
|
||||
// bulkDeviceBody — спільне тіло всіх трьох ручок.
|
||||
//
|
||||
// Фільтр і перелік id разом: перелік каже, що людина бачила на екрані,
|
||||
// фільтр — за яким наміром вона це відбирала. Сервер бере перетин, тож
|
||||
// хост, заведений між переглядом і натисканням, у дію не потрапить,
|
||||
// навіть якщо підпадає під фільтр.
|
||||
type bulkDeviceBody struct {
|
||||
Filter store.DeviceFilter `json:"filter"`
|
||||
DeviceIDs []string `json:"device_ids"`
|
||||
Changes store.BulkDeviceChanges `json:"changes"`
|
||||
|
||||
// Mode — яке саме видалення. Читається ручкою видалення, а ще —
|
||||
// добором цілей, бо від нього залежить, чи видно прибрані хости.
|
||||
//
|
||||
// archive — хост зникає з переліків, зібране лишається в базі
|
||||
// purge — хоста й усього, що з ним пов'язане, більше немає
|
||||
// restore — не видалення: добір цілей для повернення з архіву
|
||||
//
|
||||
// Порожнє значення означає archive, і це не заготовка «на потім», а
|
||||
// сумісність: ручка існувала до появи вибору, і клієнт, який про
|
||||
// нього не знає, не має отримати незворотну дію замість тієї, яку
|
||||
// просив.
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
|
||||
// resolveBulk — спільний перший крок: перетин і те, що з нього випало.
|
||||
func (s *Server) resolveBulk(w http.ResponseWriter, r *http.Request, p *Principal,
|
||||
in bulkDeviceBody) ([]store.BulkDeviceTarget, []string, bool) {
|
||||
|
||||
// Повне видалення й повернення бачать і прибраних. Інакше «прибрати
|
||||
// з переліку» було б пасткою: хост зникає звідусіль разом із
|
||||
// можливістю його добити, а зібране лишається в базі назавжди.
|
||||
//
|
||||
// Дві дії, які адресовані САМЕ прибраним хостам, і жодної третьої:
|
||||
// масова правка архіву сенсу не має (міняти налаштування тому, кого
|
||||
// в переліках немає, — це спосіб повернути його наполовину), а
|
||||
// архівне видалення вже архівованого не робить нічого.
|
||||
resolve := s.store.ResolveBulkTargets
|
||||
if in.Mode == "purge" || in.Mode == "restore" {
|
||||
resolve = s.store.ResolveBulkPurgeTargets
|
||||
}
|
||||
targets, err := resolve(r.Context(), p.TenantID, p.Scope(),
|
||||
in.Filter, in.DeviceIDs)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "добір хостів для масової дії", err)
|
||||
return nil, nil, false
|
||||
}
|
||||
|
||||
keep := make(map[string]bool, len(targets))
|
||||
for _, t := range targets {
|
||||
keep[t.DeviceID] = true
|
||||
}
|
||||
dropped := []string{}
|
||||
for _, id := range in.DeviceIDs {
|
||||
if !keep[id] {
|
||||
dropped = append(dropped, id)
|
||||
}
|
||||
}
|
||||
return targets, dropped, true
|
||||
}
|
||||
|
||||
// handleBulkDeviceTargets — «що саме я зараз зачеплю».
|
||||
//
|
||||
// Окремий крок і окрема ручка, як і в масовому виконанні команд:
|
||||
// підтвердження без точного переліку не вважається підтвердженням, і
|
||||
// клієнт не має змоги його оминути. Разом із переліком їдуть числа
|
||||
// прив'язаного — чеки, алерти, вузли на мапах, розклад: усе, про що
|
||||
// питають ПІСЛЯ видалення, має бути видно ДО нього.
|
||||
func (s *Server) handleBulkDeviceTargets(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, "devices:write") {
|
||||
return
|
||||
}
|
||||
var in bulkDeviceBody
|
||||
if !decodeBody(w, r, &in) {
|
||||
return
|
||||
}
|
||||
|
||||
targets, dropped, ok := s.resolveBulk(w, r, p, in)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if targets == nil {
|
||||
targets = []store.BulkDeviceTarget{}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"devices": targets,
|
||||
// Хости, які людина відмітила, але які не пройшли: або зникли,
|
||||
// або лишились без доступу на запис, або більше не підпадають
|
||||
// під фільтр. Мовчати про них не можна — інакше «обрано 50,
|
||||
// змінено 43» людина побачить лише в аудиті.
|
||||
"dropped": dropped,
|
||||
"max": store.MaxBulkDevices,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleBulkUpdateDevices(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, "devices:write") {
|
||||
return
|
||||
}
|
||||
var in bulkDeviceBody
|
||||
if !decodeBody(w, r, &in) {
|
||||
return
|
||||
}
|
||||
|
||||
// Розклад збору конфігів живе за іншим правом і в іншому розділі.
|
||||
// Масова форма не має ставати обхідним шляхом до нього: хто не
|
||||
// може змінити розклад одному хосту, не міняє його й п'ятдесяти.
|
||||
if in.Changes.Backup != nil {
|
||||
if !requirePerm(w, p, "ncm:write") {
|
||||
return
|
||||
}
|
||||
if in.Changes.Backup.Op == "set" {
|
||||
cron := strings.TrimSpace(in.Changes.Backup.Cron)
|
||||
if cron == "" {
|
||||
cron = "0 3 * * *"
|
||||
}
|
||||
// Розклад перевіряємо тут, а не в планувальнику: інакше
|
||||
// людина дізнається про друкарську помилку через добу
|
||||
// мовчання — і одразу на всіх хостах набору.
|
||||
if _, err := cronx.Parse(cron); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_cron", "розклад: "+err.Error())
|
||||
return
|
||||
}
|
||||
in.Changes.Backup.Cron = cron
|
||||
}
|
||||
}
|
||||
if in.Changes.Kind != nil && in.Changes.Kind.Op == "set" &&
|
||||
!validDeviceKinds[in.Changes.Kind.Value] {
|
||||
writeError(w, http.StatusBadRequest, "bad_kind", "невідомий тип пристрою")
|
||||
return
|
||||
}
|
||||
if !s.checkBulkChecksOp(w, r, p, in.Changes.Checks) {
|
||||
return
|
||||
}
|
||||
|
||||
targets, dropped, ok := s.resolveBulk(w, r, p, in)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if len(targets) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "no_targets",
|
||||
"жоден з обраних хостів більше не доступний на запис — перегляньте перелік ще раз")
|
||||
return
|
||||
}
|
||||
|
||||
ids := make([]string, 0, len(targets))
|
||||
for _, t := range targets {
|
||||
ids = append(ids, t.DeviceID)
|
||||
}
|
||||
|
||||
changed, names, checkRes, err := s.store.BulkUpdateDevices(r.Context(), p.TenantID, p.Scope(),
|
||||
ids, in.Changes)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "масова правка хостів", err)
|
||||
return
|
||||
}
|
||||
|
||||
meta := map[string]any{
|
||||
"devices": len(changed),
|
||||
"device_ids": changed,
|
||||
// Імена, а не лише id: через рік хостів може вже не бути, а
|
||||
// питання «що тоді зробили» лишиться.
|
||||
"names": names,
|
||||
"changes": in.Changes,
|
||||
"filter": in.Filter,
|
||||
}
|
||||
// Наслідок дії над перевірками, а не лише намір. «Просив додати
|
||||
// сорока, додав дванадцятьом» — це те, заради чого журнал і
|
||||
// читають; із самого changes цього не видно ніяк.
|
||||
if checkRes != nil {
|
||||
meta["checks"] = map[string]any{
|
||||
"op": checkRes.Op,
|
||||
"check_type": checkRes.CheckType,
|
||||
"done": checkRes.Done,
|
||||
"skipped": len(checkRes.Skipped),
|
||||
"failed": checkRes.Failed,
|
||||
}
|
||||
}
|
||||
|
||||
// Аудит пишеться після успіху й не блокує відповідь помилкою: якщо
|
||||
// журнал не прийняв рядок, правка вже в базі, і приховувати її від
|
||||
// людини було б гірше, ніж лишити слід тільки в логах сервера.
|
||||
if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{
|
||||
ActorUserID: p.UserID,
|
||||
ActorTokenID: p.TokenID,
|
||||
ActorIP: clientIP(r),
|
||||
Action: store.AuditActionDeviceBulkUpdate,
|
||||
ObjectType: store.AuditObjectDevice,
|
||||
Meta: meta,
|
||||
}); err != nil {
|
||||
s.log.Error("аудит масової правки хостів", "err", err)
|
||||
}
|
||||
|
||||
s.log.Info("масова правка хостів",
|
||||
"хостів", len(changed), "користувач", p.Username, "user_id", p.UserID)
|
||||
|
||||
out := map[string]any{
|
||||
"updated": len(changed),
|
||||
"names": names,
|
||||
"dropped": dropped,
|
||||
}
|
||||
if checkRes != nil {
|
||||
out["checks"] = checkRes
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// checkBulkChecksOp — запобіжники масової дії над ручними перевірками.
|
||||
//
|
||||
// Окремим правом вона не стала: усе те саме людина з devices:write
|
||||
// робить у картці хоста поштучно (handleSetDeviceChecks), і масова
|
||||
// форма економить час, а не відмикає нові можливості.
|
||||
//
|
||||
// Що тут перевіряється й чому саме тут:
|
||||
//
|
||||
// - тип із довідника. Невідомий ключ інакше доїхав би до зонда й помер
|
||||
// там як задача неіснуючому модулю — за кілометри від того місця, де
|
||||
// його ввели. Так само, як у handleSetDeviceChecks;
|
||||
// - auto_managed нізвідки не додається й нікуди не прибирається:
|
||||
// систему вже завела свій такий чек і сама тримає йому розклад, а
|
||||
// прибраний вона заведе знову на найближчому такті — тобто дія
|
||||
// виглядала б виконаною й нічого не міняла;
|
||||
// - обов'язкові за схемою параметри мають бути заповнені. Це і є та
|
||||
// межа, де масове додавання впирається в природу параметрів: url у
|
||||
// http.status обов'язковий, спільного значення для сорока хостів у
|
||||
// нього немає, і єдина відповідь, яка не бреше, — підстановка
|
||||
// {{address}} / {{name}} / {{fqdn}}. Порожньо лишити не можна, бо
|
||||
// чек без url не запуститься взагалі.
|
||||
func (s *Server) checkBulkChecksOp(w http.ResponseWriter, r *http.Request, p *Principal,
|
||||
op *store.BulkChecksOp) bool {
|
||||
|
||||
if op == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
known, err := s.store.ListCheckTypes(r.Context(), p.TenantID)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "типи перевірок", err)
|
||||
return false
|
||||
}
|
||||
var ct *store.CheckType
|
||||
for i := range known {
|
||||
if known[i].Key == op.CheckType {
|
||||
ct = &known[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if ct == nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_check_type",
|
||||
"невідомий тип перевірки: "+op.CheckType)
|
||||
return false
|
||||
}
|
||||
if ct.AutoManaged {
|
||||
writeError(w, http.StatusBadRequest, "auto_managed_check",
|
||||
"перевірку «"+ct.Name+"» заводить і планує система — масово її не додають і не прибирають")
|
||||
return false
|
||||
}
|
||||
|
||||
if op.IntervalSec != nil && (*op.IntervalSec < 5 || *op.IntervalSec > 86400) {
|
||||
writeError(w, http.StatusBadRequest, "bad_interval",
|
||||
"інтервал опитування має бути від 5 секунд до доби")
|
||||
return false
|
||||
}
|
||||
if op.TimeoutMs != nil && *op.TimeoutMs <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "bad_timeout", "таймаут має бути додатним")
|
||||
return false
|
||||
}
|
||||
if op.Retries != nil && *op.Retries < 0 {
|
||||
writeError(w, http.StatusBadRequest, "bad_retries", "повторів не може бути менше нуля")
|
||||
return false
|
||||
}
|
||||
|
||||
if op.Op != "add" {
|
||||
// Прибрати чи перелаштувати треба вміти й те, що лишилось від
|
||||
// вимкненого плагіна: інакше ці рядки не прибрати ніяк, а вони
|
||||
// далі стоять у планах і в переліку «Черги».
|
||||
return true
|
||||
}
|
||||
if !ct.Available {
|
||||
writeError(w, http.StatusBadRequest, "plugin_disabled",
|
||||
"плагін «"+ct.PluginKey+"» вимкнений — перевірку цього типу не додати")
|
||||
return false
|
||||
}
|
||||
|
||||
var schema struct {
|
||||
Required []string `json:"required"`
|
||||
}
|
||||
_ = json.Unmarshal(ct.ParamsSchema, &schema)
|
||||
if len(schema.Required) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
var params map[string]any
|
||||
if len(op.Params) > 0 {
|
||||
if err := json.Unmarshal(op.Params, ¶ms); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_params",
|
||||
"параметри перевірки мають бути об'єктом JSON")
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, k := range schema.Required {
|
||||
v, ok := params[k]
|
||||
if !ok || v == nil {
|
||||
writeError(w, http.StatusBadRequest, "missing_param",
|
||||
"параметр «"+k+"» обов'язковий для "+op.CheckType)
|
||||
return false
|
||||
}
|
||||
if str, isStr := v.(string); isStr && strings.TrimSpace(str) == "" {
|
||||
writeError(w, http.StatusBadRequest, "missing_param",
|
||||
"параметр «"+k+"» обов'язковий для "+op.CheckType)
|
||||
return false
|
||||
}
|
||||
if arr, isArr := v.([]any); isArr && len(arr) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "missing_param",
|
||||
"параметр «"+k+"» обов'язковий для "+op.CheckType)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) handleBulkDeleteDevices(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, "devices:write") {
|
||||
return
|
||||
}
|
||||
var in bulkDeviceBody
|
||||
if !decodeBody(w, r, &in) {
|
||||
return
|
||||
}
|
||||
|
||||
targets, dropped, ok := s.resolveBulk(w, r, p, in)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if len(targets) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "no_targets",
|
||||
"жоден з обраних хостів більше не доступний на запис — перегляньте перелік ще раз")
|
||||
return
|
||||
}
|
||||
|
||||
ids := make([]string, 0, len(targets))
|
||||
for _, t := range targets {
|
||||
ids = append(ids, t.DeviceID)
|
||||
}
|
||||
|
||||
switch in.Mode {
|
||||
case "", "archive":
|
||||
s.archiveDevices(w, r, p, in, ids, dropped)
|
||||
case "purge":
|
||||
s.purgeDevices(w, r, p, in, targets, ids, dropped)
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, "bad_mode",
|
||||
"невідомий спосіб видалення: "+in.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
// archiveDevices — колишня поведінка: хост зникає з переліків, зібране
|
||||
// лишається.
|
||||
func (s *Server) archiveDevices(w http.ResponseWriter, r *http.Request, p *Principal,
|
||||
in bulkDeviceBody, ids, dropped []string) {
|
||||
|
||||
deleted, names, err := s.store.BulkDeleteDevices(r.Context(), p.TenantID, p.Scope(), ids)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "масове видалення хостів", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{
|
||||
ActorUserID: p.UserID,
|
||||
ActorTokenID: p.TokenID,
|
||||
ActorIP: clientIP(r),
|
||||
Action: store.AuditActionDeviceBulkDelete,
|
||||
ObjectType: store.AuditObjectDevice,
|
||||
Meta: map[string]any{
|
||||
"mode": "archive",
|
||||
"devices": len(deleted),
|
||||
"device_ids": deleted,
|
||||
"names": names,
|
||||
"filter": in.Filter,
|
||||
},
|
||||
}); err != nil {
|
||||
s.log.Error("аудит масового видалення хостів", "err", err)
|
||||
}
|
||||
|
||||
s.log.Info("масове видалення хостів",
|
||||
"хостів", len(deleted), "користувач", p.Username, "user_id", p.UserID)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"mode": "archive",
|
||||
"deleted": len(deleted),
|
||||
"names": names,
|
||||
"dropped": dropped,
|
||||
})
|
||||
}
|
||||
|
||||
// purgeDevices — видалення назавжди.
|
||||
//
|
||||
// Додаткове право, і воно не з обережності. У цій системі вже ухвалено,
|
||||
// що масова форма не має ставати обхідним шляхом до чужого розділу: хто
|
||||
// не може змінити розклад збору конфігів одному хосту, не міняє його й
|
||||
// п'ятдесяти (див. handleBulkUpdateDevices). Тут те саме, лише сильніше:
|
||||
// видалення однієї версії конфігу вимагає ncm:delete — окремого права
|
||||
// саме тому, що архів конфігів це те, чим доводять, ЯК виглядало
|
||||
// залізо в конкретний день. Дозволити стерти весь архів хоста через
|
||||
// сторінку інвентарю означало б роздати ncm:delete всім, хто має
|
||||
// devices:write, і не сказати про це нікому.
|
||||
//
|
||||
// Хост без жодної версії такої вимоги не має: там нема чого захищати, а
|
||||
// зайве право означало б, що звичайний інженер не може прибрати за
|
||||
// собою тестовий хост.
|
||||
func (s *Server) purgeDevices(w http.ResponseWriter, r *http.Request, p *Principal,
|
||||
in bulkDeviceBody, targets []store.BulkDeviceTarget, ids, dropped []string) {
|
||||
|
||||
withConfigs := 0
|
||||
for _, t := range targets {
|
||||
withConfigs += t.Configs
|
||||
}
|
||||
if withConfigs > 0 && !requirePerm(w, p, deletePerm) {
|
||||
return
|
||||
}
|
||||
|
||||
res, err := s.store.PurgeDevices(r.Context(), p.TenantID, p.Scope(), ids)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "повне видалення хостів", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Аудит пише ІМЕНА Й АДРЕСИ, а не самі лише id.
|
||||
//
|
||||
// Після цієї дії за id не стоїть нічого: рядка немає, і жоден запит
|
||||
// його вже не розкриє. Журнал, у якому лежить
|
||||
// «8d1b1df8-fa6b-…видалено», відповідає на питання «чи хтось це
|
||||
// робив» і не відповідає на єдине, заради якого його читають, —
|
||||
// «що саме зникло».
|
||||
gone := make([]map[string]any, 0, len(res.Purged))
|
||||
names := make([]string, 0, len(res.Purged))
|
||||
for _, d := range res.Purged {
|
||||
names = append(names, d.Name)
|
||||
gone = append(gone, map[string]any{
|
||||
"id": d.DeviceID, "name": d.Name, "address": d.Address,
|
||||
"configs": d.Configs, "series": d.Series,
|
||||
"samples": d.Samples, "branches": d.Branches,
|
||||
})
|
||||
}
|
||||
// Окремий ключ дії, а не спільний із архівним видаленням.
|
||||
//
|
||||
// Досі обидва режими писались як inv.device.bulk_delete, а різниця
|
||||
// між «прибрано з переліку» й «немає більше ніде» лежала в
|
||||
// meta.mode. Для журналу це найгірше з можливих місць: фільтр за
|
||||
// дією їх не розрізняє, позначка «незворотна» стоїть однаково на
|
||||
// обох, а прочитати meta можна лише розгорнувши подію по одній.
|
||||
// Ключ, за яким шукають найстрашнішу дію в системі, має бути
|
||||
// власним.
|
||||
//
|
||||
// meta.mode лишається як був: за ним уже шукають, і прибирати поле
|
||||
// заради краси означало б зламати чужі збережені фільтри.
|
||||
if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{
|
||||
ActorUserID: p.UserID,
|
||||
ActorTokenID: p.TokenID,
|
||||
ActorIP: clientIP(r),
|
||||
Action: store.AuditActionDeviceBulkPurge,
|
||||
ObjectType: store.AuditObjectDevice,
|
||||
Meta: map[string]any{
|
||||
"mode": "purge",
|
||||
"devices": len(res.Purged),
|
||||
"hosts": gone,
|
||||
"names": names,
|
||||
"configs": res.Configs,
|
||||
"freed_bytes": res.FreedBytes,
|
||||
"series": res.Series,
|
||||
"samples": res.Samples,
|
||||
"failed": res.Failed,
|
||||
"filter": in.Filter,
|
||||
},
|
||||
}); err != nil {
|
||||
s.log.Error("аудит повного видалення хостів", "err", err)
|
||||
}
|
||||
|
||||
s.log.Info("повне видалення хостів",
|
||||
"хостів", len(res.Purged), "версій_конфігу", res.Configs,
|
||||
"рядів", res.Series, "вимірювань", res.Samples,
|
||||
"не_вдалося", len(res.Failed),
|
||||
"користувач", p.Username, "user_id", p.UserID)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"mode": "purge",
|
||||
"deleted": len(res.Purged),
|
||||
"names": names,
|
||||
"dropped": dropped,
|
||||
"purged": res,
|
||||
})
|
||||
}
|
||||
|
||||
// handleBulkRestoreDevices — повернути хости з архіву.
|
||||
//
|
||||
// Окрема ручка, а не третій режим bulk-delete. Ручка видалення описує
|
||||
// одну дію з двома ступенями суворості; повернення — дія протилежна, і
|
||||
// класти її під той самий шлях означало б, що клієнт, який помилився в
|
||||
// полі `mode`, робить не те, що збирався, у бік, який навіть не
|
||||
// обговорювався.
|
||||
//
|
||||
// Право те саме, що й у решти правок інвентарю (devices:write), і
|
||||
// окремого «restore» тут немає свідомо — з тієї ж причини, що й у
|
||||
// масової правки: повернення не відмикає нічого, чого людина з
|
||||
// devices:write не могла б зробити інакше. Зворотне теж правда й
|
||||
// важливіше: якби повернення вимагало більшого права, ніж архівування,
|
||||
// система дозволяла б прибрати хост і не дозволяла б виправити це тому
|
||||
// самому інженерові.
|
||||
//
|
||||
// Права на КОЖЕН хост окремо перевіряє добір: у набір потрапляє лише
|
||||
// те, що людині дозволено змінювати (Scope.Writable).
|
||||
func (s *Server) handleBulkRestoreDevices(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, "devices:write") {
|
||||
return
|
||||
}
|
||||
var in bulkDeviceBody
|
||||
if !decodeBody(w, r, &in) {
|
||||
return
|
||||
}
|
||||
// Режим не питаємо в клієнта: ця ручка адресована архіву завжди, а
|
||||
// добір без цього не побачив би жодної цілі.
|
||||
in.Mode = "restore"
|
||||
|
||||
targets, dropped, ok := s.resolveBulk(w, r, p, in)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if len(targets) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "no_targets",
|
||||
"жоден з обраних хостів більше не доступний на запис — перегляньте перелік ще раз")
|
||||
return
|
||||
}
|
||||
|
||||
ids := make([]string, 0, len(targets))
|
||||
for _, t := range targets {
|
||||
ids = append(ids, t.DeviceID)
|
||||
}
|
||||
|
||||
res, err := s.store.RestoreDevices(r.Context(), p.TenantID, p.Scope(), ids)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "повернення хостів з архіву", err)
|
||||
return
|
||||
}
|
||||
|
||||
names := make([]string, 0, len(res.Restored))
|
||||
// Окремо — ті, хто повернувся вимкненим або без прив'язки до зонда.
|
||||
// Це і є відповідь на питання «чому хост на місці, а даних немає»,
|
||||
// і в журналі вона потрібна не менше, ніж на екрані: через тиждень
|
||||
// про сам факт повернення вже ніхто не згадає.
|
||||
offline := []string{}
|
||||
unbound := []string{}
|
||||
for _, d := range res.Restored {
|
||||
names = append(names, d.Name)
|
||||
if !d.Enabled {
|
||||
offline = append(offline, d.Name)
|
||||
}
|
||||
if d.SelfAgentDropped {
|
||||
unbound = append(unbound, d.Name)
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{
|
||||
ActorUserID: p.UserID,
|
||||
ActorTokenID: p.TokenID,
|
||||
ActorIP: clientIP(r),
|
||||
Action: store.AuditActionDeviceBulkRestore,
|
||||
ObjectType: store.AuditObjectDevice,
|
||||
Meta: map[string]any{
|
||||
"devices": len(res.Restored),
|
||||
"device_ids": ids,
|
||||
"names": names,
|
||||
"checks": res.Checks,
|
||||
"left_disabled": offline,
|
||||
"unbound": unbound,
|
||||
"failed": res.Failed,
|
||||
},
|
||||
}); err != nil {
|
||||
s.log.Error("аудит повернення хостів з архіву", "err", err)
|
||||
}
|
||||
|
||||
s.log.Info("повернення хостів з архіву",
|
||||
"хостів", len(res.Restored), "перевірок", res.Checks,
|
||||
"не_вдалося", len(res.Failed),
|
||||
"користувач", p.Username, "user_id", p.UserID)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"restored": len(res.Restored),
|
||||
"names": names,
|
||||
"dropped": dropped,
|
||||
"result": res,
|
||||
})
|
||||
}
|
||||
130
server/internal/httpapi/downloads.go
Normal file
130
server/internal/httpapi/downloads.go
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/netpulse/netpulse/server/internal/auth"
|
||||
"github.com/netpulse/netpulse/server/internal/store"
|
||||
)
|
||||
|
||||
// Завантаження файлів звичайним посиланням.
|
||||
//
|
||||
// Уся решта API живе на Bearer-токені в заголовку. Заголовок уміє додати
|
||||
// лише fetch, а fetch кладе відповідь у пам'ять вкладки: для звіту на
|
||||
// десятки мегабайтів це означає зібрати весь файл у браузері, перш ніж
|
||||
// людина побачить діалог збереження, а в частині оточень (кіоски,
|
||||
// вбудовані webview, політики, що глушать blob:) збереження такого
|
||||
// об'єкта не спрацьовує взагалі.
|
||||
//
|
||||
// Тому файл тягне завантажувач браузера за звичайним посиланням, а право
|
||||
// доступу лежить у самому URL — короткоживучим квитком. Механіка й ціна
|
||||
// описані в міграції 0038; тут — рівно два обробники: видати квиток
|
||||
// (під звичайною автентифікацією й правом) і віддати за ним файл.
|
||||
//
|
||||
// Що квиток НЕ дає: сесії, права, доступу до будь-чого, крім одного
|
||||
// об'єкта в одному форматі. Він не поновлюється й через дві хвилини
|
||||
// мертвий.
|
||||
|
||||
// issueDownloadTicket видає квиток і повертає шлях до файлу.
|
||||
//
|
||||
// Шлях, а не повний URL: сервер за проксі не знає свого зовнішнього
|
||||
// імені, і вгадування дало б посилання, яке нікуди не веде. Той самий
|
||||
// висновок, що й у посилання для телевізора.
|
||||
func (s *Server) issueDownloadTicket(w http.ResponseWriter, r *http.Request, p *Principal,
|
||||
kind, objectID, format string) bool {
|
||||
|
||||
token, err := auth.NewToken("np_dl_")
|
||||
if err != nil {
|
||||
s.log.Error("токен завантаження", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
||||
return false
|
||||
}
|
||||
if err := s.store.CreateDownloadTicket(r.Context(), token, p.TenantID, p.UserID,
|
||||
kind, objectID, format); err != nil {
|
||||
s.writeStoreError(w, "посилання на завантаження", err)
|
||||
return false
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"path": "/api/v1/downloads/" + token,
|
||||
// Скільки посилання житиме. Клієнт має піти за ним одразу, а не
|
||||
// покласти в змінну «на потім»; секунди тут — щоб це було видно
|
||||
// без читання коду сервера.
|
||||
"expires_in": int(store.DownloadTicketTTL.Seconds()),
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
// handleDownload віддає файл за квитком.
|
||||
//
|
||||
// Без автентифікації — її замінює токен у шляху. Тому перше, що тут
|
||||
// відбувається, — звірка квитка, і лише потім будь-яке читання даних.
|
||||
func (s *Server) handleDownload(w http.ResponseWriter, r *http.Request) {
|
||||
token := strings.TrimSpace(r.PathValue("token"))
|
||||
|
||||
t, err := s.store.ResolveDownloadTicket(r.Context(), token)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
// Однакова відповідь на неіснуючий і на протухлий квиток:
|
||||
// різниця між ними — підказка тому, хто перебирає.
|
||||
writeError(w, http.StatusNotFound, "not_found", "посилання недійсне або застаріле")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.log.Error("квиток завантаження", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
||||
return
|
||||
}
|
||||
|
||||
switch t.Kind {
|
||||
case store.DownloadKindCommandReport:
|
||||
s.streamCommandReport(w, r, t)
|
||||
default:
|
||||
// Квитка з невідомим видом бути не може — його заводить цей же
|
||||
// код. Якщо він з'явився, це наш недогляд, а не помилка того, хто
|
||||
// прийшов за файлом.
|
||||
s.log.Error("невідомий вид квитка", "kind", t.Kind)
|
||||
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
||||
}
|
||||
}
|
||||
|
||||
// streamCommandReport віддає звіт про прогін команд.
|
||||
//
|
||||
// Заголовки виставляються в колбеку, який store викликає рівно тоді, коли
|
||||
// прогін знайдено, а перший байт тіла ще не пішов. Інакше 404 на
|
||||
// видалений прогін довелось би віддавати всередині вже початого файлу.
|
||||
func (s *Server) streamCommandReport(w http.ResponseWriter, r *http.Request, t store.DownloadTicket) {
|
||||
err := s.store.StreamCommandRunReport(r.Context(), t.TenantID, t.ObjectID, t.Format,
|
||||
func(run store.CommandRun) (io.Writer, error) {
|
||||
name := store.ReportFileName(run, t.Format)
|
||||
ct := "text/plain; charset=utf-8"
|
||||
if t.Format == store.ReportFormatCSV {
|
||||
ct = "text/csv; charset=utf-8"
|
||||
}
|
||||
w.Header().Set("Content-Type", ct)
|
||||
// Без Content-Disposition браузер покаже текст у вкладці, а
|
||||
// людина прийшла по файл.
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
|
||||
// Довжини не знаємо й знати не можемо: звіт формується на
|
||||
// льоту. no-store — бо посилання одноразове за змістом, і
|
||||
// кешований звіт пережив би квиток.
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return w, nil
|
||||
})
|
||||
|
||||
switch {
|
||||
case err == nil:
|
||||
return
|
||||
case errors.Is(err, store.ErrNotFound):
|
||||
writeError(w, http.StatusNotFound, "not_found", "прогін уже видалено")
|
||||
case errors.Is(err, store.ErrInvalid):
|
||||
writeError(w, http.StatusBadRequest, "invalid", err.Error())
|
||||
default:
|
||||
// Сюди потрапляє й обрив посеред потоку. Відповідь уже пішла, і
|
||||
// змінити її статус не можна — слід про обрив дописано в сам
|
||||
// файл, а тут лишається журнал.
|
||||
s.log.Error("звіт про прогін команд", "run", t.ObjectID, "err", err)
|
||||
}
|
||||
}
|
||||
329
server/internal/httpapi/git_mirror.go
Normal file
329
server/internal/httpapi/git_mirror.go
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/netpulse/netpulse/server/internal/gitstore"
|
||||
"github.com/netpulse/netpulse/server/internal/store"
|
||||
)
|
||||
|
||||
// Дзеркалення архіву конфігів на зовнішній Git.
|
||||
//
|
||||
// Чотири дії, і межа між ними проходить по тому, ХТО чекає.
|
||||
//
|
||||
// Читання стану й збереження налаштувань — миттєві, їх робить цей
|
||||
// процес. Перевірка з'єднання теж: вона коротка (одне ls-remote) і саме
|
||||
// заради неї людина зараз сидить перед екраном.
|
||||
//
|
||||
// А от сам push цей процес не робить НІКОЛИ. «Надіслати зараз»
|
||||
// прибирає витримку повтору й позначку останнього стану — і фоновий
|
||||
// такт колектора підхоплює це протягом хвилини. Причина та сама, що й
|
||||
// у прибиральника версій: REST — шлях запитів людини, його масштабують
|
||||
// копіями, а перший повний push інсталяції з річною історією — це
|
||||
// десятки хвилин і гігабайти. Тримати таке в обробнику HTTP означало б
|
||||
// або обірваний запит, або зайнятий надовго процес, який зараз комусь
|
||||
// малює сторінку.
|
||||
|
||||
// handleGetMirror — стан дзеркала.
|
||||
//
|
||||
// Право ncm:read, а не ncm:mirror, і це навмисно: помітити, що копія
|
||||
// тиждень не їде, має могти той, хто щодня дивиться на конфіги, а не
|
||||
// лише власник кабінету. Налаштувати — інша річ, і право там інше.
|
||||
func (s *Server) handleGetMirror(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, "ncm:read") {
|
||||
return
|
||||
}
|
||||
m, err := s.store.GetMirror(r.Context(), p.TenantID)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "дзеркало архіву конфігів", err)
|
||||
return
|
||||
}
|
||||
// Публічна половина ключа їде разом зі станом: людина заводить його
|
||||
// в репозиторії не тієї ж хвилини, коли створює, і повертатись сюди
|
||||
// по неї має бути куди. Помилка тут не ламає сторінку — без ключа
|
||||
// вона просто не покаже блока з ним.
|
||||
if pub, err := s.store.MirrorPublicKey(r.Context(), p.TenantID, s.keyring); err == nil {
|
||||
m.PublicKey = pub
|
||||
}
|
||||
writeJSON(w, http.StatusOK, m)
|
||||
}
|
||||
|
||||
// handleSetMirror зберігає налаштування.
|
||||
func (s *Server) handleSetMirror(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, "ncm:mirror") {
|
||||
return
|
||||
}
|
||||
var in store.MirrorInput
|
||||
if !decodeBody(w, r, &in) {
|
||||
return
|
||||
}
|
||||
if s.keyring == nil && strings.TrimSpace(in.Secret) != "" {
|
||||
writeError(w, http.StatusServiceUnavailable, "no_keyring",
|
||||
"сервер запущено без ключа шифрування — зберегти токен ніде")
|
||||
return
|
||||
}
|
||||
|
||||
// Увімкнути дзеркало без адреси неможливо: інакше на сторінці
|
||||
// стояло б «увімкнено», а такт мовчки нічого не робив би.
|
||||
if in.Enabled && strings.TrimSpace(in.URL) == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request",
|
||||
"щоб увімкнути дзеркало, потрібна адреса віддаленого репозиторію")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.SaveMirror(r.Context(), p.TenantID, in, s.keyring); err != nil {
|
||||
if isMirrorInputError(err) {
|
||||
writeError(w, http.StatusBadRequest, "bad_remote", err.Error())
|
||||
return
|
||||
}
|
||||
s.writeStoreError(w, "збереження дзеркала", err)
|
||||
return
|
||||
}
|
||||
|
||||
m, err := s.store.GetMirror(r.Context(), p.TenantID)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "дзеркало архіву конфігів", err)
|
||||
return
|
||||
}
|
||||
|
||||
// У журналі — намір і адреса, ніколи токен. RedactURL прибирає
|
||||
// логін із адреси на випадок, коли людина вставила рядок виду
|
||||
// https://токен@forgejo/…, скопійований із чужої інструкції.
|
||||
if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{
|
||||
ActorUserID: p.UserID,
|
||||
ActorTokenID: p.TokenID,
|
||||
ActorIP: clientIP(r),
|
||||
Action: store.AuditActionMirrorUpdate,
|
||||
ObjectType: store.AuditObjectMirror,
|
||||
ObjectID: m.RepoID,
|
||||
Meta: map[string]any{
|
||||
"enabled": m.Enabled,
|
||||
"url": gitstore.RedactURL(m.URL),
|
||||
"auth": m.Auth,
|
||||
"has_secret": m.HasSecret,
|
||||
"host_key_fp": m.HostKeyFP,
|
||||
},
|
||||
}); err != nil {
|
||||
s.log.Error("аудит дзеркала", "err", err)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, m)
|
||||
}
|
||||
|
||||
// mirrorTestResult — відповідь на «перевірити з'єднання».
|
||||
//
|
||||
// Завжди 200, навіть коли не вийшло. Помилка тут — це відповідь на
|
||||
// питання, а не збій запиту: людина натиснула саме для того, щоб
|
||||
// дізнатись текст. HTTP-код 502 показав би їй «щось пішло не так»
|
||||
// замість «токен не має права запису».
|
||||
type mirrorTestResult struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Refs int `json:"refs"`
|
||||
Empty bool `json:"empty"`
|
||||
|
||||
HostKey string `json:"host_key,omitempty"`
|
||||
HostKeyFP string `json:"host_key_fp,omitempty"`
|
||||
HostKeyKnown bool `json:"host_key_known"`
|
||||
// NeedsPin — сервер показав ключ, якого ми ще не знаємо. Далі
|
||||
// вирішує людина: закріпити цей чи зупинитись.
|
||||
NeedsPin bool `json:"needs_pin"`
|
||||
}
|
||||
|
||||
// handleTestMirror перевіряє з'єднання ДО збереження.
|
||||
//
|
||||
// Окрема дія існує рівно тому, що альтернатива — дізнатись про
|
||||
// помилку через добу з журналу. Токен береться з тіла запиту (людина
|
||||
// щойно його ввела) або зі збереженого, якщо поле лишили порожнім.
|
||||
func (s *Server) handleTestMirror(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, "ncm:mirror") {
|
||||
return
|
||||
}
|
||||
var in store.MirrorInput
|
||||
if !decodeBody(w, r, &in) {
|
||||
return
|
||||
}
|
||||
|
||||
remote := gitstore.Remote{
|
||||
URL: strings.TrimSpace(in.URL),
|
||||
User: strings.TrimSpace(in.User),
|
||||
Secret: in.Secret,
|
||||
HostKey: strings.TrimSpace(in.HostKey),
|
||||
// Півхвилини: перевірка має або відповісти, поки людина
|
||||
// дивиться на екран, або чесно сказати, що сервер мовчить.
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
// Порожні поля добираються зі збереженого: типовий сценарій —
|
||||
// «перевір те, що вже налаштовано», без повторного введення токена,
|
||||
// якого форма й не показує.
|
||||
if remote.Secret == "" || remote.URL == "" {
|
||||
saved, err := s.store.MirrorRemote(r.Context(), p.TenantID, s.keyring)
|
||||
if err == nil {
|
||||
if remote.URL == "" {
|
||||
remote.URL = saved.URL
|
||||
}
|
||||
if remote.Secret == "" {
|
||||
remote.Secret = saved.Secret
|
||||
}
|
||||
if remote.User == "" {
|
||||
remote.User = saved.User
|
||||
}
|
||||
if remote.HostKey == "" {
|
||||
remote.HostKey = saved.HostKey
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stat, err := gitstore.Probe(r.Context(), remote)
|
||||
res := mirrorTestResult{
|
||||
Refs: stat.Refs,
|
||||
Empty: stat.Empty,
|
||||
HostKey: stat.HostKey,
|
||||
HostKeyKnown: stat.HostKeyKnown,
|
||||
}
|
||||
if stat.HostKey != "" {
|
||||
res.HostKeyFP = gitstore.Fingerprint(stat.HostKey)
|
||||
res.NeedsPin = !stat.HostKeyKnown
|
||||
}
|
||||
switch {
|
||||
case err != nil:
|
||||
// Друга сітка поверх тієї, що вже стоїть у gitstore: текст
|
||||
// звідти проходить через Scrub, але цей рядок їде до людини й
|
||||
// у браузерну консоль, і платити за припущення «там уже
|
||||
// почищено» довелося б токеном.
|
||||
res.Error = gitstore.Scrub(err, remote.Secret).Error()
|
||||
case res.NeedsPin:
|
||||
res.Error = "сервер показав ключ, якого ми ще не знаємо. Звірте відбиток і збережіть — далі кожен обмін перевірятиме саме його"
|
||||
default:
|
||||
res.OK = true
|
||||
}
|
||||
writeJSON(w, http.StatusOK, res)
|
||||
}
|
||||
|
||||
// handlePushMirror просить надіслати негайно.
|
||||
//
|
||||
// Сам push тут не робиться (див. коментар угорі файлу): знімається
|
||||
// витримка повтору й відбиток останнього надісланого стану, і
|
||||
// найближчий такт колектора йде в мережу, навіть якщо локально нічого
|
||||
// не змінилось. Відповідь — оновлений стан; результат людина побачить
|
||||
// на тій самій сторінці протягом хвилини.
|
||||
func (s *Server) handlePushMirror(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, "ncm:mirror") {
|
||||
return
|
||||
}
|
||||
// force — окреме, свідоме «перезаписати віддалене дерево». Потрібне
|
||||
// рівно в одному випадку: локальний репозиторій відновили з бази
|
||||
// (netpulse-gitsync), коміти склались наново, і звичайний push
|
||||
// чесно відмовляється затирати те, що вже поїхало. Без цієї дії
|
||||
// вихід із такого стану був би лише через ручний git на сервері.
|
||||
var in struct {
|
||||
Force bool `json:"force"`
|
||||
}
|
||||
if r.ContentLength > 0 && !decodeBody(w, r, &in) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.RequestMirrorPush(r.Context(), p.TenantID, in.Force); err != nil {
|
||||
s.writeStoreError(w, "дзеркало архіву конфігів", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{
|
||||
ActorUserID: p.UserID,
|
||||
ActorTokenID: p.TokenID,
|
||||
ActorIP: clientIP(r),
|
||||
Action: store.AuditActionMirrorPush,
|
||||
ObjectType: store.AuditObjectMirror,
|
||||
Meta: map[string]any{"force": in.Force},
|
||||
}); err != nil {
|
||||
s.log.Error("аудит дзеркала", "err", err)
|
||||
}
|
||||
|
||||
m, err := s.store.GetMirror(r.Context(), p.TenantID)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "дзеркало архіву конфігів", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, m)
|
||||
}
|
||||
|
||||
// handleForgetMirrorSecret прибирає збережений токен або ключ.
|
||||
func (s *Server) handleForgetMirrorSecret(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, "ncm:mirror") {
|
||||
return
|
||||
}
|
||||
if err := s.store.ForgetMirrorSecret(r.Context(), p.TenantID); err != nil {
|
||||
s.writeStoreError(w, "секрет дзеркала", err)
|
||||
return
|
||||
}
|
||||
if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{
|
||||
ActorUserID: p.UserID,
|
||||
ActorTokenID: p.TokenID,
|
||||
ActorIP: clientIP(r),
|
||||
Action: store.AuditActionMirrorUpdate,
|
||||
ObjectType: store.AuditObjectMirror,
|
||||
Meta: map[string]any{"enabled": false, "has_secret": false},
|
||||
}); err != nil {
|
||||
s.log.Error("аудит дзеркала", "err", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// isMirrorInputError — чи це помилка введення, а не збій сервера.
|
||||
// Від відповіді залежить код: 400 просить виправити поле, 500 —
|
||||
// звернутись до адміністратора.
|
||||
func isMirrorInputError(err error) bool {
|
||||
for _, e := range []error{
|
||||
gitstore.ErrNoRemoteURL, gitstore.ErrBadRemoteURL,
|
||||
gitstore.ErrUnsupportedTr, gitstore.ErrPlainHTTP,
|
||||
gitstore.ErrHostKeyUnset,
|
||||
} {
|
||||
if errors.Is(err, e) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// handleMirrorDeployKey створює ключ розгортання й показує публічну
|
||||
// половину.
|
||||
//
|
||||
// POST, хоч і виглядає як «дай ключ»: він створюється й замінює
|
||||
// попередній. Друге натискання — це новий ключ, і старий одразу
|
||||
// перестає працювати; сказати про це має інтерфейс, а не з'ясувати
|
||||
// нічний бекап.
|
||||
func (s *Server) handleMirrorDeployKey(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, "ncm:mirror") {
|
||||
return
|
||||
}
|
||||
// Адреса з форми: людина могла щойно замінити https на SSH і ще не
|
||||
// зберегти. Без неї ключ ліг би з типом старої адреси.
|
||||
var in struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&in)
|
||||
|
||||
pub, err := s.store.GenerateMirrorDeployKey(r.Context(), p.TenantID, in.URL, s.keyring)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "ключ розгортання", err)
|
||||
return
|
||||
}
|
||||
if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{
|
||||
ActorUserID: p.UserID,
|
||||
ActorTokenID: p.TokenID,
|
||||
ActorIP: clientIP(r),
|
||||
Action: store.AuditActionMirrorUpdate,
|
||||
ObjectType: store.AuditObjectMirror,
|
||||
// Публічну половину — можна: вона й так їде в чужий
|
||||
// репозиторій. Приватна в аудит не потрапляє ніколи.
|
||||
Meta: map[string]any{"deploy_key": true, "public_key": pub},
|
||||
}); err != nil {
|
||||
s.log.Error("аудит дзеркала", "err", err)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"public_key": pub})
|
||||
}
|
||||
|
|
@ -104,6 +104,58 @@ func (s *Server) handleBuildMap(w http.ResponseWriter, r *http.Request, p *Princ
|
|||
writeJSON(w, http.StatusOK, st)
|
||||
}
|
||||
|
||||
// handleMapInterfaces — порти хостів мапи для ручного зіставлення.
|
||||
//
|
||||
// Право читання мапи, а не хостів: перелік імен портів пристроїв, які
|
||||
// вже намальовані на цій мапі, нічого не додає до того, що людина
|
||||
// й так бачить.
|
||||
func (s *Server) handleMapInterfaces(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !p.Can("maps:read") {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "немає права maps:read")
|
||||
return
|
||||
}
|
||||
|
||||
mapID := r.PathValue("id")
|
||||
if level, err := s.store.MapAccess(r.Context(), p.TenantID, p.UserID, mapID); err == nil &&
|
||||
level == "deny" {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "немає доступу до цієї мапи")
|
||||
return
|
||||
}
|
||||
|
||||
ifs, err := s.store.MapInterfaces(r.Context(), p.TenantID, mapID)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "порти мапи", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"interfaces": ifs})
|
||||
}
|
||||
|
||||
// handleMapDiscover просить зібрати топологію з хостів цієї мапи.
|
||||
//
|
||||
// devices:write, а не maps:write: дія заводить хостам перевірку й
|
||||
// змушує зонд їх опитати. Це зміна опитування, і той, кому дозволили
|
||||
// лише малювати схеми, робити її не має.
|
||||
func (s *Server) handleMapDiscover(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !p.Can("devices:write") {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "немає права devices:write")
|
||||
return
|
||||
}
|
||||
|
||||
mapID := r.PathValue("id")
|
||||
if level, err := s.store.MapAccess(r.Context(), p.TenantID, p.UserID, mapID); err == nil &&
|
||||
level == "deny" {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "немає доступу до цієї мапи")
|
||||
return
|
||||
}
|
||||
|
||||
res, err := s.store.RequestMapDiscovery(r.Context(), p.TenantID, mapID, p.Scope())
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "збір топології", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, res)
|
||||
}
|
||||
|
||||
// handleUndoMap відкочує полотно до попереднього знімка.
|
||||
func (s *Server) handleUndoMap(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !p.Can("maps:write") {
|
||||
|
|
|
|||
44
server/internal/httpapi/metrics_devices.go
Normal file
44
server/internal/httpapi/metrics_devices.go
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/netpulse/netpulse/server/internal/store"
|
||||
)
|
||||
|
||||
// handleMetricDevices — перелік хостів сторінки «Метрики» за фільтром.
|
||||
//
|
||||
// POST, хоч і нічого не змінює, — з тієї самої причини, що в доборі
|
||||
// цілей для команд і в переліку хостів «Конфігів»: фільтр це структура
|
||||
// з кількома списками, і складати її в рядок запиту означало б винайти
|
||||
// власне кодування масивів заради того, щоб виглядати як GET.
|
||||
//
|
||||
// Право devices:read — те саме, що вже потрібне обом ручкам метрик
|
||||
// (перелік рядів і точки графіка). Той, кому дозволено бачити графіки,
|
||||
// має право й відібрати хост, чиї графіки дивиться.
|
||||
func (s *Server) handleMetricDevices(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, "devices:read") {
|
||||
return
|
||||
}
|
||||
|
||||
var f store.MetricsFilter
|
||||
if err := json.NewDecoder(r.Body).Decode(&f); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_json", "не вдалося прочитати фільтр")
|
||||
return
|
||||
}
|
||||
|
||||
list, err := s.store.ListMetricDevices(r.Context(), p.TenantID, p.Scope(), f)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "добір хостів за фільтром", err)
|
||||
return
|
||||
}
|
||||
if list == nil {
|
||||
list = []store.MetricDeviceRow{}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"devices": list,
|
||||
"total": len(list),
|
||||
})
|
||||
}
|
||||
336
server/internal/httpapi/ncm_commands.go
Normal file
336
server/internal/httpapi/ncm_commands.go
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/netpulse/netpulse/server/internal/store"
|
||||
)
|
||||
|
||||
// Масове виконання команд по фільтру.
|
||||
//
|
||||
// Найнебезпечніша дія в системі: одна команда на двісті пристроїв, без
|
||||
// попереднього перегляду наслідків і без відкату. Тому запобіжники тут
|
||||
// не «на майбутнє», а частина функції:
|
||||
//
|
||||
// - окреме право ncm:exec (не ncm:write і тим паче не devices:read);
|
||||
// - обмеження видимості: у прогін потрапляє лише те, що людині
|
||||
// дозволено ЗМІНЮВАТИ, а не лише бачити;
|
||||
// - перелік хостів повертається до запуску, і запуск іде по перетину
|
||||
// фільтра з тим переліком, який людина бачила;
|
||||
// - кожен прогін і кожна зупинка — рядок у журналі аудиту.
|
||||
//
|
||||
// Право перевіряється однакове на читання й на запуск. Вивід команди
|
||||
// показує стан заліза детальніше за будь-яку іншу сторінку, і давати
|
||||
// його всім, хто має ncm:read, означало б зробити обхідний шлях до
|
||||
// того, заради чого й заведено окреме право.
|
||||
|
||||
const execPerm = "ncm:exec"
|
||||
|
||||
// handlePreviewCommandTargets — «кого зачепить цей фільтр».
|
||||
//
|
||||
// POST, хоч і нічого не змінює: фільтр — це структура з кількома
|
||||
// списками, і складати її в рядок запиту означало б винайти власне
|
||||
// кодування масивів заради того, щоб виглядати як GET.
|
||||
func (s *Server) handlePreviewCommandTargets(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, execPerm) {
|
||||
return
|
||||
}
|
||||
|
||||
var f store.DeviceFilter
|
||||
if err := json.NewDecoder(r.Body).Decode(&f); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_json", "не вдалося прочитати фільтр")
|
||||
return
|
||||
}
|
||||
|
||||
list, err := s.store.ResolveCommandTargets(r.Context(), p.TenantID, p.Scope(), f)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "добір хостів за фільтром", err)
|
||||
return
|
||||
}
|
||||
if list == nil {
|
||||
list = []store.CommandCandidate{}
|
||||
}
|
||||
|
||||
// Ліміт повідомляється разом із переліком: людина має побачити, що
|
||||
// впирається в стелю, ще у вікні підтвердження, а не отримати
|
||||
// відмову після натискання.
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"devices": list,
|
||||
"total": len(list),
|
||||
"max": store.MaxRunDevices,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleListCommandRuns(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, execPerm) {
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
runs, err := s.store.ListCommandRuns(r.Context(), p.TenantID, limit)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "перелік прогонів", err)
|
||||
return
|
||||
}
|
||||
if runs == nil {
|
||||
runs = []store.CommandRun{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"runs": runs})
|
||||
}
|
||||
|
||||
func (s *Server) handleGetCommandRun(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, execPerm) {
|
||||
return
|
||||
}
|
||||
// Стенограма — за окремим проханням: сторінка перечитує прогін раз
|
||||
// на дві секунди, поки він живий, а стенограма сесії до великого
|
||||
// шасі важить сотні кілобайт на хост.
|
||||
withTranscript := r.URL.Query().Get("transcript") == "1"
|
||||
|
||||
run, err := s.store.GetCommandRun(r.Context(), p.TenantID, r.PathValue("id"), withTranscript)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "прогін команд", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, run)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateCommandRun(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, execPerm) {
|
||||
return
|
||||
}
|
||||
|
||||
var in store.CommandRunInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_json", "не вдалося прочитати запит")
|
||||
return
|
||||
}
|
||||
|
||||
// Перелік звіряється з фільтром ще раз, уже на сервері.
|
||||
//
|
||||
// Не паранойя, а два різні захисти в одному місці. По-перше, тіло
|
||||
// запиту складає клієнт, і без звірки сюди можна було б покласти
|
||||
// будь-який ідентифікатор — зокрема хост із чужої філії, який
|
||||
// фільтр ніколи б не повернув. По-друге, між переглядом і
|
||||
// натисканням минає час: хост могли вимкнути, перевести в іншу
|
||||
// групу або завести новий, що теж підпадає під фільтр. Перетин
|
||||
// означає рівно те, що обіцяє підтвердження, — «виконати на тому,
|
||||
// що я щойно бачив, і ні на чому більше».
|
||||
matched, err := s.store.ResolveCommandTargets(r.Context(), p.TenantID, p.Scope(), in.Filter)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "добір хостів за фільтром", err)
|
||||
return
|
||||
}
|
||||
allowed := make(map[string]store.CommandCandidate, len(matched))
|
||||
for _, c := range matched {
|
||||
allowed[c.DeviceID] = c
|
||||
}
|
||||
|
||||
var (
|
||||
final []string
|
||||
names []string
|
||||
dropped []string
|
||||
)
|
||||
for _, id := range in.DeviceIDs {
|
||||
c, ok := allowed[id]
|
||||
if !ok {
|
||||
dropped = append(dropped, id)
|
||||
continue
|
||||
}
|
||||
final = append(final, id)
|
||||
names = append(names, c.Name)
|
||||
}
|
||||
if len(final) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "no_targets",
|
||||
"жоден з обраних хостів більше не підпадає під фільтр — перегляньте перелік ще раз")
|
||||
return
|
||||
}
|
||||
in.DeviceIDs = final
|
||||
|
||||
run, err := s.store.CreateCommandRun(r.Context(), p.TenantID, p.UserID, in)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "створення прогону команд", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Аудит пишеться після успіху й не блокує відповідь помилкою: якщо
|
||||
// журнал не прийняв рядок, прогін уже існує, і приховувати його від
|
||||
// людини було б гірше, ніж лишити слід лише в логах сервера.
|
||||
if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{
|
||||
ActorUserID: p.UserID,
|
||||
ActorTokenID: p.TokenID,
|
||||
ActorIP: clientIP(r),
|
||||
Action: store.AuditActionCommandRunCreate,
|
||||
ObjectType: store.AuditObjectCommandRun,
|
||||
ObjectID: run.ID,
|
||||
Meta: map[string]any{
|
||||
"commands": run.Commands,
|
||||
"devices": len(final),
|
||||
"device_ids": final,
|
||||
"names": names,
|
||||
"filter": in.Filter,
|
||||
"concurrency": run.Concurrency,
|
||||
"timeout_sec": run.TimeoutSec,
|
||||
},
|
||||
}); err != nil {
|
||||
s.log.Error("аудит прогону команд", "run", run.ID, "err", err)
|
||||
}
|
||||
|
||||
s.log.Info("масове виконання команд запущено",
|
||||
"run", run.ID, "хостів", len(final), "команд", len(run.Commands),
|
||||
"користувач", p.Username, "user_id", p.UserID)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"run": run,
|
||||
// Хости, що відпали між переглядом і запуском. Мовчати про них
|
||||
// не можна: людина підтверджувала перелік, і різницю має
|
||||
// побачити вона, а не наступний, хто відкриє журнал.
|
||||
"dropped": dropped,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleCancelCommandRun(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, execPerm) {
|
||||
return
|
||||
}
|
||||
runID := r.PathValue("id")
|
||||
|
||||
stopped, err := s.store.CancelCommandRun(r.Context(), p.TenantID, runID, p.UserID)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "зупинка прогону", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{
|
||||
ActorUserID: p.UserID,
|
||||
ActorTokenID: p.TokenID,
|
||||
ActorIP: clientIP(r),
|
||||
Action: store.AuditActionCommandRunCancel,
|
||||
ObjectType: store.AuditObjectCommandRun,
|
||||
ObjectID: runID,
|
||||
Meta: map[string]any{"stopped": stopped},
|
||||
}); err != nil {
|
||||
s.log.Error("аудит зупинки прогону", "run", runID, "err", err)
|
||||
}
|
||||
|
||||
s.log.Info("прогін команд зупинено",
|
||||
"run", runID, "не_почато", stopped, "користувач", p.Username)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"stopped": stopped})
|
||||
}
|
||||
|
||||
// handleDeleteCommandRun прибирає прогін разом із виводом.
|
||||
//
|
||||
// Право — те саме ncm:exec, і поруч тепер є спокуслива альтернатива:
|
||||
// міграція 0037 завела ncm:delete для видалення версій конфігів. Брати
|
||||
// його сюди було б помилкою, і різниця тут не в масштабі, а в тому, чи
|
||||
// можна знищене відтворити.
|
||||
//
|
||||
// Версія конфігу — єдиний слід того, як виглядало залізо в конкретний
|
||||
// день; стерти її означає стерти доказ, і другого такого дня не буде.
|
||||
// Вивід прогону відтворюється повторним запуском тієї самої команди —
|
||||
// причому тим самим, хто натискає «видалити»: побачити прогін узагалі
|
||||
// може лише власник ncm:exec. Вимагати для видалення право, без якого
|
||||
// цих даних не видно й не створити, — це не захист, а зайвий прапорець,
|
||||
// який хтось однаково видасть разом із першим.
|
||||
//
|
||||
// Захищає тут не кнопка, а журнал. Команди, перелік хостів, автор і час
|
||||
// лежать у core.audit_log із моменту запуску, звідси не прибираються, і
|
||||
// саме видалення теж лягає туди — разом зі зведенням, що саме зникло.
|
||||
func (s *Server) handleDeleteCommandRun(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, execPerm) {
|
||||
return
|
||||
}
|
||||
runID := r.PathValue("id")
|
||||
|
||||
run, err := s.store.DeleteCommandRun(r.Context(), p.TenantID, runID)
|
||||
if errors.Is(err, store.ErrRunActive) {
|
||||
// 409, а не 400: із запитом усе гаразд, це стан системи, який
|
||||
// сам мине. Текст пояснює, чого чекати, — див. store.
|
||||
writeError(w, http.StatusConflict, "run_active", err.Error())
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "видалення прогону", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{
|
||||
ActorUserID: p.UserID,
|
||||
ActorTokenID: p.TokenID,
|
||||
ActorIP: clientIP(r),
|
||||
Action: store.AuditActionCommandRunDelete,
|
||||
ObjectType: store.AuditObjectCommandRun,
|
||||
ObjectID: runID,
|
||||
Meta: map[string]any{
|
||||
"commands": run.Commands,
|
||||
"devices": run.Total,
|
||||
"counts": run.Counts,
|
||||
"status": run.Status,
|
||||
"created_by": run.CreatedBy,
|
||||
"created_at": run.CreatedAt,
|
||||
},
|
||||
}); err != nil {
|
||||
s.log.Error("аудит видалення прогону", "run", runID, "err", err)
|
||||
}
|
||||
|
||||
s.log.Info("прогін команд видалено",
|
||||
"run", runID, "хостів", run.Total, "користувач", p.Username)
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleCommandRunReportLink видає посилання на файл звіту.
|
||||
//
|
||||
// Окремий крок, а не одразу файл, бо файл тягне браузер звичайним
|
||||
// посиланням, без заголовка Authorization. Чому саме так — у
|
||||
// downloads.go; тут важливо, що право перевіряється саме на цьому кроці,
|
||||
// під нормальною автентифікацією, і квиток живе хвилини.
|
||||
func (s *Server) handleCommandRunReportLink(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, execPerm) {
|
||||
return
|
||||
}
|
||||
runID := r.PathValue("id")
|
||||
|
||||
var in struct {
|
||||
Format string `json:"format"`
|
||||
}
|
||||
// Порожнє тіло — не помилка: типовий формат читає людина.
|
||||
_ = json.NewDecoder(r.Body).Decode(&in)
|
||||
if in.Format == "" {
|
||||
in.Format = store.ReportFormatTxt
|
||||
}
|
||||
if in.Format != store.ReportFormatTxt && in.Format != store.ReportFormatCSV {
|
||||
writeError(w, http.StatusBadRequest, "bad_format", "формат звіту — txt або csv")
|
||||
return
|
||||
}
|
||||
|
||||
// Прогін звіряється тут, а не при завантаженні: посилання, яке
|
||||
// віддає 404, людина побачить уже у вкладці завантажень, де пояснити
|
||||
// їй нічого не можна.
|
||||
run, err := s.store.CommandRunHead(r.Context(), p.TenantID, runID)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "прогін команд", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Аудит на видачу, а не на завантаження: за квитком файл забирає
|
||||
// браузер, і особи в тому запиті вже немає. Вивід команд показує стан
|
||||
// заліза детальніше за будь-яку сторінку — те, що він поїхав назовні
|
||||
// файлом, має лишити слід.
|
||||
if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{
|
||||
ActorUserID: p.UserID,
|
||||
ActorTokenID: p.TokenID,
|
||||
ActorIP: clientIP(r),
|
||||
Action: store.AuditActionCommandRunReport,
|
||||
ObjectType: store.AuditObjectCommandRun,
|
||||
ObjectID: runID,
|
||||
Meta: map[string]any{"format": in.Format, "devices": run.Total},
|
||||
}); err != nil {
|
||||
s.log.Error("аудит звіту про прогін", "run", runID, "err", err)
|
||||
}
|
||||
|
||||
s.issueDownloadTicket(w, r, p, store.DownloadKindCommandReport, runID, in.Format)
|
||||
}
|
||||
74
server/internal/httpapi/ncm_config_devices.go
Normal file
74
server/internal/httpapi/ncm_config_devices.go
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/netpulse/netpulse/server/internal/store"
|
||||
)
|
||||
|
||||
// handleConfigDevices — перелік хостів сторінки «Конфіги» за фільтром.
|
||||
//
|
||||
// POST, хоч і нічого не змінює — з тієї самої причини, що й у доборі
|
||||
// цілей для команд: фільтр це структура з кількома списками, і складати
|
||||
// її в рядок запиту означало б винайти власне кодування масивів заради
|
||||
// того, щоб виглядати як GET.
|
||||
//
|
||||
// Право ncm:read, а не ncm:write: людина дивиться архів конфігів, а не
|
||||
// чіпає розклад. Той, хто має право бачити сторінку, має право й
|
||||
// відібрати на ній потрібне.
|
||||
func (s *Server) handleConfigDevices(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, "ncm:read") {
|
||||
return
|
||||
}
|
||||
|
||||
var f store.ConfigFilter
|
||||
if err := json.NewDecoder(r.Body).Decode(&f); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_json", "не вдалося прочитати фільтр")
|
||||
return
|
||||
}
|
||||
|
||||
scope := p.Scope()
|
||||
list, err := s.store.ListConfigDevices(r.Context(), p.TenantID, scope, f)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "добір хостів за фільтром", err)
|
||||
return
|
||||
}
|
||||
if list == nil {
|
||||
list = []store.ConfigDeviceRow{}
|
||||
}
|
||||
|
||||
// Перелік профілів їде разом із хостами, а не окремим запитом.
|
||||
//
|
||||
// Він потрібен рівно там, де показується фільтр, і рахується з тих
|
||||
// самих меж видимості. Другий похід по мережу заради півдесятка
|
||||
// рядків подвоїв би затримку кожного натискання в полі пошуку.
|
||||
profiles, err := s.store.ConfigProfilesInUse(r.Context(), p.TenantID, scope)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "перелік профілів збору", err)
|
||||
return
|
||||
}
|
||||
if profiles == nil {
|
||||
profiles = []store.ConfigProfileOption{}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"devices": list,
|
||||
"total": len(list),
|
||||
"profiles": profiles,
|
||||
// Поріг застарілості віддається назад, бо сервер його підставляє
|
||||
// й підрізає: інтерфейс має показувати те число, за яким справді
|
||||
// відібрано, а не те, яке ввела людина.
|
||||
"stale_days": staleDaysOf(f),
|
||||
})
|
||||
}
|
||||
|
||||
func staleDaysOf(f store.ConfigFilter) int {
|
||||
if f.StaleDays <= 0 {
|
||||
return store.DefaultStaleDays
|
||||
}
|
||||
if f.StaleDays > 3650 {
|
||||
return 3650
|
||||
}
|
||||
return f.StaleDays
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue