diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 8e332e1..131f6e0 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index 1ebf24b..0e65f56 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,6 @@ server/webui/dist/* !server/webui/dist/index.html !server/webui/dist/.gitignore + +# Кеш інкрементної збірки TypeScript — машинний стан, не код. +web/tsconfig.tsbuildinfo diff --git a/HISTORY.md b/HISTORY.md index 1da7b9a..896c7b6 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -3117,3 +3117,4172 @@ ZXAN# після збереження картка відкривається знову — половина налаштувань (розклад, доступи, перевірки) кріпиться до хоста й до його створення не існує, і повертатись у той самий хост руками не треба. + +--- + +## 2026-08-25 — Профіль збору конфігів підбирається за SNMP + +Профіль підбирався за полем «виробник», яке заповнює людина руками. Це +видно на першому ж живому хості: `Леніна.21` — поле порожнє, збір падає +з «не задано профіль». `ZTE Миронівка` — поле заповнене «ZTE», і +береться перший-ліпший ZTE-профіль, який може виявитись не тим. + +Пристрій же сам каже, що він таке. Механіка вже була — вона підбирає +шаблони опитування (`tpl.auto_assign`, міграція 0027). Тепер те саме для +профілів. + +### Що зроблено + +**`ncm.profile_auto_assign`** (міграція 0029) — 14 вбудованих правил +`sysObjectID → профіль`. Збіг за префіксом OID, бо виробник кодує в +ньому й серію: одне правило на родину, а не рядок на кожну коробку. + +**Уточнення за `sysDescr`.** Один enterprise-OID — не один тип заліза. +У ZTE `1.3.6.1.4.1.3902` — і комутатори ZXR10, і OLT ZXAN, а знімаються +вони різними командами й мають різне запрошення. Правило з `descr_regex` +точніше за правило лише з OID і виграє в нього. Перевірено: + +``` +C320 (…3902.1082, «ZXA10 C320») → zte-zxan +ZXR10 (…3902.3, «ZXR10 5952») → zte-zxr10 +Cisco (…9.1.1745) → cisco-ios +``` + +**Порядок підбору** в `resolveProfile`: явно заданий профіль → збіг за +sysObjectID → збіг за виробником. Остання гілка лишається для заліза, +яке віддає лише CLI. + +**Виробник заповнюється сам.** `ApplySystemInfo` проставляє `vendor` +хосту, у якого поле порожнє. Уже заповнене не чіпає: людина могла +написати точніше за наш довідник. + +### Головне: розірвано замкнене коло + +Автопідбір спирався на `sysObjectID`, а той нізвідки не брався, доки +хост нічим не опитують — системну групу знімав лише чек +`topology.discover`, який треба завести руками. Автоматика чекала на +дані, дані чекали на людину. + +Тепер: хост має SNMP-доступ — значить, дозволено запитати, хто він +такий. `EnsureIdentifyChecks` заводить такому хосту чек +`topology.discover` у полегшеному режимі. + +**Полегшений режим.** `{"protos": [], "collect_interfaces": false}` — +ні сусідів, ні обходу `ifTable`, лише системна група. Заради цього +`Params.Protos` став вказівником: «поля немає» (типове lldp+cdp) і +«порожній список» (свідоме «жодного») — різні речі, зрізом їх не +розрізнити. Обхід інтерфейсів пропускається — це не мікрооптимізація: +`ifTable` на комутаторі з 400 портами це тисячі PDU, а розпізнавання +коштує три OID. + +**Два інтервали.** Поки хост не розпізнано — дві хвилини: людина щойно +завела хост і дивиться на екран. Розпізнали — шість годин: питання +закрите, лишається сторожова функція (залізо в шафі міняють). Справа не +в тому, що дані швидко старіють, а в тому, що розклад агента кладе +задачі на сітку інтервалу — при шести годинах перший запуск міг би +припасти й на п'яту годину очікування. + +Чек не створюється, якщо автовиявлення вже налаштоване вручну: його +системна група так само заповнить поля. + +### У вебі + +Підказка під «— автоматично —» тепер каже, що буде саме з цим хостом: +«Розпізнано по SNMP: ZXA10 C320 — профіль підбереться сам», або «ще не +опитано по SNMP — підбереться за виробником "ZTE"», або «не розпізнано і +виробник не заданий». Різниця між налаштованим хостом і мовчазною +помилкою за добу, коли спрацює розклад. + +`DeviceSummary` возить `sys_object_id` і `sys_descr`. + +### Спіткнулись + +`CASE WHEN … THEN $3 ELSE $4 END` без явних кастів — Postgres вивів +типи параметрів як `text` і впав на `integer <> text`. Параметри в +`CASE` треба кастувати: `$3::int`. + +### Заразом: зразок запрошення huawei-vrp не збігався ніколи + +Розпізнавання спрацювало на другому живому хості — `Леніна.21` виявився +Huawei S2326TP-EI, і йому автоматично став `huawei-vrp`. Збір усе одно +впав: «не дочекались запрошення командного рядка». + +Стенограма показала, що вхід відпрацював, пристрій показав `` +і чекав на команду. Проблема була в самому зразку: у ранньому сіді +(0010) зворотні скісні подвоєні. Для JSON-полів це правильно — там це +екранування самого JSON. Для `prompt_regex`, звичайного `text`, друга +скісна лишається в даних символом. + +У Go з цього виходить: клас `[>` + дві скісні + `]` закривається +першою ж дужкою, а остання стає літералом. Тобто зразок вимагав, щоб +запрошення закінчувалось квадратною дужкою — чого `` не робить. + +Сід 0014 має правильний зразок, але йде з `ON CONFLICT DO NOTHING`, і +зіпсований рядок пережив його. Виправлено міграцією 0030. Решта 148 +вбудованих профілів не зачеплені — перевірено по `DISTINCT +prompt_regex`, зіпсований рядок був один. + +Умова в міграції написана через `position(chr(92) || chr(92) IN …)`, а +не через порівняння з рядком: питання саме в кількості скісних, і зайвий +рівень екранування в самій міграції зробив би її такою ж помилкою, яку +вона лікує. + +### Результат на живому стенді + +Обидва хости пройшли шлях цілком самі — ні профілю, ні виробника руками +ніхто не задавав: + +``` +ZTE Миронівка 1.3.6.1.4.1.3902.1015.320.1.2 «ZXR10 ROS … ZXPON C320» + → vendor ZTE, профіль zte-zxan → 32 854 рядки +Леніна.21 1.3.6.1.4.1.2011.2.23.92 «S2326TP-EI / Huawei VRP» + → vendor Huawei, профіль huawei-vrp → 398 рядків +``` + +`Леніна.21` до цього не мала виробника взагалі й падала з «не задано +профіль». Інтервал чека розпізнавання в обох після успіху сам піднявся +з 2 хв до 6 год. + +**Дорогою знайшлась ще одна причина, чому це не працювало б і так.** +Планувальник агента відправляв звіт автовиявлення тільки за наявності +сусідів або портів. У режимі самого лише розпізнавання немає ні того, ні +іншого — системна група нікуди не їхала. Тепер `Devices` теж рахуються. + +### Заразом: чек розпізнавання отримав власну назву + +Розпізнавання зробили полегшеним режимом `topology.discover`. Механіка +правильна, підпис — ні: цей тип чека зветься «Neighbor Discovery», і в +картці хоста з'являвся рядок, який обіцяє пошук сусідів, а не шукає +жодного. Назва в списку перевірок — це те, за чим оператор о третій ночі +вирішує, чи цей рядок пояснює те, що він бачить. + +Заведено окремий тип `topology.identify` — «Розпізнавання пристрою» +(міграція 0031, вже створені чеки перенесено). Префікс `topology.` +обов'язковий: `Registry.Register` вимагає, щоб тип чека починався з +ключа модуля. + +Заразом режим тепер визначається типом чека, а не параметрами: +розпізнавання не має способу випадково перетворитись на повний обхід +через недогляд у формі. + +--- + +## 2026-08-25 (продовження) — Розпізнавання: кнопка, тип хоста, дублі + +### «Розпізнати зараз» + +Розпізнавання відбувається саме, але за розкладом. Людина, яка щойно +завела хост, чекати такт не хоче. + +Кнопка не може штовхнути зонда напряму: API і колектор — різні процеси +в різних контейнерах, а сесії зондів живуть у колекторі. Тому той самий +шлях, яким уже ходить збір конфігів: REST лишає намір у базі +(`inv.devices.identify_requested_at`, міграція 0032), диспетчер +колектора його забирає й надсилає живій сесії `DiscoveryRequest`. + +Механізм поштовху вже був: `Scheduler.TriggerNow(deviceIDs, "topo.")` +зрушує задачі з розкладу зонда. Тому `RequestIdentify` спершу +переконується, що чек існує — інакше поштовх нікуди б не влучив. + +Відмова повертається як 200 з поясненням, а не 4xx: «немає SNMP-доступу» +і «зонд не на зв'язку» — не помилки запиту, а стан системи, і людині їх +треба показати у формі, а не в діалозі про збій. Текст готовий, не код: +причин кілька, кожна лікується по-своєму, і тримати цей перелік ще й у +вебі означало б забути оновити одне з двох місць. + +### Тип хоста визначається так само + +`kind` за замовчуванням `other`, і в переліку хостів це видно: всі +«інше». Поле не косметичне — за ним фільтри, значки на мапі, звіти. А +пристрій уже сказав, що він таке: OLT відрізняється від комутатора тим +самим `sysDescr`, що ZXAN від ZXR10. + +`kind` ліг у те саме правило, що вже несе `vendor` (міграція 0033): +рядок з уточненням за описом дає `olt`, загальний рядок виробника — +`switch`. Де виробник робить і комутатори, і маршрутизатори, чесніше +сказати `switch`: людина виправить одним кліком, а невірний `router` на +сотні портів вона помітить не одразу. + +`fillVendorFromOID` став `fillFromOID` і бере правило тим самим +порядком, що й підбір профілю. Інакше хост міг би отримати тип від +одного правила, а профіль від іншого — і пояснити, чому OLT підписаний +комутатором, було б нічим. Уже задане не чіпається: `other` — це +типове значення колонки, людина його не обирала, а от `switch` вона +могла поставити свідомо. + +### Ручне додавання `topology.identify` давало «внутрішню помилку» + +Система вже завела такий чек сама. Друга копія впиралась у +`checks_uniq` (хост, тип, інтерфейс, md5 параметрів), унікальне +порушення доходило до обробника як невідома помилка й показувалось як +«внутрішня помилка» — без жодної підказки, що робити. + +Полікувано з двох боків. `core.check_types.auto_managed` позначає чеки, +які заводить і планує система: з довідника тип не прибирається (перевірка +при збереженні має його знати), але в переліку для ручного додавання не +з'являється. І окремо `SetDeviceChecks` тепер розрізняє 23505 і каже +«перевірка з такими ж параметрами у цього хоста вже є» — 400, а не 500. + +### Поштовх не зачіпав нікого + +Кнопка відповідала `queued: true`, колектор писав «розпізнавання +зрушено вручну», зонд писав «сервер попросив запустити автовиявлення» — +і нічого не відбувалось. + +`TriggerNow(deviceIDs, checkTypePrefix)` фільтрує задачі за префіксом, а +викликали його з `"topo."`. Рядок `topology.identify` на `"topo."` не +починається — крапка не там. Помилці стільки ж років, скільки коду: +`DiscoveryRequest` доти не слав ніхто, і зламаний фільтр не мав на чому +проявитись. Тепер префікс — ключ модуля з крапкою, `"topology."`. + +Заразом закрито перегони: `RequestIdentify` міг щойно завести чек, а +зонд зрушує лише те, що вже має в розкладі. Диспетчер тепер спершу +звіряє план і аж потім штовхає — інакше кнопка мовчки нічого не робила б +саме там, де вона найпотрібніша: на щойно заведеному хості. + +Перевірено на живому: Juniper QFX із `other` став `switch` за +секунду після натискання. + +--- + +## 2026-08-25 (продовження) — Тихий збій, який коштував би архіву + +Користувач подивився на зібраний конфіг D-Link і побачив там один +рядок: `Command: show config`. Завдання при цьому було позначене +успішним. Двадцять байтів у сховищі замість конфігу. + +Стенограма пояснила все: + +``` +DGS-1100-06/ME:5# show config active +Command: show config + +Next possible completions: +``` + +`show config active` — команда профілю `dlink-dxs` — на цій платформі +не існує. Пристрій ввічливо запропонував варіанти, і ця пропозиція +лягла у сховище як версія конфігу. + +### Перевірка правдоподібності + +Порожній вивід ми ловили й раніше. Але пристрій рідко мовчить: на +невідому команду D-Link відповідає «Next possible completions», Cisco — +«% Invalid input», і кожна з цих відповідей непорожня. + +Тепер `looksLikeConfig` вимагає щонайменше п'ять змістовних рядків і +відсіює характерні відмови CLI. Зразки відмов шукаються **лише в +короткому виводі**: у справжньому конфізі на тридцять тисяч рядків слова +«invalid» чи «unknown» трапляються в іменах ACL і в банерах, і глушити +збір через них не можна. Поріг грубий навмисне — точного числа не +існує, а видимість точності заважала б помітити, що він спрацював. + +Помилка показує, що саме сказав пристрій. Без цього людині лишалось би +здогадуватись, чим команда з профілю не підійшла її залізу. + +Гучна помилка тут коштує одного дзвінка. Тиха — усього архіву. + +### Профіль для D-Link /ME + +Правильна команда — `show config current_config`. Але й з нею конфіг +обривався на четвертому рядку, і причина та сама, що колись у ZTE: +зразок `[>#:]\s*$` приймає будь-який рядок, що закінчується решіткою, а +конфіг /ME починається з рамки коментарів, де є рядок із самої лише +решітки. + +Зразок `:\d+#\s*$` вимагає двокрапку й цифру перед решіткою — саме так +виглядає справжнє запрошення `DGS-1100-06/ME:5#`. + +Зіставлення — за описом, а не за OID моделі (міграція 0034): у D-Link +кожна модель має власний OID, а «/ME» стоїть у sysDescr усієї серії. + +``` +було: 20 байтів, 1 рядок +стало: 15 807 байтів, 403 рядки +``` + +### SSH за ключем + +`private_key` був у протоколі від початку — і його ніхто не читав. +Транспорт брав лише пароль, тож доступ із ключем був неможливий, а +пристрій, у якого вхід за паролем вимкнений, відповідав +«no supported methods remain» без жодної підказки, чого від нас хотіли. + +Тепер ключ пробується першим, а пароль за потреби йде як парольна фраза +до нього. + +--- + +## 2026-08-25 (продовження) — Модель і версія ПЗ визначаються самі + +Колонки `model`, `os_version`, `serial_number` існували в `inv.devices` з +першої міграції — і завжди були порожні. Заповнити їх мала людина, а +людина цього не робить: у неї сотня хостів і жодного стимулу. + +Пристрій же називає себе в sysDescr. Проблема лише в тому, що кожен +робить це по-своєму, і жодного порядку в цьому немає: + +``` +Juniper Networks, Inc. ex4600-40f Ethernet Switch, kernel JUNOS 14.1X53-D27.3 +ZXR10 ROS Version V4.6.02A ZXPON C320 Software, Version V1.2.5P3 +S2326TP-EI / VRP (R) software,Version 5.70 (S2300 V100R006C01SPC100) +DGS-1100-06/ME/A1 +``` + +Тому не спільний розбір, а зразок на правило: правило вже знає, з ким +має справу, бо дійшло до нього за OID і описом. + +Зразки застосовує Go, а не Postgres — потрібні групи захоплення. RE2 не +має відкату, тож зразок із довідника не підвісить збір, навіть якщо його +напише людина. Зіпсований зразок мовчки нічого не дає, а не валить +розпізнавання: помилка в одному рядку довідника не має зупиняти +онбординг решти хостів, а помітно її буде там, де й має бути — у +порожньому полі моделі. + +Результат на живому стенді, без жодного дотику руками: + +``` +JUN.QFX-Миронівка switch Juniper ex4600-40f 14.1X53-D27.3 +ZTE Миронівка olt ZTE C320 V1.2.5P3 +Леніна.21 switch Huawei S2326TP-EI V100R006C01SPC100 +ПОЛЬОВЕ-СВ switch D-Link DGS-1100-06/ME — +ПОЛЬОВЕ.7 switch D-Link DES-1210-28/ME — +``` + +D-Link версії не показує: в його sysDescr її немає взагалі (прошивка +видно лише у вітанні CLI). Порожньо чесніше, ніж підставити щось схоже. +Серійні номери сюди теж не потрапляють — вони живуть в ENTITY-MIB, а це +вимагає нових полів у protobuf. + +### Таблиця правил переїхала + +`ncm.profile_auto_assign` заводилась, щоб підбирати профіль збору +конфігу. Далі обросла `vendor`, потім `kind`, тепер моделлю й версією — +тобто давно перестала бути «про NCM». Тепер це `inv.ident_rules`. + +Ім'я, яке бреше про призначення таблиці, коштує дорожче за одну +міграцію: наступний, хто шукатиме, де система вирішує тип хоста, +шукатиме де завгодно, тільки не в схемі ncm. Профіль лишається одним із +наслідків правила, а не його метою. + +### У картці хоста + +Блок «Розпізнано по SNMP» — окремо від полів форми. Це не налаштування, +а факт із мережі: редагована модель у сусідстві з тією, що приїхала по +SNMP, читалась би як пропозиція її поправити, а поправлене система все +одно не перезапише — і людина лишилась би з двома різними правдами. + +sysDescr показується повністю: саме з нього дістаються модель і версія, +і коли щось дісталось не так, дивитись треба туди. + +--- + +## 2026-08-25 (продовження) — Розпізнавання перестає бути мовчазним + +Користувач: «Хост Миронівка.Ядро.1 не автовизначився! Потрібно продумати +автовизначення як профі.» + +### Що насправді сталося + +Хост DGS-3420 стояв нерозпізнаним, хоча SNMP відповідав, правило +існувало й підходило. Достатньо було штовхнути розпізнавання вручну — +і виробник із типом з'явились за секунду. + +Причина: розпізнавання відбулось у вікні між перейменуванням таблиці +правил (міграція 0035) і перезбіркою колектора. Запит падав, +`ApplySystemInfo` повертав помилку — і кидав ВЕСЬ звіт, уже записавши +системну групу. + +Ззовні це виглядало як «система просто не розпізнала». Ні сліду, ні +причини, ні різниці між «немає правила», «правило не спрацювало» і «нас +ніхто не питав». Один зіпсований хост зупиняв обробку решти в тому ж +звіті. + +### Що виправлено — і це не про той хост + +**Помилка на одному хості більше не роняє звіт.** Кожен обробляється +окремо, причина осідає в `inv.devices.identify_error` — у картці, де її +шукатимуть, а не в журналі сервера, який читає той, хто вже знає, що +щось не так. У журнал іде масштаб: «частину хостів не розпізнано, +невдач: N». + +**Заміна заліза помічається окремо.** `hardware_changed_at` +заповнюється, коли sysObjectID БУВ і став іншим (перше розпізнавання +зміною не рахується). Це єдиний випадок, коли автоматика перезаписує +задане людиною: модель і виробник описують коробку, якої вже немає, і +берегти їх — берегти неправду. + +**Профіль збору закріплюється за хостом.** Досі підбирався щоразу +наново під час кожного завдання: у картці порожньо, і зміна довідника +мовчки змінювала поведінку всіх хостів. Тепер записується, а прапорець +`profile_auto` розрізняє, хто його обрав: підібраний автоматично можна +переглянути після заміни заліза, обраний людиною — ніколи. + +**Модель для решти D-Link.** Досі її діставало лише правило серії /ME, +а загальне правило виробника лишало поле порожнім — тобто кожна нова +серія вимагала б рядка в довіднику. Тепер `^([A-Z]{3}-[0-9A-Za-z/-]+)`: +`DGS-3420-28SC Gigabit Ethernet Switch` → `DGS-3420-28SC`. + +### Версія з конфігу, коли SNMP її не каже + +Користувач: «Якщо хост не віддає версію по snmp, може робити це якось +інакше?» + +D-Link у sysDescr дає саму лише модель. Але в шапці власного конфігу +пише `# Firmware: Build 1.05.B026`. + +Конфіг ми й так збираємо. Дістати з нього версію коштує нуль запитів до +пристрою й нуль нового трафіку — на відміну від окремого походу в CLI +заради одного рядка. Зразки лягли в те саме правило +(`config_version_regex`, `config_model_regex`, `config_serial_regex`) і +застосовуються після кожного успішного збору. + +Дивимось лише перші 4 КБ: шапка з моделлю й прошивкою стоїть на початку +файлу, а ганяти зразок по тридцяти тисячах рядків заради рядка, який +завжди в перших двадцяти, — платити ні за що. + +Перевірено на справжніх даних: + +``` +Миронівка.Ядро.1 DGS-3420-28SC (модель, якої раніше не було) +шапка конфігу 1.05.B026 (версія, якої немає в SNMP) +``` + +--- + +## 2026-08-25 (продовження) — Масова правка й масове видалення хостів + +Оператор має змінювати десятки хостів одним рухом. Уся складність тут +не в SQL, а в одному питанні: що означає порожнє поле. + +### «Не чіпати» — це не «стерти» + +Форма на десять полів, застосована до п'ятдесяти хостів, не має занулити +те, чого людина не заповнювала. В одиничній картці порожнє поле означає +«зітри»: людина бачить поточне значення й стирає його свідомо. У масовій +формі поле порожнє ЗАВЖДИ — спільного поточного значення у п'ятдесяти +хостів немає, — і те саме тлумачення знищило б виробників, зонди й +розклади в усьому наборі за один клік. + +Тому кожне поле має три явні стани, і жоден не вгадується з вмісту: + +``` +поля немає в JSON → не чіпати +{"op":"set", ...} → задати всім однакове +{"op":"clear"} → очистити всім +``` + +У формі це перемикач «не чіпати / задати / очистити» на кожному рядку, і +поле значення взагалі не з'являється, поки не обрано «задати». + +Масовими стали: зонд-опитувач, тип, виробник, увімкнено/вимкнено, групи, +шаблони опитування, доступи до обладнання, розклад збору конфігів. Не +стали: ім'я, адреса, нотатки — вони унікальні на хост, і масова зміна тут +означала б не швидкість, а помилку. + +### Групи: «додати» і «прибрати» — різні дії + +Набір зв'язків має чотири стани, і це не ускладнення форми, а суть +множини: хости в наборі мають РІЗНІ поточні групи, тож спільного +«задати» для них не існує, поки людина не скаже, що старе можна викинути. + +`replace` лишено навмисно, попри те що воно найнебезпечніше: без нього +неможливо навести лад там, де вже наставлено абияк. Але окремим вибором, +зі своїм попередженням, і ніколи не поведінкою за замовчуванням. + +Розклад збору конфігів, навпаки, задається цілком: це одне рішення +(«щодня о третій, профілем huawei-vrp»), а не набір незалежних дрібниць — +увімкнути збір, не сказавши коли й чим, означає завести політику, яка +мовчки нічого не збирає. + +### Набір добирається так само, як для масових команд + +Клієнт шле перелік id РАЗОМ із фільтром, сервер бере перетин, а те, що +відпало, повертає окремим списком — рівно як `ResolveCommandTargets`. +Дві масові операції в одній системі мусять поводитись однаково, інакше +людина, яка звикла до однієї, помилиться в другій. + +Межі — `Scope.Writable`, а не `Readable`: «бачу хост» і «можу його +змінити» — різні права. Хост, доступний лише на читання, не просто +відсіюється, а показується у відсіяних: інакше «обрано 50, змінено 43» +людина побачила б лише в аудиті. + +### Стеля — 200 за раз + +Не через продуктивність: UPDATE на десять тисяч рядків Postgres зробить +швидко. Через те, що правка йде однією транзакцією й разом із хостами +чіпає членство в групах, доступи й розклади — тобто тримає блокування на +пів-інвентарю, поки диспетчер колектора читає з тих самих таблиць свій +план на такт. Двісті — це ще й приблизно те, що людина здатна прочитати +в підтвердженні, а підтверджене наосліп підтвердженням не є. + +Шаблони — єдине, що застосовується поза транзакцією: кожне призначення +тягне перерахунок чеків і правил алертів зі своїми транзакціями, і +затягнути це всередину означало б тримати `core.checks` і `alr.rules` +заблокованими весь час перерахунку двохсот хостів. + +### Видалення показує наслідки числами + +М'яке, те саме `deleted_at = now(), enabled = false`, що й для одного +хоста. Перед натисканням видно точний перелік імен і адрес, а під ним — +що станеться з прив'язаним: + +``` +N перевірок лишаться, але опитування спиниться +N активних алертів закриються самі на найближчому такті правил +N розкладів збору лишаться, але збір спиниться +N вузлів на мапах ЛИШАТЬСЯ з останнім відомим станом +N версій конфігу зберігаються — заради цього видалення й м'яке +``` + +Останнє виявилось дірою і в одиничній формі: вона обіцяла, що хост +«зникне зі списків і мап», а читання мапи не фільтрує видалені хости — +вузол лишається стояти. На живому стенді вузли на мапах має п'ять хостів +із шести, тобто обіцянку бачив би кожен. Текст виправлено в обох місцях. + +Кожна масова дія — рядок у `core.audit_log` з іменами хостів, а не лише +з ідентифікаторами: через рік хостів може вже не бути, а питання «що +тоді зробили» лишиться. + +--- + +## 2026-08-25 (продовження) — Черги, мапа, і калібрування висновку + +### Сторінка «Черги» + +Знайдено вісім місць, де щось накопичується, і для кожного визначено +головне: **втрачаються дані чи лише затримуються**. Втрата важливіша за +глибину — черга на сто елементів зі збалансованим темпом здорова, а +одна викинута вибірка телеметрії не повертається ніколи. + +Висновок рахує сервер, інтерфейс лише малює: пороги, що живуть у двох +місцях, розходяться при першій же правці одного з них. + +### Перше, що вона знайшла — і чому це виправлення в двох місцях + +Сторінка одразу показала «1 перевірка пропустила такт» і три +перевірки, що мовчать понад три інтервали. + +Три «мовчазні» виявились чеками **видалених хостів**. План їх не бере +(`BuildPlan` фільтрує `deleted_at`), але рядки лишились увімкненими — і +виглядали б як вічний затор. Тому: видалення хоста тепер вимикає його +чеки (не видаляє — хост відновлюють разом із налаштуваннями), а сама +сторінка їх не рахує. + +Пропущений такт виявився справжнім: `Леніна.21`, icmp.ping з інтервалом +30 с, попередній запуск не встиг до наступного. + +**Але висновок був «критично» — і це помилка калібрування.** Один +пропуск із сімнадцяти чеків це повільний пристрій, а не аварія. +Червоне, яке горить від шуму, вчить не дивитись на сторінку взагалі — і +тоді вона не спрацює тоді, коли справді знадобиться. Тепер поріг у +частці, а не в числі: на двох чеках один пропуск це половина +моніторингу, на двох тисячах — шум. Три тести саме на калібрування. + +### Мапа: підписи, стани, справжні зв'язки + +Причин відсутності підписів було **дві, і обидві справжні**. + +Головна — даних немає: `inv.interfaces`, `topo.links` і `topo.neighbors` +порожні, бо повне автовиявлення ніхто не заводив. + +Друга — окремий баг: власний підпис ребра їхав у `data.label` і **ніде +не читався**, бо `TrafficEdge` затіняв його локальною змінною. Людина +вводила підпис, він зберігався, інспектор його показував — а на лінії +не було нічого. + +Стан ребра замість ярлика «без прив'язки» — шість станів, і кожен +пропонує дію: «знайдено в мережі» → кнопка «Прив'язати»; «суперечить +виявленому» → перевірити схему; «намальовано вручну» → «Зібрати +топологію». + +Ця кнопка й замикає коло: підвищує наявний чек розпізнавання до повного +`topology.discover`, і далі ланцюг іде сам — сусіди → інтерфейси → +`snmp.if` → трафік на лінії. Пастку враховано: чек заводиться ДО +поштовху, інакше `TriggerNow` не влучає нікуди. + +### Видалений хост більше не стоїть на мапі назавжди + +`MapNodes` не фільтрував `deleted_at`: вузол лишався з останнім відомим +станом, оновлювати його вже нікому. Обрано не тихе зникнення, а окремий +стан: підпис закреслений, колір «невідомо», в інспекторі — блок «хост +видалено» з кнопкою прибрати. Мовчазне зникнення вузла разом із лініями +виглядало б як поламана мапа. + +--- + +## 2026-08-26 — Фільтр у «Конфігах», і чому він третій, а не новий + +Користувач: «Реалізуй фільтри в конфігах, щоб можна було шукати по +назві\айпі\моделі\вендору чи версії, групі і т.д — роби розумно». + +Сторінка мала поле «пошук хоста», яке шукало лише за іменем і лише в +пам'яті браузера — по всьому інвентарю, вивантаженому цілком. + +### Третьої мови фільтрів не з'явилось + +`DeviceFilter` уже вмів усе, що назвав користувач, і ним уже +користувались дві сторінки — «Команди» й масові дії в «Хостах». Писати +третій набір понять означало б завести оператору другий словник: людина, +яка щойно відібрала «усі Huawei на Миронівці» в командах, мусила б +вчитись відбирати те саме заново. + +Але поки застосувань було два, спільною лишалась тільки структура — а +сама умова стояла двома копіями SQL. Третя копія перетворила б збіг на +систему: копії розходяться не одразу, а на першому ж новому полі, і тоді +однаково заповнений фільтр дає різні набори на різних сторінках, і +помітити це можна лише звірянням переліків руками. + +Тому умова тепер одна (`store/device_filter.go`) і вставляється в усі три +запити з власним зміщенням плейсхолдерів. Тест саме на нумерацію: зсув на +одиницю не падає й не помиляється помітно — він мовчки порівнює не ті +колонки. + +### Пошук за адресою вже працював. Не працював за майданчиком + +`query` дивився в ім'я, адресу, виробника й модель — «айпі» знаходилось. +Додано версію ПЗ й назву майданчика: людина, яка набирає «Миронівка», не +має спершу здогадатись, що це майданчик, а не частина імені. Оскільки +умова спільна, це запрацювало на всіх трьох сторінках одразу; клієнтський +відбір у «Хостах» приведено до того самого набору полів, щоб перелік на +екрані й набір, який візьме масова дія, збігались. + +### Що є в конфігах і чого немає більше ніде + +Два поняття, і обидва — окремою структурою `ConfigFilter` поверх +спільної, а не новими полями в `DeviceFilter`. Причина не в чистоті: +кожне поле спільного фільтра мусить щось означати на кожній сторінці. +«Остання версія старша за тиждень» у масових командах не означає нічого — +там немає ні версій, ні збору. Поле, яке одна сторінка мовчки ігнорує, +рано чи пізно почнуть слати з неї «про всяк випадок». + +**Стан архіву** — `none` / `stale` / `fresh` ділять хости без залишку, +`failed` навмисно перетинається з ними. Хост, у якого архів свіжий, а +останній збір щойно впав, — найцікавіший випадок: проблема вже почалась, +а вік версії про неї ще мовчить. Заховати його всередину «немає бекапу» +означало б показати його тоді, коли рятувати пізно. + +Поріг застарілості — явне число (типово тиждень), а не «прострочений +розклад» із `next_backup_at`. У хоста може не бути розкладу взагалі, а +питання «коли його востаннє зберігали» до нього все одно ставлять. І +головне: прострочений розклад рахує сам планувальник — якщо застряг він, +поле, яким ми його перевіряємо, застрягне разом із ним. Вік останньої +версії правдивий незалежно від того, що там із плануванням. + +**Профіль збору.** Перемикачі будуються з профілів, які справді комусь +призначені, а не з довідника: у довіднику їх півтори сотні, у парку +працює п'ять. Окреме значення «не закріплено» — на стенді такий хост є +(Миронівка.Ядро.1), і збирається він нормально, профіль йому підбирається +під час завдання. Але підбір мовчки зміниться разом із довідником, тож +подивитись саме на них треба вміти. + +### Підпис у переліку рахує сервер + +Поруч з іменем хоста тепер видно стан архіву — «2 верс. · 3 години тому», +«бекапу немає», «застарів», «збір впав». Питання «у кого тут біда» +ставлять до всього переліку одразу, а не клікаючи по хостах по черзі. + +`backup_state` рахує той самий SQL-вираз, що й відбір. Поріг — це +рішення, і якщо його рахувати ще й у браузері, два місця розійдуться на +першій же правці одного з них: хост стоятиме в переліку «застарілі» з +підписом «свіжий», і повірити не можна буде жодному з двох. + +### Спільна панель + +`ChipRow` і `VersionRow` жили в `CommandsPage`; тепер це +`components/DeviceFilterPanel.tsx`, а сторінки додають до нього лише +власні поля через `children`. Правило поділу: питання до ХОСТА — у +панелі, питання до того, що з ним робить сторінка, — на сторінці. + +Відбір робить сервер. На сторінці, куди можуть приїхати тисячі хостів, +фільтрація в пам'яті означає вивантажити їх усі — і зробити це знову на +кожну натиснуту літеру. + +--- + +## 2026-08-26 — Мінікарта: три помилки й одна справжня + +Користувач тричі сказав, що мініатюра конфігу нечитабельна. Кожен раз +причина була інша, і кожен раз я спершу правив на дотик. + +**Перше — «бліда».** Додав кольори й контраст. Не допомогло, бо річ була +не в кольорі. + +**Друге — суцільна пляма.** Аж коли я відмалював алгоритм на справжньому +конфізі ZTE окремим скриптом і подивився на PNG, стало видно: 32 344 +рядки в 620 пікселів — це 52 рядки на піксель, і кожен піксель брав +найдовший рядок зі своїх п'ятдесяти двох. Інформації там уже не було, +лишався силует. Рішення — як у редакторах коду: рядок отримує два +пікселі, а коли файл не влазить, смужка сама їде за прокруткою. + +**Третє — злиті слова.** Рядок малювався суцільною рисою від відступу до +кінця. Саме проміжки між словами роблять зображення схожим на текст; +без них це штрихування. Тепер малюється кожне слово окремо, а ширина +виросла з 68 до 140 — на 68 слова зливаються незалежно від того, як їх +малювати. + +### Пошук показував неправду + +Окрема помилка, знайдена дорогою: у мінікарту потрапляли лише **перші +1000** знайдених рядків. У конфізі з 7 058 збігами карта показувала +приблизно перші дві тисячі й мовчала про решту — тобто казала «збіги на +початку, далі чисто». Це рівно той висновок, заради якого на мінікарту +й дивляться. + +Стеля виглядала розумною оптимізацією рівно доти, доки не звірити її з +числами справжнього файлу. + +Заразом: колонка збігів заливалась бінарно й ставала суцільною вже від +сотні розкиданих збігів — тепер це щільність (корінь, а не пряма +пропорція, щоб поодинокий збіг не зникав поруч зі згустком). А знайдений +рядок тепер **перефарбовується**, а не заклеюється смугою на всю +ширину: смуга ховала саме ту форму рядка, яку людина шукає очима. + +### Висновок для себе + +Жодну з цих трьох причин не було видно з коду. Усі три стали очевидними +за секунду після того, як зображення було намальовано й показано. +Дешевий спосіб подивитись на результат коштував менше, ніж будь-яка з +трьох спроб вгадати. + +--- + +## 2026-08-26 — Масова правка добирає ручні перевірки + +Користувач: «У масовій зміні хостів ти не додав зміну ручних перевірок». + +Їх лишили поза формою з міркуванням «вони поодинокі за визначенням — те, +чого немає в шаблонах». Поодинока тут причина, а не кількість. «Додати +`http.status` усім вебсерверам дільниці» і «прибрати зайвий `icmp.ping` +із сорока хостів, яким його колись наставили руками» — це одне рішення +на сорок рядків, тобто рівно те, заради чого масова форма й існує. Без +неї людина або робить сорок однакових рухів, або не робить нічого, і +частіше друге. + +### Три дії, і жодна не називається «замінити» + +``` +додати перевірку тим, у кого її ще немає +прибрати перевірку за типом +змінити наявні інтервал, таймаут, повтори, увімкнено/вимкнено +``` + +Заміни всього набору немає навмисно — і це не та сама межа, що для груп, +а гостріша. У груп `replace` лишили, бо групи ВИДНО в переліку хостів: +людина, яка натискає, бачить, що стирає. Набори перевірок не видно ніде, +тож те саме натискання зітерло б невідомо що, а помітилось би тоді, коли +перестане приходити алерт. + +Ручною вважається перевірка з `template_id IS NULL AND interface_id IS +NULL` — рівно та межа, яку вже проводить `SetDeviceChecks`. Породжену +шаблоном міняють у шаблоні, інакше правка зникне на найближчій +реконсиляції; та, що стоїть на інтерфейсі, взагалі не про хост, а про +порт, і нею володіє автовиявлення. + +### Головне питання — параметри + +Чек має `params` за схемою типу, і вони різні за природою. Кількість +пакетів у `icmp.ping` у сорока хостів однакова — задати її масово +осмислено. URL у `http.status` різний у кожного, і спільного значення +для набору не існує взагалі. + +Заборонити типи з обов'язковими параметрами означало б заборонити рівно +той випадок, з якого все почалося: `url` у `http.status` обов'язковий. +Дозволити лише те, що має типове значення зі схеми, — те саме іншими +словами: типового URL немає й бути не може. + +Тому дозволено, але з підстановкою на те, чим хости в наборі ЗАКОННО +різняться, — тобто на них самих: + +``` +{{address}} адреса хоста +{{name}} ім'я +{{fqdn}} доменне ім'я +``` + +Трьох достатньо й більше не буде. Будь-яке «різне», що не є самим +хостом, — це не масова дія, а сорок окремих рішень, і робити їх треба в +сорока картках. З тієї ж причини `params` є лише в «додати»: масово +задати той самий URL сорока перевіркам, у яких він уже різний, означає +зламати сорок перевірок одним рухом. + +Порожня підстановка — помилка, а не порожній рядок. `http:///health` ліг +би в базу мовчки, а помітили б його через добу серед впалих перевірок — +за кілометр від того місця, де його створили. Хост без адреси чесно йде +в «не вдалося». + +Форма параметрів — та сама `ParamFields`, що й у картці хоста, з тієї ж +`params_schema`. Другого, «краще поінформованого» діалекту параметрів у +продукті бути не повинно: тоді дві форми розійшлися б у тому, що таке +перевірка. + +### Дублі — це не помилка + +`checks_uniq` не дасть завести другу таку саму перевірку, і масове +додавання ГАРАНТОВАНО натрапить на хости, у яких вона вже є: половину +дільниці колись налаштували руками. Показати «додано 12» і промовчати +про решту — збрехати; показати помилку — злякати на рівному місці. + +Тому те саме розрізнення, що вже несуть `dropped`: + +``` +додано перевірку заведено +уже було перевірка цього типу вже є +не вдалося немає чим підставити {{address}} +``` + +Збіг ловиться по ТИПУ, а не по параметрах, хоч індекс рахує ще й +`md5(params)`. Індекс пропустив би другий `http.status` з іншим URL — і +хост лишився б із двома перевірками одного типу, яких у картці завести +неможливо. `ON CONFLICT DO NOTHING` лишається поруч як остання сітка: +без нього 23505 на одному хості обірвав би транзакцію для всієї +дільниці. + +Через це вікно після дії з перевірками не закривається: наслідок не +збігається з наміром, і показати його більше ніде. + +### Межі — ті самі + +Стеля та сама (`MaxBulkDevices`), право те саме (`devices:write` — усе це +людина робить у картці поштучно), рядок аудиту той самий +(`inv.device.bulk_update`), і в його `meta` лягає не лише намір, а й +наслідок: «просив додати сорока, додав дванадцятьом» — це те, заради +чого журнал читають. + +Автокеровані типи (`topology.identify`) не пропонуються ні на додавання, +ні на прибирання — так само, як у картці хоста: свій система вже завела, +а прибраний заведе знову. + +### Що показала жива база + +На стенді всі сім `icmp.ping` прийшли з шаблону, жодного ручного. Тобто +на цих даних «прибрати icmp.ping» чесно відповість «не було чого +прибирати» по всіх шести хостах — і це правильна відповідь, а не +бездіяльність: ручних перевірок цього типу там справді немає. + +--- + +## 2026-08-26 — Масові ручні перевірки, і два розходження в довіднику + +### Масова правка перевірок + +Автор масових дій свідомо пропустив ручні перевірки з міркуванням «вони +поодинокі за визначенням». Користувач не погодився — і мав рацію: +«додати `http.status` усім вебсерверам дільниці» це рівно масова дія. + +Три дії, кожна називає один тип чека: додати, прибрати, змінити наявні. +«Замінити весь набір» не роблено, і межа тут гостріша, ніж у груп: +групи видно в переліку хостів, набори перевірок не видно ніде. + +**Найважче в задачі — параметри.** Обов'язковий `url` у `http.status` +не має спільного значення для сорока хостів, а без нього чек не +заводиться. Заборонити типи з обов'язковими параметрами означало б +вбити рівно той випадок, з якого задача почалась. + +Розв'язано підстановкою на сам хост: `{{address}}`, `{{name}}`, +`{{fqdn}}`. Це єдине, чим хости в наборі законно різняться; будь-яке +інше «різне» — не масова дія, а сорок окремих рішень. Порожня +підстановка (хост без адреси) дає чесне «не вдалося» по цьому хосту, а +не порожній рядок у параметрі. + +**Дублі ловляться по ТИПУ, а не по параметрах.** `checks_uniq` рахує ще +й `md5(params)` і пропустив би другий `http.status` з іншим URL — хост +лишився б із двома чеками одного типу, яких у картці завести неможливо. + +Відповідь розрізняє «додано / уже було / не вдалося», і вікно після дії +не закривається: наслідок не збігається з наміром, а побачити його +більше ніде. + +### Довідник типів перевірок казав неправду про два з них + +Обидва розходження знайшлися, коли масову форму почали будувати з тієї +самої `params_schema`, що й картку хоста: там, де опис бреше, дві форми +брешуть однаково, і помітно це стає лише поруч. + +**`snmp.get`** описував `oids` як масив рядків. Насправді агент читає +туди об'єкти: OID, ключ метрики, одиниця, множник. Без ключа метрики +зібране значення нема куди покласти — тобто форма, побудована за старим +описом, давала чек, який мовчки нічого не збирав. Множник тут не +косметика: сенсори віддають десяті градуса цілим числом, і 235 замість +23.5 — це різниця між нормою й аварією. + +**`snmp.if`** значився таким, що його заводить людина. Насправді +перелік портів у params складає `EnsureInterfaceChecks` з того, що +знайшло автовиявлення: прибраний руками чек повернеться на наступному +такті, а доданий поверх наявного дасть другий чек того ж типу з іншим +`md5(params)` — унікальний індекс такого не ловить. Позначено +`auto_managed`. + +--- + +## 2026-08-26 — Помилка, яка тихо зносила автоматику + +`auto_managed` ховає тип чека з переліку для ручного додавання. Але +форма перевірок хоста надсилає ПОВНИЙ набір, а `SetDeviceChecks` +видаляє все, чого в ньому немає. + +Наслідок: форма більше не знала про автокеровані чеки — а отже не могла +їх «лишити». Кожне збереження форми тихо зносило розпізнавання хоста й +опитування портів, тобто рівно ту автоматику, заради якої їх і сховали. + +На живому стенді з шести хостів `topology.identify` лишився в одного, +`snmp.if` не лишилось узагалі, а метрики портів перестали оновлюватись. + +Виправлено з двох боків, і другий бік важливіший за перший. + +**Перше:** видалення більше не чіпає автокеровані типи. + +**Друге:** чек розпізнавання заводиться будь-якому хосту зі +SNMP-доступом, а не лише нерозпізнаному. Стара умова «ще не +розпізнаний» здавалась очевидною — чек потрібен, щоб хост розпізнати, — +і була хибною: коли чек зникав з іншої причини, він не повертався +НІКОЛИ. Хост уже розпізнаний, під умову не підпадає, і сторожова +функція (помітити заміну заліза) припинялась назавжди. Різниця між +«розпізнати» і «стежити» лишилась там, де їй місце, — в інтервалі: +дві хвилини проти шести годин. + +**Що з цього варто запам'ятати.** Помилка з'явилась не в новому коді, а +на стику: одна зміна (сховати тип) зробила хибним припущення іншої +(«форма надсилає все, що має лишитись»). Знайшлась вона лише тому, що +стан бази звірили з тим, який був три години тому, — з коду вона не +видно ні в тому місці, ні в іншому. + +### Заразом: агенти правили живі дані + +У журналі аудиту видно `ncm.command_run.delete` і `ncm.config.delete` на +бойовому стенді. Інструкція «живу базу не змінюй» дозволяла тлумачити +перевірку через власні ручки продукту як допустиму. Формулювання +посилено: заборона тепер перелічує способи (SQL, HTTP-ручки) і прямо +вимагає одноразової бази або транзакції з відкатом. + +--- + +## 2026-08-26 — Фільтри складу портів у шаблонах + +Користувач: «додай фільтри у шаблони, щоб не збирати на приклад усі +порти, а тільки по regex». У Zabbix це фільтр правила виявлення. + +Задача не теоретична. На стенді `JUN.QFX-Миронівка` віддає 100 портів, +з них під наглядом 98; `ПОЛЬОВЕ.7` — 57, і 29 із них насправді не +порти, а рядки «802.1Q Encapsulation Tag 0101». Сто портів по десять +OID у одному чеку — це сотні змінних за такт заради графіків, на які +ніхто ніколи не подивиться. + +### Фільтр належить шаблону + +«На QFX опитуємо лише xe- і ae-» — твердження про КЛАС заліза, а не про +конкретну коробку; рівно та причина, з якої шаблони існують узагалі. +Тому `tpl.templates.if_filter jsonb`, а не поле хоста. + +Фільтри кількох шаблонів одного хоста **обʼєднуються**, а не +перетинаються. Шаблони в цьому продукті лише ДОдаються (їх чіпляє +автовиявлення саме), і другий шаблон не сміє мовчки відібрати порти, +які просить перший: «OLT: gpon» плюс «Магістраль: 10G» на одному ZTE +мають дати і те, і те. Перетин дав би нуль і зламав би обидва шаблони +одночасно, не назвавши винного. Шаблон без фільтра в обʼєднанні не +бере участі: відсутність думки не є думкою «усе». + +### Зразки застосовує Go, а не Postgres + +Три причини, і жодна не про швидкість. + +**Крива дужка.** У `~` вона валить запит, а з ним транзакцію, всередині +якої перебудовується чек: snmp.if мовчки перестає оновлюватись на +кожному такті, і побачити це можна хіба в журналі сервера. У Go зразок +компілюється наперед, і помилка називає себе у формі шаблону, де її +щойно зробили. + +**Відкат.** RE2 його не має за побудовою. Бектрекінговий рушій +Postgres на випадковому `(a+)+b` здатен з'їсти хвилини всередині +транзакції, що тримає рядки `inv.interfaces`. + +**Попередній перегляд мусить казати правду.** Перегляд іде через HTTP, +тобто через Go. Якби бойове фільтрування жило в SQL, два рушії +розійшлися б на першому ж `\d` — у POSIX ERE це просто літера d. + +Той самий вибір і з тієї ж причини вже зроблено в `autoassign.go`, +`firstGroup`. + +Зразок компілюється з `(?i)`: Huawei пише `Vlanif10`, інші вендори — +`vlan10`, і фільтр, який промахнувся через велику літеру, помиляється +мовчки. Кому потрібен регістр — пише `(?-i)`. + +### Порожній результат — відмова, а не тиша + +Фільтр, який не пропустив жодного порту, — майже завжди помилка в +зразку. Порожній snmp.if-чек ззовні не відрізнити від справного, і +помічають його тоді, коли метрик портів немає вже тиждень. + +Тому три рівні: + +* **збереження шаблону** рахує обʼєднання фільтрів для КОЖНОГО + привʼязаного хоста й відмовляє з 409, називаючи хости й числа + («Леніна.21: 0 з 29»). Перевірка стоїть усередині тієї самої + транзакції — шаблон лишається таким, яким був; +* **перебудова після автовиявлення** не чіпає склад чека взагалі й + пише попередження: краще збирати зайве, ніж перестати збирати все; +* **реконсиляція** (розпізнавання хоста, масова правка) цю відмову + ковтає навмисно — там помилка одного фільтра поклала б увесь звіт, а + сказана вона вже тричі в інших місцях. + +Питання ставиться не «чи знайшов щось цей фільтр», а «чи лишиться хоч +один порт у хоста». Інакше два коректні шаблони заважали б зберегти +один одного. + +### Порт, який випав, каже про це сам + +`inv.interfaces.filtered_out_at` — коли цей порт перестав потрапляти в +чек. Метрики лишаються в історії, але оновлюватись перестають, і без +цієї мітки обрив графіка нічим не відрізнити від аварії. Окремо від +`monitored`: то ручний вимикач людини, і плутати його з наслідком +шаблону означало б, що вимкнений руками порт сам увімкнеться, щойно +хтось поправить зразок. + +У перегляді видно ще точніше число: скільки портів **зараз +опитуються** і після цього фільтра перестануть (`eligible && !matched +&& !filtered_out_at`). Саме воно лякає, і саме його треба бачити до +збереження. + +### Що показала перевірка на справжніх іменах + +Прогін заготовок по всіх 234 портах стенду: + +``` + JUN.QFX ZTE Леніна ПОЛЬОВЕ-СВ ПОЛЬОВЕ.7 +тип = ethernet|LAG 49/98 3/35 26/29 6/14 28/57 +Juniper xe-/ge-/ae- 44/98 0/35 0/29 0/14 0/57 +швидкість >= 10G 50/98 1/35 0/29 0/14 0/57 +має опис 27/98 1/35 29/29 0/14 0/57 +``` + +Три висновки, яких не було видно з коду. + +**Найнадійніша ознака — тип, а не імʼя.** Його задає RFC 2863, а не +маркетинг вендора: одна умова прибрала з D-Link усі 29 псевдопортів +802.1Q і «System», а з Juniper — усі `.0`-підінтерфейси (вони +propVirtual), тунелі й службові lsi/dsc/irb/vtep. І ніде не дала нуля. + +**«Тільки з описом» — пастка.** На Huawei опис заповнює сама прошивка +однаковим текстом для всіх портів і не відсіює нічого; на обох D-Link +описів немає взагалі, і фільтр дав би нуль. Обидва факти тепер написані +в підказці заготовки й зафіксовані тестом. + +**«Магістральні» порожні на трьох хостах із пʼяти.** Заготовка, яка +виглядає найбезпечнішою, — головний постачальник порожніх чеків. Без +відмови при збереженні її натиснули б і пішли. + +Заразом знайшлось `ae11` — заведений, але порожній LAG зі швидкістю 0. +Жоден зразок його не відрізняє від живого; очима в переліку портів це +видно за секунду. Тому перегляд показує перелік, а не лише число. + +### Стелі не чіпано + +`MaxInterfacesPerCheck = 256` лишилась як була — вона про розмір PDU, а +не про смак. Змінився лише `LIMIT` читання: він був 257 і відрізав би +порти ще до того, як зразок їх побачить, тобто `xe-0/0/47` для фільтра +просто не існувало б. Тепер читаємо до 4096, фільтруємо, і аж потім +обрізаємо. `sameInterfaceSet`, `monitored` і реконсиляція шаблонів +працюють як раніше. + +Базові правила складу портів (`monitored`, не loopback, не notPresent) +винесені в один рядок `ifEligibleSQL` на всі три місця: розійшовшись на +одному «і», перегляд і чек почали б відрізнятись рівно на ті порти, +через які людина й прийшла дивитись. + +--- + +## 2026-08-26 — Дзеркало архіву конфігів на зовнішньому Git + +Половина задачі була спроєктована ще в `0006_ncm.sql`: `ncm.repos` мала +`remote_url`, `remote_secret_id` і `mirror_enabled` з коментарем «Опційне +дзеркалювання на зовнішній Git». Жоден рядок Go їх не читав. Модель +виявилась придатною — добудовано, а не переписано. + +### Коли пушити + +Не після коміту. Push у `StoreConfig` коротший на десяток рядків і має +рівно один наслідок: доступність чужого сервера стає умовою успішного +бекапу. Збір — нічний розклад по сотнях пристроїв; недоступний Forgejo +перетворив би його на сотню таймаутів там, де все залізо відповіло, а +конфіг уже лежить у базі. + +Дзеркало — копія. Копія не має права ламати оригінал. Тому власний такт +у колекторі, поруч із прибиральником і з тих самих причин +(`grpcapi/ncm_mirror.go`), власне advisory-блокування, і витримка +повтору в БАЗІ, а не в пам'яті: процес перезапускають, і витримка, що +живе в пам'яті, після кожного релізу починала б шквал спроб рівно тоді, +коли ми щойно перезапустились. + +Такт хвилинний, але в мережу йде лише коли змінився відбиток локальних +посилань — або раз на шість годин про всяк випадок. Відбиток каже, чи +змінилось У НАС; він нічого не каже про те, чи ціле дзеркало. Без +періодичної звірки інсталяція зі стабільними конфігами вважала б копію +живою рівно доти, доки та не знадобиться. + +### Що бачить людина при тижневій відмові + +Найгірший варіант — мовчазне «все добре»: дзеркало заводять, щоб +пережити втрату диска, і дізнатись у день втрати, що воно тиждень +мовчало, — це не мати його взагалі. + +Тому три поверхні. Сторінка «Дзеркало» першим рядком показує «остання +копія: 7 днів тому» й текст останньої помилки. Після трьох невдач +поспіль (≈8 хвилин) піднімається алерт `ncm.mirror:` — на сторінку +алертів і в лічильник у шапці, тобто туди, куди дивляться щодня; +severity `high`, якщо копії немає понад добу. Знімається він першим же +вдалим обміном. І журнал аудиту: `ncm.mirror.update` / `ncm.mirror.push`. + +Стан видно з `ncm:read`, а міняти налаштування можна лише з новим правом +`ncm:mirror` (власник і адмін). Помітити відмову має могти той, хто +щодня дивиться на конфіги; вивозити архів за периметр — рішення рівня +організації. + +### Без -f + +Refspec шаблонний і без плюса: push або перемотується вперед, або +відмовляється з поясненням. Механізм, який уміє мовчки переписати +віддалену історію, — це не копія, а другий спосіб її втратити. Перезапис +існує окремою дією з підтвердженням і гасне після першого успіху +(`mirror_force_once`). Потрібен він рівно в одному випадку: локальний +репозиторій відновили з бази (`netpulse-gitsync`), коміти склались +наново, і їхні ідентифікатори не збігаються з тими, що колись поїхали. + +### Ключ SSH-сервера звіряється — на відміну від заліза + +Для мережевого обладнання в продукті ухвалено протилежне +(`agent/internal/ncmx/transport.go`), і там воно правильне: сотня +коробок, кожна перегенеровує ключ після прошивки. Тут один сервер, і +везе він за периметр архів із ключами та хешами паролів. Ключ +показується під час перевірки з'єднання, закріплює його людина +збереженням, далі кожен обмін звіряє. Без закріпленого ключа push по SSH +не збирається взагалі. + +Автоматичне TOFU виглядало б так само зручно й не перевіряло б нічого: +довіра діставалась би тому, хто відповів першим. + +### Токен + +`core.secrets`, AES-GCM, AAD `|ncm.mirror|` — той самий +механізм, що й для паролів від обладнання. Назад в API не повертається +ніколи: форма показує «збережено», порожнє поле означає «лишити як є». + +Окремо перевірено, що він не тече в текст помилки. go-git вкладає в +помилку повну адресу, а люди вставляють у поле адреси рядок виду +`https://токен@forgejo/…` з чужої інструкції — і тоді токен несе не +транспорт, а НАШ власний текст, зібраний із `r.URL`. Саме це й було +помилкою першої редакції; ловиться тестом +`TestExplainNeverEchoesURLCredentials`. + +### Розкладка під наступний вид даних + +Шлях у дереві складався у двох місцях (`StoreConfig` і `SyncGit`) — тепер +в одному, `store.ConfigPath`. Правило: `config_type` без скісної риски +дає `<хост>/<тип>.cfg` (усе наявне — байт у байт), зі скісною — +`<хост>/<шлях як є>`. Тобто набір файлів сервера +(`files/etc/nginx/nginx.conf`) лягає в наявне дерево наявної гілки, не +зсуваючи жодного вже надісланого шляху. Зсув шляху після першого push — +найдорожча правка з можливих: віддалене дерево прийме її лише примусом. + +Дзеркалення це переживає без жодної правки: refspec шаблонний, а не +перелік гілок. + +### Що показала жива перевірка + +Одноразові Gitea й Postgres у власних контейнерах (прибрані). Перший +повний push 9,5 МБ тексту в чотирьох гілках — 1,5 с; повторний без змін +— 286 мс без мережевої роботи; приріст на одну версію — 2,5 с. Хибний +токен, токен в адресі, підмінений ключ хоста, розбіжність дерев і +свідомий перезапис — усі п'ять дали очікуваний текст, у жодному токена +немає. + + +--- + +## 2026-08-26 — Дзеркало, гілки й чотири помилки, знайдені ним + +Перше вмикання дзеркалення на зовнішній Forgejo показало більше, ніж +мало б: щоб дзеркалити, треба спершу мати що. + +### Git-архів не працював ніколи + +Том `/var/lib/netpulse` належав root, а процес працює під uid 10001. Він +не міг створити в ньому навіть підкаталогу — і кожен коміт конфігу тихо +не відбувався: у сховищі лишався контентний хеш замість SHA коміту, а +репозиторію не існувало взагалі. + +Причина в тому, що іменований том успадковує права каталогу з образу при +першому монтуванні. Каталог створювався від root — том теж. Дефект +прожив із самого розгортання й був невидимий, бо збір конфігів від нього +не залежить: база лишається джерелом істини, і все працювало. + +Каталог тепер створюється в образі під потрібним користувачем. + +### Кирилиця в шляхах + +`sanitizePath` викидала все, крім латиниці, і від українських імен +лишались хвости: «ZTE Миронівка» → `ZTE`, «Леніна.21» → `21`, +«ПОЛЬОВЕ-СВ» → `device`. Той, хто склонував би дзеркало, побачив би +каталоги «1», «7» і «21». + +Git зберігає шляхи в UTF-8 і кирилиці не боїться — боялась її лише ця +функція. Закріплено тестом на справжніх іменах зі стенду. + +Показово, що тест на розкладку вже існував — і закріплював помилку як +даність, «не тому, що гарно, а тому, що вже в історії». Міркування +правильне, висновок ні: історії ще не було, дзеркало не створене. Це був +останній момент, коли виправлення коштувало нічого. + +### Гілки за UUID + +У коді стояло свідоме рішення: гілка `device/`, «щоб пережити +перейменування, інакше історія розсипається на дві». Правильне +міркування з наслідком, якого не передбачили: у Forgejo двадцять рядків +UUID, і знайти серед них свій комутатор неможливо ані за іменем, ані за +адресою. Пошук по гілках — перше, чим користуються в дзеркалі. + +Тепер `device/<ім'я>-<адреса>`, а стабільність забезпечується інакше: +при зміні імені гілка ПЕРЕЙМЕНОВУЄТЬСЯ (`gitstore.RenameBranch`), а не +заводиться друга. Історія лишається однією. + +Адреса в імені не косметика: у мережах повно хостів «Комутатор» і «ONU». + +### Заразом перевірилось те, заради чого писався gitsync + +Локальний репозиторій видалено цілком і зібрано з нуля: 10 версій із 10. +Заявлене «база — джерело істини, Git повністю відтворюваний» виявилось +робочим шляхом, а не обіцянкою в коментарі. + +### Порівняння версій не працювало + +Порівнювати можна було лише зі СТАРІШОЮ версією — з правильного +міркування, що diff «уперед» читався б задом наперед. На живому стенді з +цього вийшло, що порівняння не працює зовсім: у чотирьох хостів із шести +версія одна, а в решти двох треба вгадати, яку з двох натиснути. + +Обмеження знято, міркування збережено інакше: старіша з двох завжди стає +лівим боком, незалежно від того, з якого боку до пари підійшли. + +### Прибирання шаблону лишало правила + +Перевірки зникали справно, а правила з тригерів — ні: реконсиляція йшла +лише по НОВОМУ набору шаблонів, тож відчеплений ніхто не переглядав. +Виходило найгірше поєднання: шаблон прибрано, перевірок немає, а тригер +із нього досі піднімає алерти — і знайти джерело нема де. Тепер +переглядається об'єднання старого й нового набору. + +### Відповідність не розрізняла типи конфігів + +Перевірка брала просто найсвіжішу версію хоста. Поки в кожного один тип +(`running`), це працює — і працює випадково. Щойно на сервері з'явиться +набір файлів під типом `files`, правило для заліза почне перевіряти його +й чесно повідомить про порушення там, де його немає. + +Правило тепер має тип (міграція 0056, типово `running`), а хост без +конфігу цього типу під нього просто не підпадає — це не порушення. +Виправлено до появи першого правила, тож мігрувати нічого не довелось. + +## 2026-08-27 — Сім задач одним заходом: що спільного виявилось у всіх + +Сім міграцій (0058–0064) робились паралельно, і за задумом їх поєднував +лише перелік справ. За фактом — дещо інше, і це варте окремого запису. + +**Схема обіцяла те, чого не існувало, у трьох місцях одразу.** Тип +перевірки `snmp.walk` стояв у довіднику з міграції 0010, а модуля на +зонді не було ніколи — чек повертався з `no_module`. Таблиця +`ncm.rollbacks` лежала зі схемою двоетапного погодження й без жодного +рядка коду. Повідомлення `ConfigApplyJob` і `ConfigApplyResult` були в +протоколі від першого дня, з гілками в обидва боки, і без обробників по +обидва боки. + +Жодне з трьох не помічалось, бо все виглядало готовим: у довіднику є, у +схемі є, у протоколі є. Обіцянка, записана в схему, читається як +виконана — і це, схоже, головний спосіб, у який цей проєкт себе +обманює. + +**Політики RLS написані на 68 таблицях і не спрацювали жодного разу.** +Застосунок ходить у базу суперкористувачем. Про це вже було відомо: у +`0050_audit_read.sql:188` це записано прямим текстом — діру помітили, +обійшли в одному місці й пішли далі. Знайдене й не полагоджене за рік +стає нормою. + +**Правило з подієвим джерелом було зламане з обох кінців.** Воно не +могло спрацювати (движок рахував три джерела з дев'яти) — і навіть якби +спрацювало, `ResolveMissing` закрив би такий алерт наступним тіком, бо +порожній перелік кандидатів він читає як «проблема зникла». + +**Тест, який нічого не перевіряв.** `TestDeleteRemoteRefIsTargeted` +складав адресу як `file://` + шлях. На Linux провідна скісна в шляху вже +є, і виходить правильне `file:///tmp/…`. На Windows виходить +`file://C:/…`, де `C:` — це ХОСТ. Тест падав на першому push, тобто +твердження, заради якого він написаний (що видалення гілки адресне й не +чіпає сусідню), не перевірялось узагалі. Списувалось на середовище. + +**І одна хибна передумова — моя.** Ставлячи задачу про очисник даних, я +записав «зараз немає жодного механізму, який щось прибирає». Насправді +стиснення налаштоване на восьми гіпертаблицях, видалення за строком на +дев'яти, і половина роботи звелась до того, щоб їх не зламати. Три +таблиці справді ростуть назавжди — але це не «немає нічого», це «є, і в +ньому дірки». Різниця істотна: перше веде до того, щоб написати своє +поверх чужого. + +### Що з цього випливає для порядку робіт + +Спільна риса всіх шести знахідок — не помилка в коді, а **впевнений +вигляд там, де нічого немає**. Схема, довідник, протокол, зелений тест, +увімкнена політика безпеки. Кожне з них — інтерфейс, за яким мала б +стояти реалізація, і кожне читається як доказ, що вона стоїть. + +Тому в чотирьох із семи задач головним результатом стала не можливість, +а ВІДМОВА: правило з нереалізованим джерелом більше не зберігається, +профіль без команд заливки каже про це замість мовчазної кнопки, +міграція RLS валить сама себе на таблиці з `tenant_id` без політики, +тест словника аудиту падає на ключі без назви. Кожна з цих відмов +закриває не окрему помилку, а спосіб, у який така помилка з'являється +непоміченою. + + +## 2026-08-27 — Подієві алерти: тригери на журнал, конфіги й відповідність + +### Що було зламано + +Движок правил рахував рівно три джерела: `metric`, `icmp`, `interface`. +Для `syslog`, `trap`, `ncm`, `agent`, `compliance` і `link` `EvaluateRule` +свідомо повертав порожньо. При цьому і форма правил, і редактор тригерів +шаблону дозволяли завести правило з такими джерелами без єдиного слова +застереження. + +Наслідок — не «функція не працює», а гірше: людина заводила тригер +«конфіг змінився», бачила його в переліку ввімкненим, із нулем активних +алертів, і читала цей нуль як «змін не було». Відсутньої функції шукають +обхідний шлях; функція, яка виглядає працюючою, обхідного шляху не шукає +ніхто. + +Знайшлася там же й друга, тихіша поломка: якби подієвий алерт усе ж +з'явився, наступний тік движка його б закрив. `EvaluateRule` для такого +правила віддавав порожній список кандидатів, а `ResolveMissing` слідом +закривав усе, чого в цьому списку немає. З погляду опитування алерт +«зник» — хоча зникнути він не може за побудовою. + +### Чому подієвий шлях, а не ще одне опитування + +Опитуване джерело має ряд вимірів: питання «чи виконується умова зараз» +осмислене, і відповідь може змінитися будь-коли. Тому вікно `for_seconds` +— це запит по часу, а гасіння — різниця множин. + +У журналу, конфігів і відповідності такого ряду немає. Питати «чи +змінився конфіг зараз» безглуздо: він змінився о 10:42 і більше нічого +про це не скаже. Опитування `ts.syslog` розкладом було б і дорожчим +(гіпертаблиця на мільярд рядків), і брехливішим — вікно опитування +завжди або пропускає події, або рахує їх двічі. + +Тому правило перевіряється рівно в ту мить, коли подія надійшла, у тому +процесі, який її прийняв: + +* `syslog` — `grpcapi.StreamLogs`, одразу після запису пачки, поруч із + наявним тригером позачергового бекапу; +* `ncm` — шлях завантаження конфігу: `changed` після `StoreConfig`, якщо + версія справді інша, і `backup_failed` у кожній гілці, де завдання + закривається як провалене; +* `compliance` — прогін `RunCompliance`, який запускають з UI. + +### Чотири рішення, які довелося ухвалити явно + +**Дедуплікація.** Один алерт на пару «правило + хост», скільки б подій під +нього не підпало. Ключ (`EventDedupKey`) навмисно не містить нічого від +самої події й збігається формою з ключем метричного алерту — два різні +формати рано чи пізно дали б два алерти про одне. Захищає той самий +унікальний індекс `alerts_active_dedup_uniq`, що й раніше. + +Але злиття не має ховати масштаб: «порт мигнув один раз» і «порт мигнув +4000 разів за годину» — різні аварії з однаковим текстом. Тому з'явився +`alr.alerts.event_count`, і його видно на дошці алертів. + +**Частота.** Дедуплікації мало: без обмежувача потік журналу дав би сотню +UPSERT-ів на секунду. Тому, по-перше, пачка рядків зводиться до +звернення до бази (`OnSyslog` збирає збіги в мапу за парою +«правило + хост»), по-друге, між двома зверненнями до одного алерту +витримується `min_interval_seconds` правила. Пропущені за цей час події +не викидаються — вони накопичуються в обмежувачі й доливаються в +лічильник наступним зверненням: економимо запити, а не правду. + +**Гасіння.** Подієвий алерт не має способу зникнути сам, і вдавати, що +має, було б брехнею. Його закриває: + +* людина — наявними `Ack` / `Close`; +* строк — `ExpireEventAlerts`, стан `expired`, а не `resolved`. Різниця + принципова: ніхто не казав, що проблему полагодили, вона просто + відстоялась. `expired` в `alr.alert_state` існував із 0007 і досі не + використовувався жодним кодом. +* виняток — відповідність. Там прогін перевіряє всі хости під правилом і + каже про кожен «пройшов» або «ні», тобто має справжній сигнал зняття. + Такий алерт закривається сам (`ResolveEventAlerts`), і це не виняток із + правила, а наслідок того, що дані інші. + +Строк за замовчуванням — доба: «встиг побачити на наступній зміні». +Менше — ризик, що нічну подію ніхто не побачить; більше — накопичення +без користі, бо сама подія лежить і в журналі, і в історії алертів. Нуль +дозволений і означає «закриє людина». + +**Доставка.** Подія приходить у `netpulse-server` (gRPC для зондів), а +ключі шифрування каналів, маршрути й тихі години читає `netpulse-api`, +де крутиться движок. Розсилати з обох процесів означало б дублювати +повідомлення й розводити дві копії маршрутизації. Тому подієвий шлях +лише піднімає алерт із позначкою `notify_pending`, а розсилає його движок +наступним тіком — під тим самим advisory-блокуванням, тобто рівно в +одному екземплярі. `TakeNotifyPending` знімає позначку тією ж командою, +що й повертає рядки: інакше два інстанси API розбудили б людину двічі. + +Плата — затримка до одного тіку (30 с). Для «конфіг змінився» чи +«порушено стандарт» це прийнятно: жодне з них не є аварією, на яку +біжать за секунди. + +### Джерела, яких свідомо не буде + +`trap`, `link` і `agent` не реалізовані, і тепер API відмовляє у їх +вмиканні з людським поясненням замість мовчазного збереження: + +* `trap` — трап приїжджає як OID і набір varbind-ів. Без словника MIB + умова звелася б до порівняння цифр із крапками, яких людина не набере + з голови. Це була б друга мовчазна обіцянка замість першої. +* `link` — лінк на мапі не має власних вимірів: він живий рівно + настільки, наскільки живі його кінці. Правило про лінк — це правило + про пристрої, і воно вже є. +* `agent` — «зонд не на зв'язку» — це стан, а не подія; він рахується + опитуванням `last_seen_at`. Місце йому серед метричних правил. + +Відмова стосується лише **ввімкнених** правил і тригерів. Шаблон +редагується як документ цілком, і якби нереалізоване джерело блокувало +збереження, людина з одним старим тригером на трапи не могла б виправити +жоден інший. Вимкнений тригер нічого не обіцяє — він не розгортається в +правило взагалі. + +Наявні правила з цими джерелами міграція **вимикає, а не видаляє**. +Видалення забрало б і текст, і саму згадку про намір; вимкнене правило +лишається на екрані сірим — найкоротший спосіб сказати «воно не +працювало й не працює», не забравши нічого. + +### Чому перевірка умови стоїть на записі + +Той самий аргумент, що й у правил відповідності (0056): про друкарську +помилку в регулярному виразі людина має дізнатися з форми, а не з +тригера, який рік мовчав. `ValidateRuleCondition` компілює зразок +`syslog`, вимагає явної події для `ncm` і живе в `store`, а не в +HTTP-шарі, — бо ту саму відповідь має дати й збереження тригера шаблону, +а два різні пояснення тієї самої відмови розходяться на першій же правці. + +### Міграція 0058 + +* `alr.rules.auto_close_seconds`, `alr.rules.min_interval_seconds`; +* `alr.alerts.event_count`, `alr.alerts.notify_pending` + два часткові + індекси під вибірки «що розіслати» й «що прострочено»; +* наявним подієвим правилам проставлено добовий строк; +* правила й тригери шаблонів із джерелами `trap`/`link`/`agent` вимкнено. + +### Інтерфейс + +Форма тригера тепер пропонує рівно ті джерела, які працюють, і для +подієвих показує їхні власні поля (зразок і рівень syslog, подія конфігу, +поріг серйозності знахідки) замість порогу й агрегації, яких у них немає. +Витримка й «враховувати топологію» для подієвих правил не питаються: перше +не має сенсу без вікна, друге питає, які хости зараз лежать, а подія хоста +не вкладає. + +Під вибором джерела для подієвих правил стоїть прямий текст про те, що +такий алерт не зникає сам. Це і є те, що система мала сказати з самого +початку. + +### Файли + +* `server/migrations/0058_event_alerts.sql` +* `server/internal/store/alerts_events.go` (новий) — класифікація джерел, + перевірка умов, `RaiseEventAlert`, `ExpireEventAlerts`, + `TakeNotifyPending`, `ResolveEventAlerts` +* `server/internal/alerting/events.go` (новий) — `EventSink`: кеш правил і + селекторів по кабінету, зведення пачок, обмежувач частоти +* `server/internal/alerting/engine.go` — подієві джерела вилучено з + циклу опитування; додано гасіння прострочених і розсилку черги +* `server/internal/store/alerts.go`, `alerts_state.go`, `alerts_query.go`, + `template_triggers.go`, `ncm_compliance.go` +* `server/internal/grpcapi/service.go`, `streams.go` +* `server/internal/httpapi/alerts.go`, `templates.go`, `compliance.go`, + `server.go` +* `server/cmd/netpulse-api/main.go`, `server/cmd/netpulse-server/main.go` +* `web/src/pages/RulesPage.tsx`, `web/src/pages/AlertsPage.tsx`, + `web/src/components/TriggerEditor.tsx`, `web/src/types.ts`, + `web/src/api/client.ts` +* тести: `server/internal/store/alerts_events_test.go`, + `server/internal/alerting/events_test.go` + +## 2026-08-27 — `snmp.walk` і прототипи елементів шаблону + +Дві речі, які досі були обіцянкою. + +Перша: тип `snmp.walk` стоїть у довіднику `core.check_types` від +міграції 0010, а модуля на зонді немає. Чек такого типу заводився, +потрапляв у план і повертався з `no_module` — система обіцяла вміння, +якого не мала, і дізнатися про це можна було лише завівши такий чек. + +Друга: шаблон описує СТАЛИЙ перелік метрик. Для `sysUpTime` цього +досить, але половина того, за чим справді стежать, лежить у таблицях зі +змінним індексом — диски, сенсори температури, VLAN, елементи стека, +оптичні модулі. Скільки їх і під якими індексами — властивість +конкретної коробки, а не класу заліза, і в шаблон її не впишеш. + +Досі єдиний такий випадок — порти — покривав захардкоджений Go +(`EnsureInterfaceChecks`). Це працює рівно доти, доки випадок один: +наступний вендор із власною таблицею вимагав би нового Go-коду, релізу +сервера й ще однієї міграції. Шаблони писались саме щоб цього не було. + +### Прототип — це два речення + +1. «Рядки цієї таблиці шукай обходом ось цього OID»; +2. «На кожен рядок збери ось ці метрики, підставивши індекс». + +Між ними стоїть сервер. Він тримає перелік знайдених рядків у +`tpl.discovered_rows` і перебудовує склад `snmp.get`-чека, коли рядки +з'являються або зникають. + +Це рівно той поділ праці, який уже є для портів: модуль `topology` +знаходить, `inv.interfaces` зберігає, `EnsureInterfaceChecks` +перебудовує чек. Різниця одна й вона головна — тут «що саме шукати» +описує шаблон, а не Go. + +### Чому зонд не збирає все сам + +Спокуса була: обійти таблицю й одразу зібрати по ній значення — тоді +рядки з'являлися б без жодної затримки, а серверу не треба було б нічого +пам'ятати. + +Відкинуто з тієї ж причини, з якої `snmp.if` отримує перелік портів у +параметрах, а не ходить по `ifTable` сам: зонд не має права вирішувати, +що опитувати. Це впирається в ліміти тарифу й у те, що оператор мусить +бачити склад чека до того, як він поїде в мережу. Крім того, метрики +пішли б повз `snmp.get` — другим, паралельним шляхом із власним +розкладом і власними багами поруч із наявним. + +### Два інтервали, а не один + +У прототипа `discovery_sec` (типово година) і `interval_sec` (типово +хвилина), і це не надмір налаштувань. + +Обхід таблиці коштує стільки пакетів, скільки в ній рядків, а диски й +сенсори не з'являються щохвилини. Збір значень — звичайна метрика. +Один інтервал на обидві дії означав би або щохвилинний walk усього +піддерева, або графік, що з'являється через годину після того, як у +сервер вставили диск. + +Наслідок у схемі: на прототип виходить два чеки — рідкий `snmp.walk` і +частий `snmp.get`. Обходи одного шаблону з однаковою частотою при цьому +злипаються в один чек: одна SNMP-сесія на всі піддерева, рівно з тієї ж +причини, з якої в один чек збираються OID звичайних елементів. + +### Фільтр рядків — той самий рушій, що фільтр портів + +Механіка з 0051 не дублювалась, а узагальнилась. `compiledCondition` +тепер судить не про структуру порту, а про інтерфейс `filterCandidate`; +набір допустимих полів став параметром компіляції. Портам лишились +`name/alias/type/speed`, рядкам дісталися `index/name`. + +Так навмисно: другий примірник цієї механіки розійшовся б із першим на +першому ж виправленні, і «не збігається» почало б означати різне у двох +сусідніх вкладках однієї форми. Заразом безкоштовно приїхали властивості, +за які вже заплачено, — RE2 без відкату, `(?i)` типово, помилка в зразку +називає себе у формі, а не через добу серед порожніх графіків. + +Полів у рядка рівно два, і це не тимчасове звуження: більше про рядок +таблиці не знає ніхто. Умова `ge/le` на неіснуючу швидкість тепер не +компілюється — інакше вона мовчки не пропускала б жодного рядка. + +### Рядок, що зник, мусить пояснювати обрив графіка + +`tpl.discovered_rows` має дві позначки замість видалення: + +* `gone_at` — рядка більше немає в таблиці (диск вийняли); +* `filtered_out_at` — рядок є, але фільтр прототипу його не пропускає. + +Причина та сама, що в `filtered_out_at` для портів у 0051: метрики +лишаються в історії, і людина, яка дивиться на обрив, мусить мати спосіб +дізнатися, що сталось. Видалений рядок відповів би мовчанням. Події +розділені, бо дії різні: перше виправляють у шафі, друге — у формі +шаблону. + +Обрізаний обхід (`truncated`) не дає права нікого ховати: до решти +рядків просто не дійшли, і позначити їх зниклими означало б стерти +половину графіків через власну стелю. + +### Рядки тримаються за КЛЮЧ прототипу, а не за його id + +`SaveTemplate` переписує прототипи цілком — так само, як елементи, — тож +їхні `id` живуть рівно до наступного натискання «Зберегти». Прив'язка до +`id` означала б, що правка назви прототипу обриває всі графіки дисків до +наступного обходу, тобто годину порожнечі за одну зайву літеру. + +### Три секунди типового таймаута тут не годяться + +`core.checks.timeout_ms` типово 3000, і для одного `Get` цього досить. +Обхід триває стільки, скільки в таблиці рядків: 64 диски — це щонайменше +три BulkGet-и поспіль, а до віддаленого вузла й більше. Чек, що не +встиг, віддає «таймаут», сервер не бачить жодного рядка й нікого не +заводить — прототип мовчки не працює взагалі, і причина не написана ніде. + +Тому обхід отримує 20 секунд, а збір значень по рядках — 15, рівно +стільки ж, скільки `EnsureInterfaceChecks` дає чеку портів із тим самим +порядком кількості OID. Таймаут переписується і при оновленні чека, а не +лише при створенні: інакше чек, заведений із помилковим значенням, ніс +би його вічно. + +### Помилка 0046 не повторена + +`params_schema` для `snmp.walk` описувала чек, якого ніколи не існувало: +один `oid` і `max_rows` — без відповіді на питання, ЧИЇ це рядки й куди +їх покласти. Схему звірено з `WalkParams` у Go рядок за рядком. + +Заразом виправлено `snmp.get`: сервер почав писати `labels` (без них два +диски злилися б в один графік — ряди розрізняє `ts.series.labels_hash`), +і схема мусила сказати про це в тій самій міграції. `omitempty` на полі +не косметика: без нього `params` усіх наявних `snmp.get`-чеків отримали +б зайве `"labels":null`, інший `md5(params)`, інший хеш плану й +одноразовий перезалив усього парку заради нічого. + +### Вбудований прототип рівно один і вимкнений + +`snmp-host-resources` — вбудований шаблон, спільний для всіх тенантів і +вже причеплений до хостів на робочих стендах. Увімкнений прототип завів +би їм по два нові чеки МОВЧКИ, під час міграції, без жодного рішення +людини; перше, що побачив би адміністратор, — приріст SNMP-трафіку +невідомо звідки. + +Вимкнений він робить рівно те, заради чого стоїть: показує в редакторі, +як прототип виглядає зібраним, і копіюється разом із шаблоном. +Реконсиляція його не бачить, тож ціна для наявних стендів — нуль. + +### Інтерфейси прототипом НЕ виражені + +Спокуса очевидна: порти — це теж таблиця зі змінним індексом, і зняти +хардкод із `autochecks.go` було б красиво. Не зроблено, і це рішення, а +не брак часу. + +`snmp.if` — не набір метрик. Він повертає `InterfaceCounters`: агент +тримає попередній замір, рахує швидкості за ФАКТИЧНИМ інтервалом, +ловить перевертання лічильника й ділить на номінальну швидкість порту. +Прототип віддав би сирі октети без жодного з цих чотирьох умінь. + +Далі — прив'язки. Лічильники лягають у `ts.if_counters` за +`interface_id`, а не в `ts.samples` за міткою. На цьому `interface_id` +тримаються анімація трафіку на мапі, інспектор лінка, `filtered_out_at` +із 0051 і правила тригерів із джерелом `interface`. Прототип породжує +серії з мітками й без `interface_id` — переїзд означав би переписати +все перелічене й перенести історію. + +І нарешті ціна помилки. На стенді шість хостів із робочими +`snmp.if`-чеками. Міграція, яка спробувала б замінити їх чеками +прототипу, у найкращому разі обірвала б графіки трафіку, у гіршому — +лишила б обидва набори чеків одночасно й подвоїла опитування. Виграш — +мінус двісті рядків Go; ризик — єдине, що на цьому продукті бачать +щодня. + +Хардкод лишається там, де він є, і тепер має поруч чесний коментар про +те, чому саме він окремий випадок. + +### Що змінено + +Зонд: + +* `agent/internal/modules/snmp/walk.go` — новий модуль: обхід піддерева, + індекс + назва рядка, стелі, дедлайн, окрема доля кожного прототипу; +* `agent/internal/modules/snmp/snmp.go` — `snmp.walk` у `CheckTypes()` + і в диспетчері; +* `agent/internal/modules/snmp/walk_test.go`. + +Сервер: + +* `server/migrations/0059_template_prototypes.sql` — `tpl.item_prototypes`, + `tpl.discovered_rows`, RLS, правдива `params_schema` для `snmp.walk` + і `snmp.get`, `auto_managed`, вимкнений вбудований прототип; +* `server/internal/store/prototypes.go` — опис, реконсиляція, приймання + рядків, розгортання прототипу в `oidSpec`; +* `server/internal/store/iffilter.go` — узагальнення рушія фільтрів; +* `server/internal/store/templates.go` — прототипи в читанні, збереженні, + реконсиляції, експорті й клонуванні; `labels` в `oidSpec`; +* `server/internal/store/prototypes_test.go`; +* `server/internal/httpapi/templates.go` — прототипи в тілі збереження + й перевірка зразків там, де їх щойно надрукували; +* `server/internal/grpcapi/streams.go` — приймання `payload_json` від + `snmp.walk`. + +Веб: + +* `web/src/components/PrototypeEditor.tsx` — нова вкладка редактора; +* `web/src/pages/TemplatesPage.tsx`, `web/src/types.ts`. + +### Чого немає + +Попереднього перегляду рядків у формі, на відміну від фільтра портів. +Порти вже лежать в інвентарі, і зразок можна прикласти до них не +виходячи з форми; рядків чужої таблиці на сервері немає доти, доки зонд +не зробить обхід. Показати їх у формі можна було б лише збігавши на +живий пристрій просто з браузера — це окрема ручка з окремою моделлю +доступів, і вона не мала б бути умовою появи прототипів. + +# 0060 — Відкат конфігу: від наміру до запису на пристрій + +Таблиця `ncm.rollbacks` стояла в базі з 0006 — з наміром, двоетапним +погодженням і полем «команди, які реально підуть на пристрій». Коду під +нею не було жодного рядка. Тобто система вміла зібрати конфіг, зберегти +версію, показати різницю, віддзеркалити в Git і перевірити на +відповідність — і не вміла єдиного, заради чого все це збирають: +**повернути** те, що працювало вчора. + +Це не «ще одна функція NCM». Це те, чим Enterprise-тариф відрізняється +від Oxidized: Oxidized теж збирає й версіонує, але на питання «поверни +як було» відповідає «ось файл, іди набирай». + +--- + +## Чому заливка — це не «надіслати файл на пристрій» + +Спокуса зробити відкат як «взяти збережений конфіг і віддати його +пристрою цілком» велика й хибна на всіх родинах, які є в мережі. + +CLI мережевого заліза не має режиму «замінити конфіг на оцей». Рядки, +віддані в режимі конфігурації, **додаються** до наявного. Хост, у якому +вчора помилково створили VLAN, після такої «заливки» матиме і вчорашній +конфіг, і той VLAN — рівно те, від чого відкочувались. Відкат, який не +прибирає зайве, відкатом не є. + +Тому на пристрій їде **різниця**: + +* рядків бракує — ідуть як є; +* рядки зайві — ідуть із префіксом заперечення родини (`no `, `undo `); +* родина заперечення не має — рядки **не вигадуються**, а показуються + людині окремим переліком «прибрати вручну». + +Останнє — про D-Link, і це не пропуск. У D-Link немає універсального +`no`: створене прибирається `delete`, налаштоване переписується +`config`, увімкнене вимикається `disable`. Вивести з рядка конфігу +потрібне дієслово автоматично неможливо — `create vlan v10 tag 10` +прибирається як `delete vlan v10`, і жодне механічне правило цього не +дасть. Тому система заливає лише додане й змінене, а зайве віддає +людині — з попередженням **до** підтвердження, а не з розбіжністю після. + +## Чому контекст відновлюється за відступом + +Рядок ` ip address 10.0.0.1 255.255.255.0` сам собою не означає нічого: +він належить інтерфейсу, назва якого стоїть вище й без відступу. Віддати +його без батьківського рядка означає віддати його в глобальний режим — і +отримати або помилку, або, що гірше, зміну не того об'єкта. + +Тому кожна зміна їде разом зі своїм ланцюжком батьків, а після блока — +`exit`. Відступ як ознака вкладеності — властивість самих конфігів +(Cisco, ZTE, Huawei друкують ієрархію відступами), а не наш здогад; +родини без ієрархії (D-Link) просто не мають відступів, і той самий код +дає для них плаский перелік. + +У межах одного блока спершу йдуть заперечення, потім додавання. Зміна +значення виглядає в різниці як пара «зайвий рядок / потрібний рядок»; +якби спершу додати новий, частина платформ відкинула б його як конфлікт +зі старим, а наступне заперечення прибрало б уже обидва. + +--- + +## Безпека + +### Погодження не обходиться + +Політика (`ncm.rollback_policy`) типово вимагає **другої людини** й +типово забороняє самопогодження. Знімок політики лежить у самому намірі: +вимкнення вимоги заднім числом не легалізує те, що вже висить у черзі. + +Вимикач цієї вимоги навмисно лежить під **іншим правом** +(`settings:write`), ніж сам відкат (`ncm:rollback`). Інакше двоетапність +зводиться до однієї галочки, яку знімає той самий, кого вона мала +стримати. Погодити машинним токеном не можна взагалі: погодження — це +підпис людини, а токен інтеграції звів би двоетапність до другого curl +із того самого скрипта. + +### Паролі не потрапляють ані в базу, ані в стенограму + +Правило проєкту («у стенограму пишуться лише байти, які надіслав сам +пристрій») лишилось чинним — і саме тому знадобився другий захист. +Заливка додає ризик, якого не було в зборі: пристрій **відлунює** те, що +ми йому шлемо, а шлемо ми рядки конфігу, зокрема з паролями. Ті байти +приходять від пристрою, тож у стенограму лягають законно, і зонд +прибрати їх не може — він не знає, які рядки в цієї родини секретні. + +Тому зроблено дві речі. + +1. У `ncm.rollbacks.commands` план лежить **замаскованим** + (`redact_patterns` профілю плюс запасний перелік). Туди ж, у + замаскованому вигляді, він іде в журнал аудиту. Тіла конфігів + зашифровані в `core.secrets`, і покласти ті самі рядки відкритим + текстом у сусідню таблицю означало б обійти власне шифрування. +2. Стенограма й вивід кожної команди маскуються перед записом у базу. + +Маскується сам збіг, а не весь рядок: людині при погодженні треба +бачити, **що** змінюється («буде переписано пароль на vty»), і рядок із +самих зірочок цього не каже. + +Наслідок: справжній план у базі не зберігається взагалі. Він +**перебудовується** з зашифрованих тіл у мить відправки, а звіряється +підписом (`plan_hash`), знятим при погодженні. Побічний ефект виявився +головним запобіжником: перебудова йде від **поточного** стану хоста, тож +якщо між погодженням і відправкою конфіг на пристрої змінився — план +вийде інший, підпис не збіжиться, і відкат не поїде. «Погодили одне, +виконали інше» стає неможливим не за домовленістю, а механічно. + +### Після заливки — обов'язковий контрольний збір + +Відповідь CLI не є доказом. Пристрій відповідає рядком тексту, і +мовчазна згода означає «прочитав», а не «застосував»: половина платформ +мовчки ігнорує рядок, який не підходить до поточного контексту. Вірити +виводу — це той самий клас помилки, що колись дав «Next possible +completions» у ролі версії конфігу в архіві (0034, 0043). + +Тому після заливки система йде й **знімає конфіг заново**, будує з нього +той самий план ще раз і дивиться, чи лишилось що робити: + +* нуль команд → `applied`, відкат справді відбувся; +* не нуль → `mismatch`, і це число видно в інтерфейсі. + +Порівняння хешів для цього не годиться: свіжий конфіг ніколи не +збігається з архівним побайтово (інший час, uptime, лічильники), тож +хеш казав би «не збіглось» завжди, і стан `mismatch` за тиждень +перестали б читати. План же порівнює рівно те, що вміє залити — а рядки +під `scrub_patterns` профілю з порівняння викидаються з тієї ж причини. + +Контрольний збір ставиться **власним** завданням, без дедуплікації, на +відміну від звичайного `EnqueueConfigJob`. Збір, що вже виконується, міг +початись до заливки, і його результат відповів би на питання, якого ми +не ставили. + +### Обрив зв'язку посеред заливки + +Найгірший стан, у якому може опинитись вузол: половина команд на +пристрої, друга — ні, а результат не приїде ніколи. Найгірша реакція на +нього — записати `failed` і заспокоїтись, бо `failed` читається як +«нічого не сталось». + +Тому прибиральник переводить такий намір не у відмову, а в `verifying`: +ставить контрольний збір і дивиться, що реально стало на пристрої. +Відповідь буде або `applied` (устигло все), або `mismatch` із числом +недоробленого — і в обох випадках це знання, а не здогад. Пристрій +поруч, його можна спитати. + +Зонд зі свого боку робить три речі, які цьому допомагають: + +* зупиняється на **першій** помилці (доливати рядки в конфіг, який уже + пішов не тим шляхом, означає отримати стан, якого не описує жоден із + двох конфігів); +* віддає звіт **завжди**, навіть якщо обірвалось усе — у ньому видно, що + саме встигло виконатись; +* виконує команду збереження **лише після повного успіху**. Незбережений + напівзалитий конфіг має важливу властивість: його прибирає + перезавантаження вузла. Зберегти його означає відібрати цю останню + можливість. + +Окремий стан отримав і випадок «залито, але не збережено»: до +перезавантаження пристрій працює за новим конфігом, після — за старим. + +--- + +## Вбудовані профілі + +Заповнено те, що перевірено або однозначно випливає з синтаксису родини: + +| профіль | вхід | заперечення | збереження | +|---|---|---|---| +| `cisco-ios` | `configure terminal` | `no ` | `write memory` | +| `zte-zxr10` | `configure terminal` | `no ` | `write` | +| `zte-zxan` | `configure terminal` | `no ` | `write` | +| `dlink-me` | — (без режиму) | немає | `save` | + +**MikroTik RouterOS — свідомо без відкату.** Вивід `export` виглядає як +набір команд, але команди в ньому — `add`. Повторне виконання `add` не +повертає рядок на місце, а створює **другий** такий самий запис: другу +адресу на інтерфейсі, друге правило фаєрвола. Прибирання робиться через +`remove [find …]` — через пошук за критерієм, якого в рядку експорту +немає. Тобто механічний відкат тут не «поки не зроблений», а дав би +гарантовано хибний результат. + +**Juniper JUNOS — свідомо без відкату.** Профіль знімає `show +configuration | display omit`, тобто ієрархію у фігурних дужках. Це не +набір команд: віддати такий текст рядками в CLI неможливо. Альтернатива +існує — профіль зі збором `| display set` дав би рядки `set …` із +заперечником `delete `, — але це інший профіль збору й переливання +архіву, і робити це мовчки, підмінивши формат історії, не можна. + +Решта 140+ профілів лишається з `apply_supported = false`, і інтерфейс +про це чесно каже: «для цього профілю відкат не налаштований» плюс +причина з `apply_note`. Порожній профіль, який виглядає робочим, гірший +за відсутню кнопку: він обіцяє відкат рівно до того моменту, коли відкат +знадобиться. + +--- + +## Інтерфейс + +Кнопка «відкотити» стоїть на кожній версії, крім найсвіжішої. Діалог +показує три речі, і підтвердити не можна, доки не переглянуто першу: + +1. **Що зміниться** — різниця «зараз → стане», двома колонками. Не дата + версії й не її розмір, а самі рядки. Прапорець ставиться фактом + перегляду, а не галочкою «я подивився»: галочку ставлять не дивлячись. +2. **Команди на пристрій** — план у тому порядку, у якому його виконає + зонд, із нумерацією (коли заливка спиниться, у звіті буде номер). + Різниця показує намір, план показує дію, і це різні речі: «прибрати + vlan 200» у різниці виглядає як зникнення рядка, а на пристрій піде + `no vlan 200`. +3. **Що лишиться на пристрої** — перелік «прибрати вручну», з окремим + свідомим підтвердженням. З'являється не завжди й найважливіший, коли + з'являється. + +Наміри показуються там же, у картці хоста: погоджує їх інша людина, і +вона приходить на ту саму сторінку — щоб побачити ту саму різницю, яку +бачив автор, у тому ж місці. + +--- + +## Що змінилось + +**Міграція** +`server/migrations/0060_ncm_rollback.sql` — поля заливки в +`ncm.profiles`, два стани (`verifying`, `mismatch`) і десяток полів у +`ncm.rollbacks`, таблиця `ncm.rollback_policy`, команди заливки для +чотирьох родин і чесні пояснення для двох, яким її не дали. + +**Сервер** +`store/ncm_rollback_plan.go` (побудова плану, маскування), +`store/ncm_rollback.go` (намір, погодження, черга, перевірка), +`httpapi/ncm_rollback.go` (вісім ендпоїнтів, RBAC), +`grpcapi/ncm_rollback.go` (диспетчер і приймач результату). + +**Зонд** +`ncmx/apply.go` (заливка тим самим CLI, що й збір), +`session/apply_jobs.go` (звіт назад тим самим стрімом). + +**Веб** +`components/RollbackDialog.tsx`, панель намірів і кнопка в +`pages/ConfigsPage.tsx`. + +`.proto` **не змінювався**: `ConfigApplyJob`, `ConfigApplyResult` і +гілки в `ControlDown`/`ControlUp` лежали там від першого дня й чекали +рівно коду по обидва боки. + +## 2026-08-27 — Кнопки в Telegram нарешті щось роблять + +### Що було зламано + +Сповіщення в Telegram їхало з двома кнопками — «Підтвердити» й +«Заглушити 1 год». Кнопки малював `notify.go`, `callback_data` складався +як `ack:` і `mute:`. Приймача не існувало: сервер +не мав ані вебхука, ані опитування, і натискання не робило нічого. + +Не робило — це м'яко сказано. Telegram, надіславши `callback_query`, +малює на кнопці годинник і крутить його, доки бот не відповість на +`answerCallbackQuery`. Не відповідав ніхто, тож годинник висів до +таймауту клієнта. З телефона о третій ночі це читається не як «функції +немає», а як «зламалось» — причому в найпомітнішому місці продукту, у +повідомленні про аварію. + +Поруч лежала дрібніша поломка того ж роду: кнопка «Заглушити» малювалась +навіть тоді, коли алерт не має хоста (аварія самого зонда, черга не +встигає). У `callback_data` їхало голе `mute:`, і натиснути на неї можна +було рівно з тим самим результатом — тобто ні з яким. + +### Довге опитування, а не вебхук + +Вебхук вимагає, щоб Telegram МІГ ДО НАС ДОСТУКАТИСЬ: публічний порт із +переліку 443/80/88/8443 і сертифікат, якому довіряє їхній бік. +Самопідписаний приймається лише завантаженим у `setWebhook` файлом — і +навіть тоді потрібне ім'я, на яке його видано. + +Це розгортання — самопідписаний TLS на голій IP-адресі, домену немає. +Причому це не «поки не налаштували»: self-hosted інсталяція в мережі +оператора зазвичай узагалі не має входу ззовні. Вебхук там не запрацює +ніколи, і код під нього був би кодом, який не працює в жодній наявній +інсталяції. + +`getUpdates` не вимагає ані вхідного порту, ані імені, ані сертифіката: +з'єднання ініціює сервер, а TLS перевіряється в бік `api.telegram.org` — +тобто туди, де сертифікат справжній. Ціна — одне висяче HTTP-з'єднання +на бота й курсор у базі. + +Про `X-Telegram-Bot-Api-Secret-Token` окремо, бо його вимагають за +звичкою: цей заголовок захищає ВЕБХУК від сторонніх POST-ів на наш +відкритий шлях. У схемі з опитуванням відкритого шляху не існує взагалі +— приймати нема чого, ми самі ходимо по оновлення. Отвору, який той +заголовок затуляє, тут немає. Якщо колись з'явиться домен, вебхук стане +кращим, і перевірка натискання переїде в нього без змін: вона навмисно +не знає, звідки прийшло оновлення. + +### Порядок перевірок + +Головне питання приймача — не «що просять», а «хто просить». У +`callback_data` лежить рівно те, що ми туди поклали, але надіслати цей +рядок може будь-хто, хто раз побачив формат: він не підписаний і не +зашифрований. Тому кабінет і особа НЕ беруться з нього ніде. + +1. **Чий чат.** `matchChannel` шукає канал, у якого `chat_id` збігається + з чатом повідомлення з кнопкою. Чат прописав адміністратор кабінету + руками, тож збіг означає рівно потрібне. Чат, якого немає в жодному + каналі, кабінету не дає взагалі — і на цьому все зупиняється. +2. **Що просять.** `parseCallbackData` приймає лише `ack:`/`mute:` з + канонічним uuid. Кривий рядок відпадає до першого звернення до бази. +3. **Хто натиснув.** `core.telegram_accounts` за парою + (кабінет, `from.id`). Немає прив'язки — немає дії. +4. **Чи можна йому.** `UserPermissions` (потрібне `alerts:ack`) і + `LoadScope` (хост має бути в межах доступу). Ті самі дві перевірки, + що й у HTTP-обробників. +5. І лише тоді — дія. + +Порожній набір прав тут означає ще й відкликане членство: людину +прибрали з кабінету, а прив'язка лишилась. Відповідь однакова навмисно — +з боку Telegram це та сама відмова. + +### Та сама логіка, а не її копія + +`doAck` викликає `store.AckAlert`, `doMute` — `store.MuteDevice`. Ті +самі, що й `POST /api/v1/alerts/{id}/ack` і `POST /api/v1/mutes`: +підтвердження з телефона має лишати в базі такий самий слід, як +підтвердження з браузера, а власна копія розійшлася б із оригіналом на +першій же правці — і побачили б це не в коді, а у звіті. + +З тієї ж причини стеля заглушення переїхала з `httpapi` у +`store.MaxMute`: кнопку «Заглушити» тепер натискають із двох місць, і +політика в них одна. + +### Ідемпотентність — двома шарами + +Палець натискає двічі, а мережа вміє доставити те саме натискання +повторно. + +*Дія.* Перед `AckAlert` іде `AlertAckState`: якщо алерт уже +підтверджений, друге натискання відповідає «Уже підтверджено: <хто>» і +НЕ переписує автора — перший, хто взявся, лишається першим. Для +заглушення так само: `ActiveMute` не дає покласти другий ряд у +`alr.mutes`, бо інакше «на годину» мовчки перетворювалось би на дві. + +*Вигляд.* `withStatus` не дописує рядок, який у тексті вже є. Друге +натискання приводить повідомлення в той самий вигляд, що й перше, — а +саме це людина перевіряє очима. + +### Після дії повідомлення міняється + +`answerCallbackQuery` показує спливаючий рядок на кілька секунд і лише +тому, хто натиснув; у чат він не потрапляє, а чат читає вся зміна. Тому +слідом іде `editMessageText`: у текст дописується +«✅ Підтверджено: , 27.08 03:12», а `inline_keyboard` +замінюється на порожній — кнопки зникають. Без цього наступний черговий +натискає їх ще раз. + +`parse_mode` при правці навмисно не задається, хоч надсилали ми з HTML: +Telegram віддає в `message.text` уже готовий текст без розмітки, і +повторна відправка його як HTML або зламалася б на першому «<» у назві +інтерфейсу, або перетворила б частину тексту алерту на теги. + +### Прив'язка людини + +Питати в людини її числовий `telegram user_id` безглуздо — вона його не +знає, а дізнатись може лише через сторонніх ботів, тобто ми б відправляли +її віддати свою ідентичність невідомо кому заради нашої ж форми. Поле +вводу до того ж дозволяє вписати ЧУЖИЙ id. + +Тому напрям такий: NetPulse видає одноразовий код (профіль → Telegram), +людина шле боту `/link КОД`. Повідомлення в Telegram — доказ володіння +акаунтом, якого форма дати не може: його не надіслати за іншого. Код +народжується там, де людина вже увійшла паролем, живе 15 хвилин, згорає +при першому використанні й лежить у базі лише хешем. + +Код перевіряється ще й на кабінет: приймається лише той, що виданий +кабінету, якому належить бот, що прийняв повідомлення. Інакше чинний код +кабінету А, надісланий боту кабінету Б, прив'язав би людину туди, де її +бот навіть не стоїть. + +### Міграція 0061 + +* `core.telegram_accounts` — зіставлення `tg_user_id` → `core.users`. + Тенантне, а не глобальне: підрядник обслуговує кілька кабінетів і в + кожному є окремим користувачем. Два унікальні індекси — один telegram + на людину й одна людина на telegram у межах кабінету. +* `core.telegram_link_codes` — sha256 коду, строк, одноразовість. +* `alr.telegram_cursors` — місце в черзі `getUpdates`, ключ — хеш токена + бота. Без нього перезапуск процесу або губить натискання, або переграє + добову історію (Telegram тримає невибрані оновлення 24 години) і + глушить хост о десятій ранку за кнопкою, натиснутою вночі. Ключ саме + бот, а не канал: `getUpdates` ексклюзивний, і два канали одного бота + крали б оновлення один в одного. + +Чого в міграції НЕМАЄ навмисно — зв'язку «повідомлення Telegram → +алерт». Редагувати треба РІВНО те повідомлення, кнопку якого натиснули, +а його `chat_id` і `message_id` приходять у самому `callback_query`. +Довідка «яким повідомленням це поїхало» вже є: +`alr.notifications.external_id` зберігає `message_id` з 0007. + +### Один читач на бота + +`getUpdates` ексклюзивний, тож два процеси на одному боті ділили б +натискання навпіл. Приймач бере advisory-блокування на ВЕСЬ час роботи +(не на такт, як движок алертів) і тримає його разом зі з'єднанням: +падіння процесу звільняє блокування само, сусід підхоплює за пів +хвилини. Живе з'єднання перевіряється щотакту — мертве означає, що +Postgres блокування вже зняв. + +Курсор посувається незалежно від того, чи вдалася сама дія: оновлення, +на якому обробник спіткнувся, інакше приходило б знову й знову, і одна +крива кнопка глушила б усі наступні назавжди. Людина при цьому без +відповіді не лишається — невдача сказана їй текстом. + +### Файли + +``` +server/migrations/0061_telegram_callbacks.sql нове +server/internal/store/telegram.go нове +server/internal/alerting/telegram_callback.go нове (розбір і перевірки) +server/internal/alerting/telegram_bot.go нове (опитування, Bot API) +server/internal/alerting/telegram_callback_test.go нове (без мережі) +server/internal/httpapi/telegram_link.go нове (код прив'язки) +server/internal/alerting/notify.go кнопка mute лише за наявності хоста +server/internal/store/alerts_query.go MaxMute, ActiveMute, AlertAckState, + DeviceNameInTenant +server/internal/httpapi/alerts.go maxMute → store.MaxMute +server/internal/httpapi/server.go /api/v1/me/telegram{,/code} +server/internal/store/audit_actions.go core.telegram.link / .unlink +server/cmd/netpulse-api/main.go -telegram-callbacks +web/src/types.ts TelegramLink, TelegramLinkCode +web/src/api/client.ts три виклики +web/src/pages/ProfilePage.tsx картка «Telegram» +``` + +### Чого НЕ зроблено + +* **Вебхука немає** — і не буде, доки немає домену з дійсним + сертифікатом. Причина вище; перевірка натискання від транспорту не + залежить і переїде без змін. +* **Кнопки «Закрити алерт» немає.** `CloseAlert` каже «проблему + полагоджено», і казати це не подивившись — не те, що робиться однією + кнопкою з телефона. +* **Прибирання протухлих кодів** (`PruneTelegramLinkCodes`) написане, але + в жоден такт не всунуте: рядків там стільки, скільки натискань на + «Отримати код», і заводити під це фонову задачу зараз дорожче за сам + механізм. Один незужитий код на людину тримає унікальний індекс. +* **Живого прогону не було.** Жодного запиту до Telegram API не + надсилалось: тест перевіряє розбір і перевірки без мережі, решта — на + стенді з живим ботом. + +## 2026-08-27 — Журнал без назв і архів без виходу + +Дві різні поломки, але хвороба одна: дію додали, а місце, де про неї +дізнається людина, лишили без змін. У журналі це виглядало як сирий +ключ замість назви, в інвентарі — як хост, що зник назавжди після +кнопки «прибрати з переліку». + +--- + +### Частина перша: журнал аудиту не знав власних дій + +`store.audit_actions.go` перекладає ключ (`ncm.mirror.push`) у фразу +(«Примусовий пуш архіву на дзеркало»). Файл просив наступного дописати +рядок — і за півроку програв це прохання тричі: дзеркало Git, ролі й +прив'язка хоста до машини зонда писались у журнал, а назв не мали. +Адміністратор бачив у фільтрі сирі ключі. + +Помітити пропуск неможливо ніяк, крім як відкрити журнал і впізнати +відсутню назву: запис іде, сторінка відкривається, фільтр працює. + +#### Що знайшлось насправді + +Перелік виведено з коду, а не з пам'яті: знайдено кожен виклик +`WriteAudit` у `server/internal` і розібрано, що саме лягає в +`AuditEntry.Action` (два з них ховались за помічниками `auditRole` і +`auditSelfAgent`, де ключ їде параметром). + +Бракувало восьми дій: + +``` +inv.device.self_agent Прив'язка хоста до машини зонда +inv.device.self_agent_clear Зняття прив'язки хоста до машини зонда +ncm.mirror.update Налаштування дзеркала Git +ncm.mirror.push Примусовий пуш архіву на дзеркало +core.role.create Створення ролі +core.role.update Зміна прав ролі +core.role.delete Видалення ролі +inv.device.bulk_purge Повне видалення хостів разом з історією +``` + +…і двох типів об'єктів: `ncm.mirror` («Дзеркало Git») і `core.role` +(«Роль»). Плюс дві нові дії цієї ж роботи — +`inv.device.bulk_restore` («Відновлення хостів з архіву») і той самий +`inv.device.bulk_purge`. + +Останній — окрема історія. Повне видалення писалось під тим самим +ключем, що й архівне (`inv.device.bulk_delete`), а різниця лежала в +`meta.mode`. Для журналу це найгірше з можливих місць: фільтр за дією +не розрізняє «прибрано з переліку» й «немає більше ніде», позначка +«незворотна» стоїть однаково на обох, а прочитати `meta` можна лише +розгорнувши кожну подію окремо. Ключ, за яким шукають найстрашнішу дію +в системі, тепер власний. Назва старого лишилась навмисно широкою: +під ним у журналі вже лежать обидва режими, і перейменувати їх заднім +числом означало б підправити чужі події, яких ніхто не перевірить. +`meta.mode` теж лишився — за ним уже шукають. + +#### Головне: спосіб, у який назви губились, закрито + +Дописати вісім рядків — це наслідок, а не робота. Робота — зробити так, +щоб дев'ятий не загубився. + +Ключ тепер має рівно одне місце оголошення — константу в +`audit_actions.go`. Поруч стоїть тест +(`audit_actions_source_test.go`), який розбирає ВЕСЬ `server/` як текст +програми, знаходить кожне присвоєння `AuditEntry.Action` і +`AuditEntry.ObjectType` і падає на двох речах: + +- ключ написано рядком просто в місці виклику (два місця оголошення — + два джерела правди, що розходяться мовчки); +- у константи немає назви у словнику. + +Ключі, які їдуть у помічник параметром, тест доводить до кінця сам: +знаходить оголошення функції, номер параметра й перевіряє аргументи в +кожному її виклику. Тобто «сховати» ключ за проміжною функцією не +вийде. + +Окремий запобіжник — від самої перевірки: якщо розбір знайшов менше +десяти присвоєнь, тест падає з поясненням. Перейменують `AuditEntry` — +і мовчазний нуль знахідок виглядав би як успіх. + +Заодно прибрано другу таку саму пастку поруч: перелік типів об'єктів +тримався мапою, а порядок для фільтра — окремим списком `order`. +Новий тип, дописаний у мапу й забутий у списку, просто не з'являвся у +фільтрі. Тепер це один упорядкований перелік. + +Блок `AuditBlindSpots` (чесний перелік того, чого журнал не бачить) +виправлено: рядок «зміни складу команди й ролей не записуються» став +неправдою наполовину — самі ролі тепер у журналі є, а от хто кого в +яку роль посадив — і далі ні. Так і написано. + +--- + +### Частина друга: «прибрати з переліку» було пасткою + +Видалення хоста має два режими: архівне (`deleted_at`) і повне (0057). +Архівне задумувалось оборотним — «зібране лишається», — але оборотним +ніколи не було: переліку прибраних хостів у продукті не існувало, дії +«відновити» теж, і рядок із `deleted_at` був недосяжний нізвідки, крім +SQL. Хост не видалений, місце займає, ряди метрик лишає — і ні +повернути, ні добити. + +0057 дав спосіб добити. Тут з'явився спосіб повернути. + +#### Чому цього не зробити одним `deleted_at = NULL` + +Архівування вимикає не лише хост, а й усі його увімкнені перевірки +(інакше увімкнений чек мертвого хоста назавжди лишається «таким, що +пропустив свій такт» на сторінці «Черги»). Знявши саме лише +`deleted_at`, ми повернули б у перелік хост, який мовчить: він на +місці, графіки порожні, причини не видно ніде. + +Вгадати нічого не можна: у базі не записано, який чек вимкнула людина, +а який — архівування. Тому міграція 0062 дає архівуванню місце, куди +це записати: + +``` +inv.devices.archived_enabled яким був enabled на момент архівування +core.checks.archived_off цей чек вимкнуло архівування, не людина +``` + +Обидві живуть лише між архівуванням і поверненням; повернення вмикає +рівно те, що вимикало архівування, і забуває запис. Хости, прибрані до +0062, мають `archived_enabled = NULL` — такий повертається вимкненим, і +інтерфейс про це прямо каже. Це чесніше, ніж увімкнути навмання те, що +людина могла вимкнути свідомо ще до видалення. + +Там же — індекс під сам перелік архіву: усі наявні індекси на +`inv.devices` часткові `WHERE deleted_at IS NULL`, тобто заведені під +питання «що зараз працює» й на питання «що прибрано» не відповідають +узагалі. + +#### Три речі, які могли змінитись, поки хост лежав + +Архів — не сейф: у кабінеті працювали далі. Дві унікальні умови бази +стосуються саме живих рядків і зіткнулися б із поверненням мовчки: + +- `devices_tenant_name_uniq (tenant_id, lower(name))` — за місяць хост + із такою ж назвою могли завести заново. Ім'я незамінне: мовчки + додати « (2)» означало б повернути не той хост, який просили. Тому + це відмова саме по цьому хосту, зі словами, що робити далі; решта + набору повертається. +- `devices_self_agent_uniq (self_agent_id)` — прив'язку «цей хост і є + машина зонда» міг забрати інший живий хост. Тут навпаки: хост + повертається, прив'язка знімається, і про це прямо сказано у + відповіді. Прив'язку людина ставить назад двома кліками, а відмовити + через неї означало б лишити хост в архіві через налаштування, яке й + так треба переглянути. +- Зонд-опитувач (`agent_id`) чіпати не треба: якщо зонд видалили, ключ + уже поставив NULL (0003), і хост повертається без опитувача — це + видно в переліку окремою колонкою. + +Зіткнутись можна не лише з живим хостом, а й із сусідом по тому самому +набору: два прибрані хости з однаковою назвою чи однією машиною зонда +не бачили одне одного, поки обидва лежали в архіві. Зайняте +накопичується під час обходу набору. + +Стан (`status`) при поверненні скидається в `unknown`: у базі лежить +те, яким хост був перед видаленням, і показати тримісячної давнини +«недоступний» як поточний стан означало б збрехати ще до першого +опитування. `next_run_at` повернутих перевірок зсувається на «зараз» — +інакше кожна з них одразу виглядає як така, що запізнилась на три +тижні, і «Черги» чесно показують затор, якого немає. + +#### Вибірка стала параметром, і навмисно тільки в одному місці + +`deleted_at IS NULL` стоїть у півсотні запитів: план колектора, рушій +алертів, мапи, добір цілей команд. Жоден із них не має права почати +бачити прибрані хости через те, що хтось додав параметр із «зручним» +типовим значенням. + +Тому параметр звужений до `ListDevices` — типом `DeviceView`, у якого +нульове значення (`""`) і є колишня поведінка. Умова написана так, що +при `false` вона тотожна колишній: параметр не розширює вибірку, а +перемикає її на другу половину. «Живі плюс прибрані» третім станом не +передбачено — над прибраним хостом немає жодної дії, яка є над живим. + +`GET /api/v1/devices?view=archived` віддає ЛИШЕ архів. Невідоме +значення читається як «живі»: клієнт старішої збірки має отримати +колишню поведінку, а не відмову. + +#### В інтерфейсі + +Кнопка «Архів (N)» у переліку хостів — і її немає, поки архів +порожній: вхід у перелік, у якому нічого не буде, лише розповідає про +режим, якого людині не треба. Сам перелік архіву відрізняється трьома +способами одразу: смуга-пояснення вгорі, перекреслене приглушене ім'я +з позначкою «в архіві» в кожному рядку, а колонка «Стан» замінена на +«Прибрано» з датою — стан прибраного хоста не показується взагалі, бо +це не стан, а музейний експонат. + +Режим вибору там увімкнений завжди: картки в прибраного хоста немає +(сервер віддає лише живих), тож клік по рядку не має куди вести, крім +як відмітити. Дії — «Повернути» й «Видалити назавжди», у порядку ціни +помилки. Вибір скидається при кожному перемиканні «живі ↔ архів»: +відмічені id лишились би дійсними, але дія над ними в іншому переліку +інша. + +Вікно підтвердження — те саме, що й у видалення, з тим самим переліком +імен і чисел. У режимі архіву воно не пропонує «прибрати з переліку»: +архівувати вже заархівоване — дія без наслідку. Після повернення +вікно не закривається, а показує те, чого не видно більше ніде: +скільки перевірок ожило, хто лишився вимкненим і в кого довелося зняти +прив'язку до машини зонда. + +Заодно виправлено текст, який став неправдою: картка «Прибрати з +переліку» обіцяла, що «побачити чи повернути такий хост через +інтерфейс не вийде». + +#### Права + +`devices:write` — те саме, що й на архівування, і окремого права під +повернення немає свідомо. Якби повернення вимагало більшого, система +дозволяла б прибрати хост і не дозволяла б виправити це тому самому +інженерові. Права на кожен хост окремо перевіряє добір +(`Scope.Writable`). Повне видалення з архіву йде тим самим шляхом, що +й було, — з `ncm:delete`, якщо в хоста є збережені конфіги. + +--- + +### Перевірка + +`cd web && npx tsc --noEmit` — чисто; `npm run build` — зібралось +(271 модуль, попередження про розмір чанка було й до цього). + +Go-перевірки (`gofmt -l . && go vet ./... && go build ./... && go test +./...`) НЕ виконано: на машині немає інсталяції Go — ані в PATH, ані +в типових місцях, ані в WSL (його теж немає). Це треба прогнати перед +злиттям; окремо варто переконатись, що новий +`audit_actions_source_test.go` бачить дерево від `server/internal/store` +(він ходить по `../..`). + +## 2026-08-27 — RLS, який нарешті вмикається + +Ізоляція кабінетів у продукті трималась на одному: на тому, що кожен із +26 тисяч рядків `server/internal/store` не забуває дописати `tenant_id = +$1`. Другий рубіж — Row Level Security — був написаний ще в 0011, +стоїть на 68 таблицях, на 56 із них із `FORCE ROW LEVEL SECURITY`, і не +спрацював жодного разу. + +Причина в одному рядку `docker-compose.yml`: DSN збирається з ролі +`netpulse`, а її створює образ Postgres зі змінної `POSTGRES_USER`, +тобто bootstrap-суперкористувачем. Суперкористувач обходить RLS +беззастережно — ані `ENABLE`, ані `FORCE` на нього не діють. Захист був +написаний, увімкнений у схемі й вимкнений у житті. + +Що показово: у 0050 це вже було записано прямим текстом — «у цьому +розгортанні застосунок ходить у базу роллю-власником таблиці», і саме +тому заборона правити журнал зроблена тригером, а не GRANT-ами. Тобто +про діру знали, обійшли її в одному місці й пішли далі. + +### Спочатку — перелік того, що зламається + +Увімкнути RLS — це один `ALTER ROLE`. Уся робота тут в іншому: у +переліку місць, які ходять у базу повз тенантний контекст. `InTenantTx` +із `SET LOCAL app.tenant_id` — єдине місце, де контекст виставляється, а +повз нього ходять 64 запити. + +Розібрані по одному, вони діляться на чотири різні речі: + +| | скільки | що це | +|---|---|---| +| A | 20 | вхід і токени: тенант ще невідомий, його щойно й з'ясовують | +| B | 29 | фонові такти поверх усіх кабінетів | +| C | 7 | тенант відомий, контекст просто не виставили | +| D | 8 | гіпертаблиці, де RLS немає й бути не може | + +Найнебезпечніша тут не C, як здавалось на початку, а B — і не тим, що +її багато, а тим, як вона ламається. Усі 29 б'ють у звичайні таблиці під +політиками: `alr.rules`, `ncm.jobs`, `ncm.command_targets`, +`core.event_outbox`, `inv.devices`. З порожнім `app.tenant_id` вони +повертають нуль рядків **без помилки**. Тобто після перемикання ролі +колектор, рушій алертів, дзеркало, retention і подієвий канал у браузер +не падають — вони замовкають. У журналі при цьому порожньо. + +Перший, хто ламається насправді, — `AuthenticateAgent`: пошук агента за +`token_hash` у `core.agents`. Виняток для шляху входу в 0012 зробили для +`core.users`, `core.sessions` і `core.memberships` — і не зробили для +агентів, API-токенів, реєстрації зондів, публічних панелей і квитків на +завантаження. Тобто без цих п'яти політик перемикання ролі означає, що +жоден зонд не автентифікується взагалі. + +### Три діри, які знайшлись дорогою + +**`topo.link_live`.** Звичайний VIEW поверх `topo.links` без +`security_invoker`. Такий вигляд читає базову таблицю правами ВЛАСНИКА, +а не того, хто питає. Це єдине місце в схемі, де перехід на роль без +BYPASSRLS не змінює нічого: завантаження лінків на мапі й далі +показувало б лінки всіх кабінетів. Один рядок `ALTER VIEW`. + +**Шість зв'язкових таблиць без жодної політики**: +`core.role_permissions`, `inv.device_group_members`, `inv.device_tags`, +`inv.device_credentials`, `topo.map_shares`, `bill.invoice_lines`. Цикл +у 0011 шукав колонку `tenant_id`, а в зв'язках її немає — і правильно, +що немає: власна колонка тенанта у зв'язці може розійтися з батьком. +Але руками про них ніхто не згадав. Найгірша з шести — +`inv.device_credentials`: її рядок каже, яким доступом ходити на хост. +Чужий рядок там — це не «побачив зайве», а «зайшов на чужий комутатор +нашими руками». + +**Права за замовчуванням.** Сім таблиць, доданих після 0011, тримаються +виключно на `ALTER DEFAULT PRIVILEGES` — і тримаються доти, доки кожну +наступну міграцію котить та сама роль. Відновлення з дампа під іншим +користувачем лишає їх без прав. Поки застосунок — суперкористувач, +цього не видно взагалі. Заразом виявилось, що `ALTER DEFAULT PRIVILEGES` +ніде не роздано на послідовності: перший же `serial` у наступній +міграції дав би «permission denied for sequence» на проді. + +### Ролі: чому воркер лишається з BYPASSRLS + +Питання стояло так: або окрема роль з обходом політик для фонових +тактів, або перебір тенантів у циклі. Перебір безпечніший, і саме так уже +влаштована більша частина фонової роботи — `SweepRetention` і +`MirrorGit` беруть перелік кабінетів і далі кожен обробляють через +`InTenantTx`. + +Ламається не обробка, а ПЕРШИЙ запит — той, що каже, кого обробляти. +Його перебором не заміниш: щоб дізнатись перелік кабінетів, треба +прочитати `core.tenants` поверх кабінетів. + +Друга половина гірша за це. Видача завдань зондам — це одна інструкція +`UPDATE … FOR UPDATE SKIP LOCKED … RETURNING tenant_id`, яка одночасно +знаходить роботу, забирає її собі й каже, чия вона. Розкласти її по +кабінетах означає замінити один такт на N тактів кожні 5 секунд і +власноруч завести голодування: кабінет, який стоїть у циклі першим, +вибирає ліміт, а останній не отримує нічого. `SKIP LOCKED` існує рівно +проти цього. + +Тому перебір лишається там, де він уже є — і саме він робить справжню +роботу, — а BYPASSRLS дістається окремій ролі для запитів-шукачів. + +Обмежує таку роль не RLS (її вона за визначенням не бачить), а те, ким і +звідки вона використовується: окремий DSN, окремий пул `Store.bg`, +скінченний перелік методів. Код, який обслуговує запит користувача, +фізично не має доступу до цього пулу — у нього інший пароль. Не `SET +ROLE` на спільному з'єднанні: воно повертається в пул, і одна забута +команда `RESET` перетворює запит користувача на запит з BYPASSRLS — +рівно та помилка, проти якої вся ця робота. + +Звужувати GRANT-и воркера до переліку таблиць 0063 не береться, і це +свідомо: вузький перелік, складений із читання коду, а не з роботи +стенду, — спосіб зупинити бекапи через півтори доби на таблиці, про яку +забули. Це окремий крок після тижня спостережень. + +### Міграцію можна накотити, нічого не вмикаючи + +Найкорисніша властивість усієї зміни. 0063 заводить ролі без пароля, +роздає права, закриває зв'язки політиками, ставить `security_invoker` — +і не змінює поведінку стенду ані на йоту, бо застосунок і далі ходить +суперкористувачем, який усе це обходить. Перемикач — не міграція, а DSN. + +Те саме з боку коду: `NETPULSE_DSN_WORKER` порожній — і `Store.bg` +дорівнює основному пулу, тобто все поводиться як раніше. Тобто і схему, +і код можна викотити наперед, а роль перемкнути окремим кроком з власною +перевіркою й відкатом у 30 секунд (`deploy/RLS-CUTOVER.md`). + +Розділяти їх довелось не з обережності, а тому, що інакше не було б +кроку, на якому можна зупинитись. + +### Міграція, яка перевіряє сама себе + +0063 закінчується трьома перевірками, які падають ТУТ, а не в проді: +таблиця з `tenant_id` без RLS або без політики; таблиця, до якої +`netpulse_app` не має SELECT (під новою роллю це не порожній результат, +а «permission denied» на першому ж запиті клієнта); і довідковий перелік +таблиць без tenant_id і без політик — щоб наступний автор побачив його +очима, а не дізнався про нову таблицю в цьому переліку через півроку. + +Перевірка разова за побудовою: вона спрацювала на момент накочування. +Наступна таблиця з `tenant_id` без політики знову з'явиться мовчки. + +### Тест, без якого все це не має сенсу + +Різницю між «RLS увімкнено» і «RLS працює» неможливо побачити з +`pg_policies`: `ENABLE`, `FORCE` і сама політика виглядають однаково і +там, де вони діють, і там, де їх обходить `rolbypassrls`. Тому тест не +перевіряє наявність політик — він перевіряє результат запиту. + +Два кабінети з хостами, портами, мітками, доступами й лінком; `SET ROLE +netpulse_app`; запит **без предиката** `tenant_id` — саме той, від якого +RLS і страхує. Плюс адресне звернення за чужим id (так виглядає витік +через підставлений у URL uuid), плюс спроба записати чужий `tenant_id`, +плюс порожній контекст. + +Дві деталі, без яких тест був би декорацією. Перша: він починається з +перевірки, що `netpulse_app` не має BYPASSRLS — інакше все нижче почне +проходити з протилежної причини, і тест зеленітиме від зняття захисту. +Друга: контрольний замір роллю власника перед перемиканням — без нього +прогін, у якому дані мовчки не налились, показав би нуль чужих рядків і +блискуче пройшов. + +### Чого це не дає + +Телеметрія лишається поза RLS — гіпертаблиці й стиснення в TimescaleDB +не поєднуються з row level security, і це не наслідок цієї роботи, а +незмінна властивість. Для `ts.*`, `core.audit_log`, `alr.alerts_history` +і `alr.notifications` предикат у запиті й далі єдиний механізм. + +`CreateUser` довелось лишити поза політиками: додати в кабінет людину, +яка вже працює в чужому, під RLS неможливо — `ON CONFLICT (username) DO +UPDATE … RETURNING` не побачить чужого користувача й замість «підхопили +наявного» дасть помилку унікальності. Політикою це не лікується: щоб її +обійти, треба зробити `core.users` видимою наскрізь, тобто відкрити рівно +ту дірку, яку закриваємо. Дія рідка, адміністративна й крос-тенантна за +природою — ходить пулом воркера. + +І головне, що варто сказати прямо: сама роль воркера бачить усе. Перехід +прибирає ризик «забутий `WHERE` в API віддає чужі хости» — той, через +який витік доходить до екрана клієнта. Ризик «фоновий такт узяв не той +кабінет» він не прибирає, тільки звужує до 29 перелічених запитів. + +## 2026-08-27 — Строки зберігання даних і сторінка сховища + +База на стенді важить 120 МБ при шести хостах. Це не проблема — проблема +в тому, що жодна з цифр не має стелі. Порт хоста це рядок у +`ts.if_counters` на кожному такті; 500 хостів по 24 порти — 12 000 рядів, +сотні тисяч рядків на добу з самих лічильників. Сеанс збору конфігу лишає +транскрипт у `ncm.jobs`, прогін команд — транскрипт на КОЖЕН хост у +`ncm.command_targets`. Диск закінчується мовчки: Postgres працює нормально +до останнього вільного байта, а потім перестає приймати записи взагалі. +Перша ознака проблеми і є сама аварія, і настає вона одночасно для всього +продукту. + +### Аудит: що вже прибиралось, а що ні + +Перше, що з'ясувалось: твердження «немає жодного механізму» неправильне. +Механізм є, і половина роботи полягала в тому, щоб його не зламати. + +**Гіпертаблиць у схемі дванадцять.** Вісім у `ts` (`samples`, +`icmp_samples`, `if_counters`, `link_status`, `device_status_history`, +`syslog`, `snmp_traps`, `agent_health`), дві в `alr` (`alerts_history`, +`notifications`), дві в `core` (`audit_log`, `login_attempts`), плюс +`bill.license_checkins`. Безперервних агрегатів шість: 5-хвилинні й +годинні згортки для метрик, ICMP і лічильників портів. + +**Стиснення** (0005) стоїть на восьми: `ts.samples`, `icmp_samples`, +`if_counters` — з другої доби, `link_status` і `snmp_traps` — із сьомої, +`syslog` і `agent_health` — з третьої, `core.audit_log` — з року (0050 +свідомо відсунула його з місяця до року, бо стиснутий журнал перестає +шукатись за вмістом). + +**Видалення** (0005, 0007, 0009, 0012) стоїть на дев'ятьох: сирі метрики, +ICMP і лічильники — 35 діб, syslog і трапи — 90, самометрики зондів — 14, +5-хвилинні згортки — 400, сповіщення — 90, спроби входу — 180, перевірки +ліцензії — 400. + +**Дір три, і кожна росте назавжди:** `ts.link_status`, +`ts.device_status_history` і `alr.alerts_history` не мають політики +видалення взагалі. Це не дрібниці: історія алертів — рядок на кожну +аварію кожного хоста, а історія станів пише рядок на кожну зміну +«вгору/вниз», тобто на кожен блимок каналу. Обидві маленькі рівно доти, +доки хостів шість. + +**Звичайні таблиці, які ростуть і не прибираються ніяк.** За спаданням +небезпеки: + +| Таблиця | Що в ній | Швидкість | +|---|---|---| +| `ncm.command_targets` | вивід кожної команди на кожному хості + повна стенограма сесії | найтовщі рядки в базі; один прогін по дільниці — кілобайти тексту на кожен зі ста хостів | +| `ncm.jobs` | транскрипт кожного сеансу збору конфігу | 500 рядків із текстом щодня при добовому розкладі | +| `core.secrets` | зашифровані тіла версій конфігів | мегабайт на версію для ZTE C320; прибирається політикою 0037 | +| `topo.discovery_runs` | прогони пошуку сусідів | найменша: запускають руками | + +**Те, що росте, але вже обмежене** — і саме тому нової ручки не отримує: +`ncm.configs` (політика 0037), `ncm.diffs` (каскад від конфігів), +`ncm.compliance_results` (`UNIQUE (rule_id, device_id)` — переписується, а +не росте), `core.event_outbox` (`PruneEvents`), `core.download_tickets` +(строк квитка), `topo.map_revisions` (стеля ревізій на мапу), `alr.alerts` +(закриті переїжджають в історію фоном). + +### Головне рішення: строк — рівня інсталяції, а не кабінету + +Видалення старого в гіпертаблиці — це `drop_chunks`: чанк зноситься цілою +таблицею, разом із рядками ВСІХ кабінетів, що в нього потрапили. Чанк +ріжеться за часом і лише за часом; про кабінет він не знає нічого. Тобто +«тенант А тримає метрики рік, тенант Б — тиждень» реалізується тільки +власним `DELETE` по рядках — тобто відмовою від єдиного механізму, заради +якого TimescaleDB і взято. + +Різниця не теоретична, і це друге рішення тієї ж пари. `DELETE` у +Postgres не звільняє місця на диску: рядок позначається мертвим, сторінка +лишається файлу, і повернути її здатен лише `VACUUM FULL` — який блокує +таблицю цілком і вимагає стільки ж вільного місця, скільки вона займає. +Тобто на переповненому томі, саме тоді, коли це потрібно, він не спрацює. +`drop_chunks` зносить чанк як таблицю, і місце повертається негайно. + +Тому: гіпертаблиці — рідні політики TimescaleDB, звичайні таблиці — +пакетне видалення, і про його межу сказано людині прямо у формі («місце +звільниться всередині таблиці й буде перевикористане, але на диск не +повернеться»). + +### Міграція 0064 + +**`core.retention_settings`** — рядок на вид даних: ключ, таблиця, +механізм (`timescale`/`batch`), колонка часу, строк у добах. `NULL` +означає «не видаляти» — саме `NULL`, а не нуль: нуль читався б як «тримати +нуль днів», тобто як наказ знести все, і одна помилка в перетворенні +типів між формою й API коштувала б усієї телеметрії. + +**Рядки заводяться з ФАКТИЧНИМ станом бази, а не з бажаним.** Це головне +рішення міграції. Спокуса накотити перелік «правильних» строків велика, +але на чужій інсталяції наш правильний строк — це чиясь втрачена історія. +Міграція лише записує те, що вже діє, у місце, де це видно людині: не +видаляє жодного рядка й не змінює жодного наявного строку. Інсталяція, що +оновиться, вранці має рівно ті самі дані, що й учора. Наслідок, який +варто розуміти: одразу після накату екран виглядає нерівно — десь 35 діб, +десь порожньо. Так і має бути. Це знімок реальності, і саме він змушує +запитати, чому історія алертів не прибирається взагалі. + +**`core.retention_current(rel)`** — чинний строк політики. Окрема функція +через одне неочевидне місце: для безперервного агрегату політика висить не +на вигляді, а на матеріалізованій гіпертаблиці під ним +(`_timescaledb_internal._materialized_hypertable_N`). Шукати її за іменем +вигляду означає не знайти ніколи й доповісти людині, що згортки не +прибираються, — при тому що 0005 їм строк задала. + +**`core.retention_hypertable(rel)`** — те саме з іншого боку: під яким +іменем відношення лежить на диску. Запитати розмір у вигляду означає +отримати нуль. + +**`core.apply_retention_policies()`** — `SECURITY DEFINER`, і це +необхідність, а не зручність. `add_retention_policy` вимагає прав +ВЛАСНИКА гіпертаблиці; після 0063 застосунок ходить роллю `netpulse_app`, +яка власником не є й не має ним ставати. Без цієї функції екран міг би +зберегти число й не змогти його застосувати — рівно той стан, у якому +інтерфейс бреше. Функція навмисно вузька: не приймає ані імені таблиці, +ані строку, а лише приводить політики у відповідність до рядків таблиці +налаштувань. `search_path` прибитий цвяхами. + +**Індекси під пакетне видалення:** `command_runs_age_idx`, +`ncm_jobs_age_idx`, `discovery_runs_age_idx` — по `created_at`. Наявні +індекси не годяться: у прогонах вони по `(tenant_id, created_at)`, у +завданнях — по `(device_id, created_at)`, а прибирання йде поверх +кабінетів і поверх хостів, тобто читало б усю таблицю на кожну партію. +`ncm.command_targets` власного індексу не отримує: цілі зникають каскадом +від прогону, і `command_targets_run_idx` каскад уже обслуговує. + +**`core.storage_samples` і `core.storage_config`** — ряд спостережень за +розміром і ємність тому. + +**Стиснення НЕ додається**, хоч трьом гіпертаблицям його бракує, і це +свідома відмова з поясненням у міграції. Урок 0050: стиснення робить +недоступними індекси за всіма колонками, крім `segmentby`. Для +`alr.notifications` `segmentby alert_id` дав би сегменти по одному-два +рядки, тобто стиснення без стиснення. `alr.alerts_history` питають і за +`tenant_id` (список історії), і за `device_id` (видалення хоста, 0057) — +будь-який вибір лишає другий запит без індексу, тобто прискорює місце +ціною тихо померлої сторінки. Правильна відповідь для всіх трьох — строк +зберігання: рядок, якого немає, займає нуль і шукається миттєво. + +### Безперервні агрегати: найлегше місце все зіпсувати + +Якщо сирі дані видаляються, а згортки лишаються, згортки мають бути +пораховані ДО видалення сирих. Інакше в графіках за минулий місяць +лишиться діра, яку вже нічим не заповнити — сирих даних, з яких згортку +рахують, більше немає, і перерахувати її неможливо в принципі. + +Захист двома правилами в `ValidateRetention`, і набір перевіряється +ЦІЛКОМ, а не по полю: половина умов тут про стосунки між видами, і на +одному числі їх не перевірити. + +1. **Нижня межа на вид.** Сирі дані під згорткою — не менше 2 діб + (5-хвилинні згортки рахуються з відставанням у три години); + 5-хвилинні — не менше 7 (годинні рахуються з вікном у дві доби); + годинні — 30. Журнал аудиту — не менше 365: рік це межа, у яку + вміщається практично будь-який розбір (0050), а коротший строк знищує + докази раніше, ніж по них приходять. +2. **Згортка не зникає раніше за джерело.** `keep(5m) ≥ keep(raw)`, + `keep(1h) ≥ keep(5m)`; «не видаляти» вважається нескінченністю. + Відмова з поясненням, а не мовчазне виправлення. + +Обидва правила перевірені юніт-тестами без бази: якби вони жили в SQL, +перевірити їх можна було б лише на живому Postgres — тобто на практиці +ніколи. + +### Безпека + +**Типове значення — «не видаляти».** Строки, яких не було, лишаються +порожніми. Строки, які були, переносяться як є. + +**Попередній перегляд перед застосуванням**, тим самим порядком, що й +повне видалення хоста: спершу покажи, що зникне. Різниця в тому, що тут +зникає не об'єкт зі списку, а хвіст історії, і побачити його інакше ніяк. + +Для гіпертаблиць рахуються саме ЧАНКИ, а не «рядки, старші за строк»: це +різні множини. `drop_chunks` зносить чанк лише тоді, коли ВЕСЬ його +діапазон вийшов за строк, тож частина рядків, старших за строк, ще поживе +в чанку, який закриється завтра. Рахувати за рядками означало б обіцяти +більше, ніж станеться, — а людина, звіривши цифри після, вирішила б, що +прибирання не працює. Розмір чанків точний (`chunks_detailed_size`), +кількість рядків — точний `count(*)` зі стелею в 15 секунд; не встигли — +повертається `-1` і форма каже «не порахували». Саме `-1`, а не нуль: +«нічого немає» перед видаленням мільйонів рядків — брехня найгіршого +ґатунку. + +Для звичайних таблиць кількість рядків точна, місце — оцінка через +середній рядок, і так і підписано: рядок із транскриптом на 40 КБ і рядок +з помилкою на 30 байтів відрізняються на три порядки. + +**Зміна строку йде в аудит** — `core.retention.update`, і в `meta` не +«зберегли форму», а що саме змінилось: вид, мітка, «було → стало» і +окрема позначка `shortened`. Через рік питання буде не «хто відкривав +сторінку», а «хто скоротив історію алертів до тижня». Дію не позначено +`Destructive`: сама зміна нічого не стирає, стирає її наслідок, і +позначка «об'єкта більше немає» була б неправдою про мить події. + +**Версії конфігів не переїжджають сюди.** Їхня політика (0037) влаштована +не за віком, а як «останні N АБО молодші за M днів», із захистом +останньої версії хоста, версій під відкатом і версій, на які посилаються +результати перевірок. Звести це до однієї цифри в добах означало б +утратити рівно ті гарантії, заради яких воно написане: конфіг, який не +міняли три роки, — не сміття, а єдина копія. Друга ручка до тих самих +даних була б гіршою за відсутність ручки — той самий висновок, що й у +коментарі 0037 до `ncm.device_policies.retention_versions`. Сторінка +сховища показує розмір `ncm.configs` і `core.secrets` як довідку й +відправляє міняти політику туди, де вона живе. + +**Дзеркала Git очистка не чіпає взагалі, і чіпати не може.** Git +append-only за побудовою: прибрати з нього історичну версію означає +переписати всі наступні коміти гілки, тобто зламати `commit_sha` в усіх +уцілілих рядках `ncm.configs` і зробити неможливим fast-forward для +дзеркала. Це вже описано в `ncm_retention.go` і сказано людині у формі. +Ні `drop_chunks`, ні пакетне видалення до репозиторію не дотягуються — +телеметрії там немає взагалі. + +### Сторінка «Сховище» + +Показує не розмір, а ШВИДКІСТЬ і те, що з неї випливає. «120 МБ» не +означає нічого; «плюс 40 МБ за добу, вільного на 12 діб» означає все. + +Цифри з системних представлень Postgres і TimescaleDB +(`pg_database_size`, `hypertable_detailed_size`, +`hypertable_compression_stats`, `timescaledb_information.chunks`, +`pg_total_relation_size`), а не з формул за кількістю рядків: розмір +рядка залежить від стиснення, TOAST і роздутості після видалень, і будь-яка +формула розійшлася б із диском у той самий день, коли на неї почнуть +покладатись, — і розійшлася б у бік «усе гаразд». + +Приріст рахується з ряду спостережень (`core.storage_samples`, знімок +щогодини), а не з різниці «зараз проти нуля»: інсталяція, підняту з +дампа, інакше показала б добовий приріст, що дорівнює всій базі. Вікно — +30 діб, різниця країв, а не регресія: регресія згладила б сходинку від +щойно ввімкненого строку саме тоді, коли цю сходинку й треба побачити. + +**Ємності тому сторінка не знає й не вигадує.** Функції «скільки +вільного на диску» в Postgres немає, а процес застосунку живе в іншому +контейнері й може стояти взагалі на іншій машині — тобто `statfs` теж не +відповідь. Число вводить людина; поки не ввела, сторінка показує розмір і +швидкість, але не показує дати переповнення. Вигадана дата гірша за її +відсутність: на такі дати дивляться саме тоді, коли перевіряти вже пізно. + +Дві смуги попереджень нагорі: види даних, у яких строку немає взагалі, і +види, у яких строк у налаштуваннях розійшовся з тим, що справді стоїть у +базі. Друге буває після відновлення з дампа — `pg_dump` не везе фонових +задач TimescaleDB узагалі, тобто строки в таблиці є, а видалення не +працює. Мовчки. + +### Права + +Дивитись — `devices:read`, тобто кожен, хто взагалі бачить систему. Те +саме рішення, що й для дзеркала конфігів (0054): помітити, що диск +закінчується, має могти той, хто щодня дивиться на моніторинг. Міняти — +`settings:write`: строк незворотний за наслідками й діє на всю +інсталяцію, тобто це рівно те, що описує це право. Досі його не питав +жоден обробник — рядок про нього прибрано з `dormantPerms` тією ж +правкою, як і передбачав коментар до цього переліку. + +Попередній перегляд теж під `settings:write`, хоч нічого й не змінює: він +частина шляху зміни, а не самостійна довідка, і він дорогий — точний +підрахунок рядків по хвосту гіпертаблиці має право запускати той, хто +збирається натиснути «застосувати», а не будь-яка відкрита вкладка. + +Пункт меню поставлено в «Збір даних», поруч із «Чергами»: там питання «чи +все встигає», тут — «чи все вміщається». В «Адмініструванні» сховище +стояло б поруч із ролями й журналом, тобто там, куди мережевий інженер не +заходить, — а закінчується диск саме в нього. + +### Прибиральник + +Такт щогодини в колекторі (`netpulse-server`), поруч із очисткою +конфігів і з тих самих трьох міркувань: тут стоїть той, хто дані створює; +REST-процес масштабують копіями й він же обслуговує запити людини; ключ +шифрування тут не потрібен зовсім. Своє advisory-блокування, окреме від +очистки конфігів: спільне означало б, що довгий прохід по прогонах команд +заодно зупиняє прибирання версій. + +Три кроки: звірка політик TimescaleDB зі збереженими строками (для +випадку відновлення з дампа), пакетне видалення звичайних таблиць по 5000 +рядків із стелею 200 000 на вид за прохід, знімок розмірів — після +прибирання, а не до, інакше приріст рахувався б із піків перед кожним +проходом. + +**Окремо про тиху відмову.** Прохід рахує, скільки рядків підпадало під +строк, ДО видалення. Якщо підпадало більше нуля, а видалено нуль — у лог +іде попередження з ймовірною причиною: з'єднання відкрито роллю під RLS +без заданого кабінету, тобто DSN колектора не веде на `netpulse_worker` +(0063). Без цієї пари чисел такий збій виглядав би як повна тиша: помилки +немає, прибирання немає, диск росте. + +### Чого не зроблено + +**Не перевірено на живій базі.** На цій машині немає ані Docker, ані +WSL, ані локального Postgres — одноразовий контейнер +`timescale/timescaledb:2.17.2-pg16` підняти нічим, а бойовий стенд чіпати +заборонено. SQL написано лише на публічних API TimescaleDB 2.17 і +перевірено читанням; перед накатом його треба прогнати на одноразовій +базі. Найризикованіші місця, які варто перевірити першими: +`chunks_detailed_size` над матеріалізованою гіпертаблицею агрегату, +`hypertable_compression_stats` на гіпертаблиці без стиснення (запит +відокремлено, щоб її збій коштував одного стовпця, а не сторінки) і +виклик `add_retention_policy` усередині транзакції з-під +`SECURITY DEFINER`. + +**Алерт про вичерпання місця не додано.** Движок правил зараз править +інший агент, і його файли не чіпались. Що варто додати, коли він +звільниться: `alr.rule_source` — це джерела, прив'язані до хоста +(`metric`, `icmp`, `interface`), а «на диску лишилось 5 %» до хоста не +прив'язане взагалі. Найдешевший шлях, який НЕ вимагає правок движка, — +писати розмір бази й запас у добах звичайними метриками в `ts.series` +(наприклад `db.size.bytes` і `db.days_left` на хості, що позначений +машиною зонда), і тоді працює наявне метричне правило без жодної зміни +коду. Альтернатива — нове джерело `system` у движку — дорожча й дає те +саме. + +**Стиснення трьом гіпертаблицям не додано** — свідомо, з поясненням вище. +Це вимірювана задача (як у 0050), а не рішення з міркувань симетрії. + +**`bill.license_checkins` не заведено видом даних.** Строк у неї є (0009, +400 діб), а розділу «Тариф» у продукті ще немає — рядок у формі означав +би запрошення покрутити те, наслідків чого людині ніде не видно. + +**Розмір у попередньому перегляді для звичайних таблиць — оцінка**, і +точнішою її зробити нічим без повного обходу рядків. Позначено як оцінка +в API (`exact: false`) і у формі (знак ≈). + +## 2026-08-27 — Перехід на роль без BYPASSRLS: як зелений тест пропустив поломку + +Перемикання застосунку на `netpulse_app` зламало вхід у систему. Не +тихо — чесна 403 `no_membership`, «користувача не додано до жодної +організації», для власника з повними правами. + +Причина в одному рядку `userMemberships`: + +```sql +SELECT ... FROM core.memberships m +JOIN core.tenants t ON t.id = m.tenant_id +``` + +Політика на `core.tenants` вимагає `id = core.current_tenant()`. Під час +входу кабінет ще невідомий за визначенням — його якраз і з'ясовують цим +запитом. Курка з яйцем: щоб дізнатись свої кабінети, треба вже бути в +кабінеті. + +### Чому перевірка цього не спіймала + +Перед перемиканням прогнали тест ізоляції. Він був правильний, ретельний +і зелений: створили другий кабінет із хостом усередині транзакції, +переконались, що роль застосунку бачить шість своїх і нуль чужих, +відкотили. Жодного сліду в базі, жодного хибного припущення. + +І він дивився повз поломку за побудовою. Тест питав «чи не видно +ЧУЖОГО». Зламалось протилежне — не видно СВОГО, і не в даних, а на +шляху до них. + +Потік метрик теж нічого не показав: він іде під `netpulse_worker`, у +якого BYPASSRLS лишився. Обидві перевірки, зроблені перед перемиканням, +були слушні й обидві сліпі до того самого місця. + +Це головний висновок дня, і він не про RLS: **зелена перевірка доводить +тільки те, що вона перевіряє.** Тест на ізоляцію не є тестом на +працездатність, хоч і виглядає переконливо. + +### Що виявилось насправді + +Аналіз, зроблений при написанні 0063, був правильний: у ньому чорним по +білому стояв «клас A — 20 запитів, вхід і токени, тенант ще невідомий». +Не зробленою лишилась робота за цим аналізом — жоден із тих запитів на +воркерний пул не перевели. Правильний висновок, не доведений до коду, +рятує рівно настільки, наскільки його прочитали. + +Замір показав 30 звернень основним пулом поза тенантним контекстом. З +них під RLS ламався один — `core.tenants`, — але це збіг обставин, а не +підстава правити один рядок. + +### Принцип замість переліку правок + +RLS захищає доступ до даних кабінету. Він **за побудовою** не може +захистити запит, який питає «а який це кабінет?»: політика вимагає +відповіді на те саме питання, яке запит ставить. + +Тому на воркерний пул переведено весь до-кабінетний шар цілком: вхід і +сесії, машинні токени, автентифікація й реєстрація зонда, публічна +панель за токеном, квиток на завантаження, приймання телеметрії. Не як +виняток заради працездатності, а тому, що RLS їх ніколи й не захищав. +Їхній захист — предикат: хеш токена, `user_id`, термін дії квитка. Він +був єдиним і до 0063. + +Перелік дозволених видів запитів у коментарі до `Store.bg` доповнено +четвертим пунктом. Лишити його тризначним означало б зробити коментар +неправдою — тобто повторити ту саму помилку, яку весь цей день ловили в +схемі, довіднику й протоколі. + +### Телеметрія: поломка, яка чекала б наступної зміни в мережі + +`ts.series` — єдина таблиця під RLS серед цілей запису телеметрії, і +писалась вона основним пулом. Під час невдалого перемикання метрики +йшли: лічильники росли, всі шість хостів звітували, відставання дві +секунди. Бо ряди вже існували. + +Впав би НОВИЙ ряд — новий хост, новий порт, перший рядок прототипу. +Тобто поломка виявилась би не при перемиканні, а через тиждень, при +першій зміні в мережі, і зв'язати її з переходом на RLS було б уже +нічим. + +### Як перевіряли вдруге + +Написали перевірку прохідності: вхід, і потім двадцять вісім переліків, +з яких чотирнадцять **мають** бути непорожніми. Порожній перелік +рахується провалом, а не «даних немає» — саме так виглядає відмова RLS. + +Еталон зняли ДО перемикання, на суперкористувачі. Без нього «шість +хостів» нічого не доводить: цифру треба порівнювати, а не оцінювати. + +Після перемикання всі 28 збіглися з еталоном, вхід повернув «Основний +кабінет», ізоляція лишилась (шість своїх, нуль без контексту), новий ряд +`ts.series` створюється, у журналах жодної відмови доступу. + +### Ціна помилки виявилась низькою, і це не випадковість + +Між зламаним входом і відкатом минуло кілька хвилин: `.env` мав копію, +знімок бази був знятий перед міграціями, а сам перехід спроєктований +інертним — три рядки в конфігурації, тридцять секунд на відкат. Паролі +ролей пережили відкат, тож повторне перемикання не вимагало заводити їх +наново. + +Дорого коштує не помилка, а помилка без шляху назад. + +## 2026-08-27 — Друга сімка: те, що перевірки нарешті виконались + +Сім задач (0065–0068 плюс `plural`, тести вебу й виправлення знайденого) +робились так само паралельно, як перша сімка. Різниця в одному: цього +разу в кожне завдання було вписано вимогу — окремим абзацом сказати, +**чого перевірка НЕ покриває**. + +Вимога з'явилась не з методології, а з ранкової поразки: тест ізоляції +RLS був правильний і зелений, і пропустив зламаний вхід, бо перевіряв +«чи не видно чужого», коли зламалось «чи видно своє». + +### Що ця вимога дала + +Три агенти незалежно написали одне й те саме речення: **жоден рядок SQL +не виконувався**. Ані нові міграції, ані запити ескалацій, ані запис +трапів — усе перевірено компіляцією Go й прочитане очима. + +Це не було відомо до того. Тести були зелені, звіти бадьорі, і без +прямої вимоги назвати непокрите ця діра просто не потрапила б у поле +зору — рівно як не потрапила вранці. + +Агент трапів пішов далі й назвав найімовірнішу непомічену поломку у +власній роботі: v1-трап жодного разу не летів по мережі, нормалізація +перевірялась на зібраному руками пакеті, і якби gosnmp заповнював +`Enterprise`/`AgentAddress` інакше, переклад за RFC 3584 давав би +невірний OID мовчки при зелених тестах. + +### Пісочниця + +Тому перед розгортанням на стенді заведено окрему базу `netpulse_probe` +й накочено в неї 0001–0068 **з нуля**. Досі міграції перевірялись лише +як приріст до наявної схеми — на чистій базі не проганявся ніхто. + +60 із 60 пройшли. Разом із ними вперше в житті виконались: +`TestRLSTenantIsolation`, `TestEscalationAgainstDB` і всі інтеграційні +набори `store` / `httpapi` / `grpcapi`. + +**І одразу дали дві помилки.** Обидві — не в новому коді, а в тестах, +які описують світ до появи розпізнавання пристроїв: +`TestControlHandshake` вимагав рівно один чек у плані (тепер їх два, +`topology.identify` заводить сам сервер при підключенні зонда), а +`TestPlanHashSkipsResend` рахував хеш ДО того, як цей чек створювався, +і потім звинувачував сервер у пересиланні, яке спричинив сам. + +Півтора року вони мовчки пропускались без `NETPULSE_TEST_DSN`. Тест, що +не виконується, не є перевіркою — він є її виглядом. + +Виправлено тести, не поведінку. Перший — так, щоб наступний +автоматичний чек не завалив його знову: він шукає свій чек серед решти, +а не покладається на їхню кількість. + +### Міна із закінченнями рядків + +Генератор профілів писав `0014` через `write_text`, який на Windows +перекладає `\n` у `\r\n`. Контрольна сума при цьому змінюється, а +сторож міграцій звіряє саме її — тобто бінарник, зібраний на Windows +після запуску генератора, зупинив би накат на стенді. **Не одну +міграцію, а всі**: сторож падає на першій розбіжності. + +Знайшлось це не в бою лише тому, що агент каталогу порівняв текст, а не +повірив «генератор відпрацював без помилки». + +Заразом виявилось, що сам генератор був зламаний: `MIGRATION` вказував +на `db/migrations`, хоча міграції давно переїхали в `server/migrations`. +Тобто `--check` **завжди** звітував про розходження, і його червоний +стан став фоном, на який перестали дивитись. + +Наслідок довелось розплутувати на стенді: там лежала CRLF-версія 0014 з +відповідною сумою, у дереві — LF. Полагоджено виправленням записаної +суми, і не на віру: `стенд без CR == дерево`, `дерево з CR == стенд`, +побайтово. + +### Третя функція, що вбиває кирилицю + +`safeName` у вебі згортав усе не-ASCII у підкреслювач, бо `\w` у +JavaScript — це `[A-Za-z0-9_]`. `Київ-ядро` і `Львів-ядро` давали +ОДНАКОВЕ ім'я файла: вивантажив два конфіги — отримав один. + +Це третій випадок за дві сесії після `sanitizePath` у гілках Git і +шляхів дзеркала. Спільного коду в них немає — спільна лише звичка +писати класи символів як `\w`. + +### Що ще знайшли тести, яких не було + +Тести вебу писались із нуля (їх не існувало взагалі) і по дорозі +принесли одинадцять справжніх вад. Найдорожчі три: `IntervalInput` міг +надіслати на сервер інтервал 0 секунд (`Number('') === 0` при `min={5}` +у розмітці); мінікарта малювалась у 180 px усередині контейнера 68 px, +тобто стиснута в 2.6 раза; `fmtBytes` існував у трьох копіях, і третя +вже розійшлась — спинялась на мегабайтах. + +Окремо варте запису: агент, що виправляв знайдене, **спростував частину +переліку**, який йому передали. Порада «повісити `aria-hidden` на +приховану копію рядка» була неправильною — копії ховаються через +`display:none`, який читач з екрана й так пропускає, а `aria-hidden` над +фокусованим елементом є справжньою помилкою доступності. Виправив +натомість те, що справді зламане: клавіатуру й саме подвійне малювання. + +Кожен новий тест перевірено на «падає до виправлення»: усі виправлення +тимчасово відкочувались, упало рівно 18 очікуваних перевірок. Тест, який +ніколи не падав, не доводить нічого — це той самий урок, що й із +пропущеними інтеграційними. + +### Що лишилось вимкненим свідомо + +Приймач трапів розгорнуто, але модуль не увімкнено в переліку зонда, і +162/udp назовні не опубліковано. Це не забуто: порт без автентифікації +приймає будь-кого, хто знає адресу, і відкривати його на бойовій машині +— рішення власника, а не наслідок того, що код готовий. + +Напівзаходу теж немає. Увімкнути модуль на нестандартному порту +всередині контейнера означало б отримати «трапи працюють» без жодного +трапа — рівно той вигляд роботи без роботи, проти якого написано весь +цей розділ. + + +## 2026-08-27 — SNMP-трапи: приймач на зонді, словник і джерело алертів `trap` + +Дорога від пристрою до бази була прокладена вся, крім першого метра. +Таблиця `ts.snmp_traps` існує з 0005, поле `LogBatch.traps` — у контракті +з перших днів gRPC, серверний `WriteLogs` писав трапи в базу. Не було +рівно одного: на зонді ніхто не слухав 162/udp. Клієнт, який налаштував +на комутаторі `snmp-server host <зонд> traps`, отримував порожній журнал +і жодного способу дізнатися, що справа не в комутаторі. + +Друге, що лікує ця робота, — відмова 0058 від джерела `trap`. Її +аргумент («без словника MIB умова звелася б до порівняння сирих OID») +був правильний, а висновок — надто широкий: він припускав, що словник +буває або повний, або ніякий. + +### Приймач на зонді + +Новий модуль `agent/internal/modules/traps`. Слухає 162/udp через +`gosnmp.TrapListener`, приймає v1, v2c та inform. + +Inform — головна відмінність від приймача syslog. Трап відправник шле й +забуває; inform він шле, доки не отримає підтвердження. Не підтвердити +означає перетворити одну подію на нескінченний потік — і саме тоді, коли +зонду найважче. Тому підтвердження не залежить від того, чи взяли ми +подію в чергу: слухач відповідає завжди, а вже потім вирішується, чи є +місце. Обробник тримає читальну горутину, тому в ньому рівно розбір і +черга, жодних звернень назовні. + +v1-трапи перекладаються у v2-ідентифікатор за RFC 3584: generic 0–5 → +`1.3.6.1.6.3.1.1.5.N+1`, generic 6 → `.0.`. Завдяки +цьому «linkDown зі старого світча по v1» і «linkDown із нового по v2c» — +одна подія, а не дві, під які треба заводити два правила. Поля заголовка +v1, яких у v2 немає (sysUpTime, enterprise, agent-address), дописуються +varbind-ами — теж за RFC 3584. + +Обмеження, щоб шторм не поклав ані зонд, ані базу: двадцять трапів за +секунду з однієї адреси (відро токенів на джерело, а не спільне — інакше +один комутатор із битою оптикою витісняє всю решту мережі), десять тисяч +у черзі з викиданням найстарішого, тридцять два varbind-и на трап, +512 символів на значення. Кожне обрізання видиме: у varbind-ах з'являється +запис із порожнім OID і текстом «ще N відкинуто». Мовчазне обрізання +перетворює «ми показали не все» на «більше нічого не було». + +Модуль вмикається окремим ключем `traps` у `-modules`, а не разом із +`syslog`: це другий порт у фаєрволі клієнта, і відкривати його «заодно» +ми не маємо права. Адреса — `-traps-listen` / `NETPULSE_TRAPS_LISTEN`. + +Syslog і трапи їдуть ОДНИМ стрімом `StreamLogs`. За межами зонда це та +сама подія з мережі: сусідні таблиці, ті самі подієві правила, той самий +момент аварії. Другий стрім означав би другий комплект підтверджень, +лімітів і черг переповнення. + +### Питання MIB, вирішене чесно + +Повного словника не буде, і це рішення, а не відкладена робота: +компілятор ASN.1, сховище вендорських MIB на кабінет і підтримка +діалектів — окремий продукт. Але між «усі MIB світу» і «нічого» лежить +те, що працює вже: + +* шість трапів, які визначає сам протокол (RFC 1215 / snmpTraps з + RFC 3418): coldStart, warmStart, linkDown, linkUp, + authenticationFailure, egpNeighborLoss. Вони вшиті в код + (`store/traps_mib.go`), бо однакові в кожного вендора й не мають + налаштовуватись кабінетом. Плюс імена обов'язкових полів трапа й + чотирьох стовпців ifTable, без яких linkDown не відповідає на єдине + питання, яке до нього ставлять, — «який саме порт»; +* власний словник кабінету — `inv.trap_oids`, редагується на сторінці + «Трапи». Кілька рядків, а не тисячі: у живому кабінеті трапів, на які + хтось дивиться, менше десятка; +* усе інше показується сирим OID із написом «невідомий трап». Саме з + написом. Назва, вгадана за схожістю префікса, була б брехнею рівно + там, де написаному довіряють найбільше, — у заголовку, який читають, + вирішуючи, чи вставати. + +Словник живе на сервері, а не на зонді, і застосовується при ЧИТАННІ. +Тому назва, дописана сьогодні, підписує й учорашній журнал. + +### Джерело `trap` у подієвому движку + +Умова вміє три речі, і рівно ті, які до трапа ставлять: `trap_oid` (що +сталося), `source_ip` (звідки — адресою або підмережею) і пара +`varbind_oid` / `varbind_value` (з яким значенням). Varbind порівнюється +зі СТОВПЦЕМ: у трапі приходить `ifOperStatus.7`, а людина пише +`ifOperStatus`, бо номера порту наперед не знає. + +Чого умова не вміє — сказано вголос, а не проігноровано. Зразок (regex), +`severity_lte` і `tag` у правилі на трапи не приймаються з поясненням, +чому саме: трап — не рядок тексту, а набір типізованих полів. Це не +причіпка: людина, яка переключила джерело правила з «Журнал» на «Трапи» +й лишила в формі зразок, інакше отримала б правило, що ловить УСЕ. +Правило без OID і без адреси теж відхиляється — воно підпало б під кожен +трап у мережі. + +Трап БЕЗ хоста піднімає алерт лише тоді, коли правило явно назвало +адресу джерела. Алерт без хоста нікуди не маршрутизується, не глушиться +вікном обслуговування й майже нічого не каже черговому. Ключ дедуплікації +для такого алерту будується від адреси (`TrapDedupKey`), інакше всі +незнайомці злилися б в один рядок «щось у мережі шле трапи». + +### Трап від невідомої адреси + +Окремий стан, а не тиха втрата. Такі трапи лягають у журнал із порожнім +`device_id` — і додатково в `inv.trap_unknown_sources`: адреса, зонд, +перша й остання поява, лічильник, останній OID. У журналі їх не видно +(він за часом, і три трапи на добу тонуть між тисячею своїх), а це +найцінніші три: майже завжди вони означають, що в мережі з'явилось +кероване залізо, про яке моніторинг не знає. + +Таблиця має стелю в 500 рядків на кабінет (тригер на INSERT). Адресу +відправника UDP підробити нічого не варте, і без обмеження цей шлях був +би способом наростити таблицю клієнта з його ж мережі. + +### Сторінка «Трапи» + +`web/src/pages/TrapsPage.tsx`, у меню поруч з алертами. Фільтри: період, +трап (списком тих, чиї назви ми знаємо, або довільним OID), адреса чи +підмережа джерела, пошук по varbind-ах, «лише невідомі». Стан фільтра в +адресі — відфільтрований журнал пересилають колезі. Угорі — блок +невідомих відправників, унизу — словник і перелік того, чого приймач НЕ +бачить: порожній журнал без цього блоку читається як «трапів не було», +хоча найчастіше означає «їх нікому приймати». + +### Що знято, а що ні + +Заборону на збереження правила з джерелом `trap` знято в коді +(`store.UnsupportedSourceReason`), бо саме там вона й стояла — 0058 +лишила по собі відмову з поясненням, а не обмеження в схемі. + +Правила, вимкнені 0058-ю, міграція 0065 навмисно НЕ вмикає назад. Вони +писались тоді, коли перевірки умови не існувало, і в їхньому `condition` +лежить що завгодно. Увімкнути їх означало б отримати або тишу, або потоп +— тобто знову «увімкнено й не працює». Правило лишається сірим, людина +його відкриває, і форма або приймає умову, або каже, чого бракує. + +### Формат зберігання varbind-ів + +Був мапою «oid → значення», став масивом `{o,t,v}`. Мапа втрачала тип +(`2` в ifOperStatus — це «down», а не число два), порядок (RFC вимагає +sysUpTime і snmpTrapOID першими) і повтори (трап із двома ifIndex +перетворювався на трап з одним). Читач розуміє обидві форми: архів, +накопичений раніше, не мав зникнути того вечора, коли вперше з'явилась +сторінка, на якій його видно. + +# 0066 — Ескалація сповіщень + +## Що було + +Таблиця `alr.escalation_policies` стоїть у схемі з 0007. Порожня. Коду під +нею немає жодного рядка — ані читання, ані запису. Це та сама порожня +обіцянка, що й тригери на трапи до 0058, тільки непомітніша: там людина +бачила правило в переліку й могла помітити нуль спрацювань, тут сутність +існувала лише в схемі. + +Ціна відсутності рахується однією ситуацією. О 02:40 падає ядро, +сповіщення йде в Telegram черговому, черговий спить. Система вважає, що +повідомила: `notify_count = 1`, у журналі доставки `status = 'sent'`, на +дошці алерт червоний. Формально все спрацювало. Фактично про аварію +дізнаються о 09:00 з дзвінка клієнта. Моніторинг, який повідомив рівно +один раз і замовк, відрізняється від відсутнього лише тим, що в нього є +алібі. + +## Рішення + +### До чого прив'язана драбина + +**До правила** (`alr.rules.escalation_policy_id`), типово `NULL`. + +- *До серйозності* — одна драбина накрила б усі `high` у кабінеті. Але + `high` на тестовому комутаторі й `high` на ядрі — це різні люди й різна + година ночі, а серйозність у них однакова, бо її ставить той самий + тригер. Розвести їх можна було б лише збрехавши про гостроту. +- *До групи хостів* — той самий комутатор ескалював би однаково + «завантаження порту 91%» і «пристрій не відповідає». Перше може чекати + до ранку, друге — ні. +- *До правила* — правило єдине місце, де «що сталося» і «на яких хостах» + уже вирішені разом. Саме там у 0018 оселилось «куди слати» + (`channel_ids`) з тим самим міркуванням. + +Серйозність і група нікуди не діваються: вони вже є в самому правилі +(`severity` + `selector`). + +`alr.routes.policy_id` лишається невикористаним свідомо — про це +поставлено `COMMENT`. Дві точки, де задається та сама драбина, означають, +що на «чому мене розбудили» треба читати обидві. + +### Схема (0066) + +- `alr.alert_escalations` — стан драбини, рівно один рядок на алерт + (PK = `alert_id`). Взводиться через `ON CONFLICT DO NOTHING`, тому + повторна доставка драбину не подвоює. +- `alr.escalation_steps` — журнал сходинок. Окремо від + `alr.notifications`, бо той відповідає на «чи пішло», а тут потрібне + «не пішло, бо алерт підтвердили о 02:47». У ескалації половина роботи — + не будити, і саме її треба вміти показати. +- `alr.escalation_policies` — доведена до придатного стану: `CHECK` на + кількість сходинок і повторів, `description`, `created_at/updated_at`, + зафіксований формат `steps`. + +`after_min` рахується від **початку алерту**, а не від попередньої +сходинки: людина проектує чергування абсолютними числами, а відносні +проміжки мовчки зсуваються від будь-якої вставки посередині. + +### Як проходить драбина + +`store.PlanEscalation` — **чиста функція**, і це головне архітектурне +рішення. Причина проста: «сходинка спрацювала» перевіряється легко й +доводить мало; «сходинка не спрацювала, бо алерт підтвердили / закрили / +хост заглушено / драбина протухла» — п'ять гілок, кожна з яких коштує +чийогось сну, і кожну треба вміти перевірити без бази. + +Правила: + +1. **Стан алерту читається перед КОЖНОЮ сходинкою.** Драбина, взведена о + 02:40, не має о 03:10 будити керівника через алерт, закритий о 02:45. + Перевірка «лише на початку» проходила б усі очевидні тести й ламалась + би рівно там, де дорого. +2. **Підтвердження й закриття зупиняють драбину.** `AckAlert` і + `CloseAlert` зупиняють її ще й негайно, у тій самій транзакції, — але + це косметика для картки алерту, а не механізм безпеки. Механізм — + пункт 1: він накриває і ті шляхи закриття, до яких код ack не + дотягується (гасіння прострочених подієвих, `ResolveMissing`, + вимкнення правила). +3. **Заглушення й вікно обслуговування сходинку не витрачають, а + відкладають.** Списана сходинка означала б, що півгодинне вікно тихо + роззброює драбину до кінця життя алерту — тобто дає рівно ту мовчазну + відмову, від якої ескалація рятує. Відкладання обмежене стелею життя + драбини (`deadline`), інакше воно ходило б по колу місяцями. +4. **Драбина взводиться лише тоді, коли перше сповіщення справді пішло.** + Немає каналів (тиха година правила, поріг серйозності, вимкнений + канал) — немає драбини: інакше о 15-й хвилині пішло б те, що на + нульовій свідомо не надіслали. +5. **Подієві алерти (0058) проходять драбину один раз, без повторів.** + Повтор — це ставка на те, що проблема триває, і зробити її можна лише + там, де існування алерту саме по собі є доказом. Метричний алерт живий + рівно доти, доки виконується умова. Подієвий такого доказу не дає: + рядок журналу стався один раз і «перестати ставатись» не може. + Повторювати за ним драбину означало б будити зміну по колу через + давно припинений нічний блимок порту. Сходинки в межах одного проходу + подієвий алерт проходить як усі — «конфіг змінився на ядрі, і ніхто не + глянув» має піднімати наступного. + +### Перезапуск процесу + +Стан живе в базі. Такт: **взяти з орендою → вирішити → записати → +надіслати**. + +- Падіння між взяттям і записом — оренда спливає, сходинку беруть знову, + вона не втрачена й не подвоєна. +- Падіння між записом і надсиланням — коштує однієї сходинки. Це та сама + угода, що вже зроблена для `notify_pending` у 0058: «спробували» не + дорівнює «доставили», і краще не надіслати, ніж надіслати вдруге о + третій ночі. + +Оренда — другий рубіж поверх advisory-блокування тіку. + +### Інтерфейс + +- Нова сторінка «Ескалації» — редактор драбин. Окремо від тригерів: одна + драбина накриває десяток тригерів і переписується раз на квартал, коли + міняється склад зміни, а тригер правлять щотижня. +- У формі тригера — вибір драбини, типово «без ескалації». +- **У картці алерту** — на якій сходинці він зараз, скільки їх усього, + коли буде наступна, а для зупиненої — чому саме. Без цього ескалація + перетворюється на невидиму магію, і перше ж «чому мене розбудили» не + має відповіді. +- Шапка самого повідомлення: `🔺 ЕСКАЛАЦІЯ 2/2 (Нічне чергування): не + підтверджено 45 хв`. Без неї третє повідомлення про ту саму аварію + читається як зациклена розсилка, і канал вимикають. + +## Чого не зроблено + +- **Тригери шаблонів драбину отримати не можуть.** Правила, породжені + `tpl.triggers`, правляться в шаблоні, а в тригері шаблону поля драбини + немає. Додавати його означало б чіпати схему шаблонів; драбина при + цьому тенант-специфічна, а шаблон — ні. +- **`alr.routes` лишились без інтерфейсу** (їх і не було). Ескалація до + них не прив'язана навмисно. +- **Ескалація не переходить на людину** (`user_id`), лише на канал. Черги + чергувань і календарів у продукті ще немає — драбина оперує тим, що є. + +## Перевірка + +`gofmt -l .`, `go vet ./...`, `go build ./...`, `go test ./...` — чисто. +`npx tsc --noEmit`, `npm run build` — чисто. + +Чого тести **не** покривають — окремо в звіті; коротко: SQL взяття з +орендою, атомарність і RLS перевіряються лише тестом проти бази +(`TestEscalationAgainstDB`), який мовчки пропускається без +`NETPULSE_TEST_DSN`, а бази в цьому оточенні немає. + +## 2026-08-27 — Диск закінчується мовчки: попередження, якого бракувало 0064 + +0064 навчила систему МІРЯТИ місце: розмір бази, приріст за добу, +«вистачить ще на N діб». Але міряти й попереджати — різні дієслова. +Цифри лежать на сторінці, куди заходять раз на квартал, а том +заповнюється в ніч із суботи на неділю. Перша ознака проблеми — Postgres +перестав приймати записи, тобто впав увесь продукт одночасно. + +Сторінка без сповіщення — це прилад без сигналізації. Він чесний, він +показує правду, і на нього ніхто не дивиться саме в ту годину, коли на +нього треба подивитись. + +### Пропозиція автора 0064 перевірена й не працює + +У розділі «Що лишається поза» 0064 записано найдешевший на вигляд шлях: + +> писати розмір бази й запас у добах звичайними метриками в `ts.series` +> (наприклад `db.size.bytes` і `db.days_left` на хості, що позначений +> машиною зонда), і тоді працює наявне метричне правило без жодної зміни +> коду. + +Це слушна за духом ідея — не чіпати движок правил, — і вона не працює +з чотирьох незалежних причин. Кожної окремо вистачило б. + +**Перша: метричне правило вимагає ХОСТА.** `store/alerts.go`, +`evalSeries` будує запит із + +```sql +JOIN inv.devices d ON d.id = se.device_id AND d.deleted_at IS NULL AND d.enabled +``` + +Ряд без хоста не дасть кандидата ніколи. «Хост машини зонда» — це +`core.agents`, а не `inv.devices`; рядка в інвентарі для нього не існує. +Тобто його довелося б завести. + +**Друга: заводити його не можна.** На `INSERT` в `inv.devices` висить +`bill.assert_device_limit` (0009). Штучний хост займає слот тарифу, а на +інсталяції, яка вже вперлась у стелю плану, `INSERT` просто впаде — +попередження про диск не встановиться саме там, де щільніше за все. І це +ще не найгірше: такий хост поповз би в інвентар, на мапи, у масові +операції й під наявні правила «даних немає взагалі» з порожнім +селектором, який за 0007 означає «до всього». Попередження про диск +почало б із того, що підняло б хибний алерт про самого себе. + +**Третя: правила живуть у кабінеті, том — в інсталяції.** `evalSeries` +фільтрує `se.tenant_id`, `alr.rules` має `tenant_id NOT NULL`. Це та сама +асиметрія, яку 0064 доводить про строки зберігання: чанк TimescaleDB не +знає кабінету. Щоб правило спрацювало в кожного, довелося б множити ряд і +кожен семпл на кількість кабінетів. + +**Четверта, і головна: `db.days_left` — не вимір, а частка.** Її +знаменник буває нулем (база не росте) і від'ємним (базу почистили), а +чисельник невідомий, поки людина не вказала ємність. `ts.samples.value` — +`double precision NOT NULL`: записати туди «немає відповіді» ніяк. +Записати нескінченність можна, і тоді першим зламається не прогноз, а +`encoding/json` — весь список алертів почне віддавати 500. Прогноз +мусить уміти мовчати; ряд вимірів такого не вміє. + +Висновок збережено: **не чіпати движок правил** — вимога слушна, і вона +виконана. Просто виконана інакше. + +### Як зроблено + +Перевірка живе там, де живе сам факт: у такті прибиральника даних, одразу +після знімка розмірів (`grpcapi/storage_retention.go`). Алерт піднімається +без правила (`rule_id IS NULL`) і без хоста (`device_id IS NULL`) — +обидві колонки необов'язкові з 0007, а всі читання алертів ходять туди +через `LEFT JOIN`. Розсилку робить наявний движок: алерт позначається +`notify_pending`, і `TakeNotifyPending` забирає його разом із подієвими +(0058). Жодного рядка в `alerting/**` і в движку правил не змінено. + +Перевірено й те, що алерт не зникне сам: `ExpireEventAlerts` працює через +`FROM alr.rules r WHERE r.id = a.rule_id`, тобто внутрішнім з'єднанням, і +рядок із NULL не чіпає. `ResolveMissing` шукає за `rule_id = $2` — так +само повз. Закриває алерт той самий такт, який його підняв. + +### До якого об'єкта чіпляти — ні до якого, і це рішення + +Спокуса привʼязати том до «хоста, на якому стоїть база», сильна: людина +шукає алерти на об'єктах. Але чесного об'єкта тут немає. Сервер бази — +не хост моніторингу; зонд — не база; штучний хост коштує слота тарифу й +ламає чужі правила (див. вище). + +Тому об'єкт — сама інсталяція, а місце, куди людина приходить, — сторінка +сховища. Саме там і показано стан: скільки лишилось, до якої дати +вистачить і чи налаштоване попередження. Алерт у дошці має текст, у якому +все це є словами, бо в Telegram посилання на сторінку нічого не варте +о третій ночі. + +### Типово УВІМКНЕНЕ + +Це навмисне порушення принципу 0064. Там нічого не вмикається само — +«строки не вмикаються самі, щоб оновлення не забрало нічиєї історії», — і +це правильно РІВНО ТОМУ, що строк видаляє. Помилкове типове значення там +знищує дані незворотно. + +Попередження не видаляє нічого. Найгірше, що коштує помилкове +спрацювання, — хвилина уваги чергового. Найгірше, що коштує помилкове +мовчання, — база, тобто весь продукт, без натяку заздалегідь. Ціни +несиметричні на кілька порядків. + +Друга причина практична: вимкнене типово попередження вмикає лише той, +хто вже думає про диск, а думає про диск той, у кого він уже +закінчувався. Тобто вимкнене типово воно рятує рівно тих, кого рятувати +пізно. + +### Пороги: головний — час, не рівень + +**21 доба до попередження.** Не «приблизно три тижні», а час, за який на +місце можна щось зробити в організації, а не в терміналі: помітити, +узгодити, замовити диск або вікно обслуговування, дочекатись його. Поріг, +коротший за цикл узгодження, повідомляє про те, чого вже не встигнути. +Чому не 30, які підсвічує сама сторінка: сторінка — довідка, її читає +той, хто вже прийшов; алерт будить. Якби він спрацьовував там само, де +фарбується сторінка, він спрацьовував би на кожній рівномірно зростаючій +інсталяції й був би вимкнений першого ж місяця. + +**4 доби до аварії.** П'ятниця, вечір → вівторок, ранок. Прогноз, знятий +у п'ятницю ввечері, має пережити вихідні й лишити ще один робочий день на +дію. Три доби з'їдають вихідні повністю; п'ять — це вже те саме +попередження іншими словами. Нижче цієї межі єдина дія, що встигає, — +скоротити строк зберігання: `drop_chunks` повертає місце негайно, а +купівля диска вже ні. Саме тому серйозність інша. + +**Рівень зайнятого лишається, але як другий поріг** — і не «на всяк +випадок». Прогноз мовчить рівно тоді, коли швидкості немає: спостережень +ще не набралось, приріст нульовий, або від'ємний після прибирання чи +видалення хоста. Останнє найгірше: одне видалення робить приріст +від'ємним на все вікно спостережень, і прогноз сліпне на місяць, поки +база тим часом росте як росла. Рівень бачить це без будь-якої історії. + +**Третій поріг — вільного менше за `max_wal_size`.** Підлога, нижче якої +відсотки безглузді: Postgres між контрольними точками має право написати +до `max_wal_size` журналу, і якщо стільки не влазить, він зупиняється +незалежно від того, 90 це відсотків чи 99. Число береться з +`current_setting('max_wal_size')`, а не з нашого уявлення про запас. + +### Вільне місце тепер можна міряти + +0064 сказала правду: у Postgres функції «скільки вільного на томі» немає, +а сервер застосунку живе в іншому контейнері. У штатному +`docker-compose` так і є. Але «не видно за замовчуванням» — не те саме, +що «не видно ніколи»: той, хто змонтує том бази в контейнер збірника хоч +тільки для читання й укаже шлях, отримує справжнє вільне місце замість +введеного числа. + +Різниця не косметична. Введене число застаріває мовчки — том розширили, +поруч поклали дамп, журнал попереднього запису роздувся від застряглого +слота реплікації. Виміряне бачить усе це, бо міряє ТОМ, а не суму відомих +нам таблиць. `statfs` рахує `Bavail`, а не `Bfree`: різниця — резерв +суперкористувача (типово 5 % на ext4), до якого Postgres не дотягнеться. + +Шлях перевіряється перед тим, як йому повірити: том, менший за самі бази, +— це не том бази, а кореневий розділ контейнера, у який показали +помилково. Такий шлях відхиляється з поясненням на сторінці, а не мовчки: +помилка тут дає не «немає числа», а «є число, і воно втішне». + +### Чого перевірки НЕ покривають + +Написано вісім наборів перевірок, і всі вони дивляться на чистий вирок за +числами. Що лишилось за їхніми межами: + +**Живої бази не торкається жодна.** Не перевірено: чи справді +`pg_ls_waldir()` доступний ролі застосунку (найімовірніше ні — потрібне +членство в `pg_monitor`, і тоді журнал просто не показується); чи +`pg_database_size` по всіх базах кластера не впаде на базі без права +`CONNECT`; чи `ON CONFLICT ... WHERE state IN (...)` справді потрапляє в +частковий унікальний індекс `alerts_active_dedup_uniq`. Три запити, які +або працюють, або не працюють, і дізнатись це можна лише на живому +Postgres. + +**`diskFree` не перевірено взагалі.** Це `syscall.Statfs` під тегом +`!windows`; на машині розробки він навіть не компілюється (перевірено +крос-складанням `GOOS=linux`). Чи правильно перемножуються `Bavail` і +`Bsize` на конкретній файловій системі, чи не переповнюється int64 на +дуже великому томі — не перевірено нічим. + +**Порядок доставки не перевірено.** Що алерт із `notify_pending` справді +підхопить `TakeNotifyPending` і що маршрут доставки з фільтром за +серйозністю його пропустить — прочитано в коді, не виконано. + +**Сліпа зона прогнозу після видалення хоста лишається сліпою.** Приріст +рахується різницею країв за 30 діб (так вирішила 0064, і два числа на +одній сторінці були б гіршими за одну сліпу зону). Одне велике видалення +робить цю різницю від'ємною — і прогноз мовчить до тридцяти діб. Покриває +це лише поріг за рівнем, тобто грубіший з двох. Перевірка на цей випадок +є, і вона перевіряє саме те, що спрацьовує рівень, — а не те, що прогноз +відновився. Він не відновлюється. + +**Не перевірено нічого про кілька кабінетів.** Алерт піднімається в +кожному чинному, і скільки їх, ніхто на перевірці не рахував. + +**Формулювання не перевірені людиною.** Тексти алерту й сторінки писались +із думкою про третю ночі, але о третій ночі їх ніхто не читав. + +# Поля заливки конфігу переїхали в каталог профілів + +## Що було + +Міграція 0060 додала `ncm.profiles` сім колонок про те, ЯК заливати +конфіг на пристрій (`apply_enter`, `apply_exit`, `apply_commit`, +`apply_negate`, `apply_block_exit`, `apply_supported`, `apply_note`) і +заповнила їх для шести профілів разовими `UPDATE`. + +`db/profiles/catalog.json` про ці поля не знав. Тобто джерело істини +роздвоїлось: команди ЗНЯТТЯ конфігу описані в каталозі, команди +ЗАЛИВКИ — у разовій міграції. Наступна платформа, додана в каталог, +з'явилась би без полів заливки, і відкат для неї мовчки не працював би +— рівно до того моменту, коли відкат знадобиться. + +## Що зроблено + +**Каталог отримав блок `apply`.** Він живе в самому профілі, поруч із +командою збору: `enter`, `exit`, `commit`, `negate`, `block_exit`, +`supported`, `note` і `comment` (причина рішення, їде коментарем у +SQL). Профілі, заведені не каталогом, а руками (`zte-zxan` — 0028, +`dlink-me` — 0034/0043), описані в новій секції `apply_external`: +тримати частину відповідей у каталозі, а частину в разовій міграції +означало б знову два джерела істини. + +Заливка задається профілем, а не родиною, і це не дрібниця. Родина +описує поведінку консолі (пейджер, запрошення), а не синтаксис +конфігурації: `zte-zxr10` стоїть у родині `huawei` через запрошення, а +конфігурується як Cisco. Успадкування від родини відправило б на +пристрій чужі команди. + +**Значення перенесено з 0060 без жодної зміни** — включно з причинами +відмови там, де заливки немає: + +* `mikrotik-routeros` — «RouterOS: рядки експорту — це add, і повторне + виконання не повертає запис, а створює дубль…»; +* `juniper-junos` — «JUNOS зберігається ієрархією у фігурних дужках — + це не набір команд…». + +Порожній `note` перетворив би свідоме рішення на схоже на недогляд, +тому причина переїхала разом зі значенням. + +**Генератор навчився другого виходу.** `build.py` тепер пише два файли: + +* `server/migrations/0014_ncm_profiles.sql` — самі профілі (як і був); +* `db/profiles/apply.generated.sql` — `UPDATE` з полями заливки. + +## Чому заливка НЕ поїхала в 0014 + +Дві незалежні причини, кожної досить. + +1. **Порядок.** Колонки `apply_*` заводить 0060, тобто ПІЗНІШЕ за 0014. + Вставка в 0014 не може згадати колонку, якої на той момент ще немає + — на чистій базі це впало б одразу. +2. **Сторож контрольних сум.** `netpulse-migrate` тримає sha256 + кожного застосованого файлу і зупиняє накат на розбіжності + («міграцію 0014 змінено після застосування»). 0014 уже накочена на + стенді, тож будь-яка зміна її тексту зупинила б оновлення всім. + +Тому 0014 лишилась байт-у-байт тією самою: sha256 +`0a50e0e9…` до й після, `git diff` по файлу порожній. + +`apply.generated.sql` свідомо НЕ є міграцією: номер призначає людина. +Над одним каталогом номерів паралельно працюють інші гілки, і вигаданий +генератором номер зіткнувся б із чужим. + +## Знайдено дорогою + +**Генератор писав не туди.** `MIGRATION` вказував на `db/migrations`, +хоч міграції переїхали в `server/migrations` ще тоді, коли їх почали +вшивати через `//go:embed` (див. `db/README-migrations.md`, де переїзд +описаний, а шлях у генераторі лишився старий). Наслідок: `--check` +завжди звітував «розійшлася з каталогом», а звичайний запуск падав на +записі в неіснуючий каталог. Виправлено; після виправлення каталог і +0014 збіглися байт-у-байт — розходження не було, був зламаний шлях. + +**Генератор ламав контрольну суму на Windows.** `write_text` у +текстовому режимі перекладає `\n` у `\r\n`, а `.gitattributes` вимагає +LF. CRLF-версія 0014 дає sha256 `501cb391…` замість `0a50e0e9…` — тобто +бінарник, зібраний на Windows після запуску генератора, зупинив би +накат на стенді. Тепер файли пишуться байтами, без перекладу. + +## Перевірено + +* `python build.py` — відпрацював, 147 платформ, 6 профілів із заливкою; +* `python build.py --check` — «згенероване актуальне»; +* `diff` згенерованої 0014 з наявною — **порожній**, sha256 не змінилась; +* окремим скриптом розібрано `UPDATE`-и з 0060 і звірено ЗНАЧЕННЯ всіх + семи полів для всіх шести профілів (незадані в 0060 поля добрано з + `DEFAULT` колонок) — **збіг по всіх**; +* `go build ./...`, `go test ./...` — зелено. + +Окремо про перевірку: «генератор відпрацював без помилки» тут нічого не +доводило б. Значуще саме те, що ТЕКСТ згенерованої 0014 не змінився й +що значення в каталозі дорівнюють значенням у вже накоченій 0060. + +## Що НЕ зроблено + +* **Міграції не створено.** `apply.generated.sql` чекає на номер, який + призначить людина. +* **Нових родин із заливкою не додано.** Спокуса була (`huawei`: + `system-view` / `return` / `undo `), але `save` на VRP перепитує + `[Y/N]`, а модель «команда → запрошення → наступна команда» такого не + переживає. Вигадана команда заливки пише на живе залізо — гірша за + її відсутність. +* **CI не звіряє каталог зі згенерованим.** `python db/profiles/build.py + --check` у `.forgejo/workflows/ci.yml` зловив би розходження, але + ci.yml поза цією зоною. + +## 2026-08-27 — Відмінювання: `plural()` більше не друкує число + +### Що було не так + +`plural(n, one, few, many)` у `web/src/components/ui.tsx` повертала рядок +**разом із числом** (`` `${n} ${форма}` ``). Тринадцять місць виклику з +шістдесяти дев'яти ставили число ще раз — на екрані виходило «5 5 хостів», +«Видалити назавжди 22 22 хости», «1 000 1000 рядків» (у StoragePage число +ще й друкувалось двічі в різному вигляді: з розділювачами тисяч ззовні й +голим зсередини). + +Три форми української множини стара функція вміла: 11–14 бралися за +двома останніми цифрами, 21 і 22 — за останньою. Тобто серйознішої вади, +ніж подвоєне число, у самій функції не було — вона була **навколо неї**: +у двох десятках місць число й іменник склеювались взагалі повз `plural()` +(«1 учасників», «21 активних алертів», «1 перевірок»), а в QueuesPage +множина була на дві форми через тернарник — «21 групах». + +### Рішення про підпис + +`plural(n, ['хост', 'хости', 'хостів'])` повертає **лише слово**; число +друкує місце виклику. + +Другий варіант — лишити «число + слово» і повиправляти місця виклику — +відкидає причину, а не наслідок: поки функція друкує число, поставити +його поруч ще раз може будь-хто, і саме так вада й з'явилась. Побічний +виграш: місце виклику лишає собі своє форматування числа — `num()` з +розділювачами тисяч і `toLocaleString('uk-UA')` більше не витісняються +голим `${n}` зсередини функції. + +Форми передаються **трійкою**, а не трьома аргументами, теж навмисно: +якби змінився лише зміст рядка, `tsc` лишився б зеленим, а числа тихо +зникли б з екрана. З тюплом кожен старий виклик перестає збиратись, і +компілятор сам показує все, що треба перевести — це те, чого перевірка +типів у цій задачі сама по собі не доводить. + +Додано `Math.abs()`: `-1 % 10` дорівнює `-1`, жоден `case` не збігається, +і від'ємний лічильник («не рахували») діставав би форму «багато». + +### Обсяг правки + +- 69 місць виклику `plural()` переведено на новий підпис (13 із них саме + ті, що друкували число двічі — там число вже стояло, тож зникло + дублювання). +- ~24 місця, де число й іменник були склеєні намертво, зведено до + `plural()`: AppShell, AlertsPage, AuditPage, BulkDeviceForm (переліки + «що зникне разом із хостом»), CommandsPage, CompliancePage, ConfigsPage, + GroupsPage, MapCanvas, MapPage, NodeInspector, ProfilesPage, QueuesPage, + RolesPage, TeamPage. +- `MapSettings`: рахунок груп винесено в `const allowed` — двічі писати + той самий `filter` у рядку означало б напрошуватись на розбіжність. + +### Перевірка + +`npx tsc --noEmit`, `npm run build`, `npx vitest run` (71 тест) — зелені. +Але зелений `tsc` тут нічого не доводив би: подвоєне число — не помилка +типів, збиралось воно й раніше. Тому результат прогнано **рядками**: +справжня стара реалізація і справжня нова, обидві через esbuild, на +справжніх шаблонах з коду, для 0/1/2/5/11/21/22: + +``` +BulkDeviceForm:833 до: «5 5 хостів» після: «5 хостів» +BulkDeviceForm:805 до: «Видалити назавжди 22 22 хости» після: «… 22 хости» +StoragePage:472 до: «1 000 1000 рядків» після: «1 000 рядків» +MirrorPage:312 до: «У ньому 21 21 версія з 7 7 хостів» після: «У ньому 21 версія з 7 хостів» +TeamPage:69 до: «1 учасників» після: «1 учасник» +AppShell:241 до: «21 активних алертів» після: «21 активний алерт» +QueuesPage:409 до: «99 у 21 групах» після: «99 у 21 групі» +``` + +Окремо перевірено механічно: біля кожного з 69 викликів число друкується +рівно один раз і саме те, за яким обрано форму. + +### Що лишилось не зробленим (свідомо) + +- `StoragePage` і `RulesPage` правлять інші агенти — там зачеплено лише + рядки з `plural()`. Незакриті склейки в них: «діб» (`keep_days`, + `min_days`, `from_days`, `to_days`, `days_left`) і «с» у RulesPage + (одиниця, не відмінюється). +- Одиниці вимірювання (`с`, `хв`, `МБ`, `Гбіт/с`) навмисно лишились без + `plural()` — вони не відмінюються. +- `DevicePassport`: «{checks_auto} заводить система» — це узгодження + дієслова, а не іменника; переписувати текст заради нього не стали. + +# Тести вебу й робочий локальний прогін перевірок + +**Стан до:** у вебі не було жодного тестового прогону — ні vitest, ні jest. +Перевірялось типами й живим браузером. `.forgejo/workflows/ci.yml` написано, але +раннера немає, тож CI не запускався жодного разу. + +**Стан після:** 103 тести в 9 файлах, `npm test` зелений, `sh scripts/check.sh` +проганяє всі перевірки обох світів однією командою. + +--- + +## Чому vitest, а не jest + +Проєкт на Vite. `vitest.config.ts` зроблено через +`mergeConfig(viteConfig, …)` — тести проходять ТУ САМУ трансформацію, що й +збірка (плагін React, tailwind, ті самі налаштування розвʼязання модулів). З +jest вийшло б два різні конвеєри, і розбіжність між ними знаходили б не тестом, +а на стенді. Окремий файл конфігурації, а не секція в `vite.config.ts`, — щоб не +чіпати файл, у якому паралельно працюють інші. + +Vitest бере `vite.config.ts` лише коли власного конфіга немає; щойно він +зʼявляється — вітовий ігнорується цілком. Саме тому `mergeConfig`, а не порожній +`defineConfig`: інакше JSX не трансформувався б узагалі. + +## Що обрано покривати — і чому саме це + +Приводом був сьогоднішній випадок із ізоляцією БД: зелений тест пропустив +поломку, бо перевіряв «чи не видно чужого», а зламалось «чи видно своє». +Висновок — **зелена перевірка доводить рівно те, що вона перевіряє** — визначив +відбір: не число покриття, а місця, де помилка ТИХА й дорога. + +1. **Порівняння конфігів (Myers, `linediff.ts`).** Найдорожче місце в усьому + вебі: людина дивиться на дві колонки й вирішує, чи котити відкат на живий + комутатор. Diff, який загубив рядок або зсунув нумерацію на одиницю, не падає + й не пише в журнал — він показує неправдиву картину, і рішення за нею + приймають як за правдивою. + + Тому перевіряються не приклади, а **інваріанти на 500 випадкових парах** + (детермінований LCG, не `Math.random` — тест, що падає раз на сто прогонів і + не відтворюється, гірший за відсутність тесту): із ділянок точно + відновлюються обидві версії; рядки, оголошені однаковими, справді однакові; + нумерація обох колонок іде без пропусків і повторів; текст у рядку відповідає + своєму номеру. Окремо — розрахунковий випадок Myers: один змінений рядок + серед 30 000 (якби відкидання спільного початку й кінця зламалось, тест не + впав би — він би завис). + +2. **Числа, одиниці, час.** `plural`, `ago`, `humanInterval`, `fmtBytes`, + `formatBps`, `fmtValue`, `fmtBps`. Перевіряються МЕЖІ (1023/1024, + 999e6/1e9, 59/60 с), знак і дата з майбутнього — саме там і живуть тихі + помилки. + +3. **Геометрія мапи.** `autoSides`, `autoLabelPositions`, `labelCandidates`, + `estimateLabelBox`, жадібна розкладка підписів у `labelLayout.ts`. Помилка + тут не падає: лінія йде в обхід через півсхеми або підписи лягають один на + одного — і те, й те виглядає як «мапу малювали абияк», а не як помилку + програми. + +4. **Дві поведінки, а не два вигляди.** `Modal` — єдине місце, де самовільне + закриття зʼїдає набране в довгій формі (тест на виділення тексту, доведене + мишею за край вікна). `ConfirmDialog` — останній екран перед незворотною + дією; його дві тихі поломки коштують найдорожче: другий клік = другий запит + на видалення, а закриття після невдачі читається як «виконано». + +## Правило, яке трималось під час написання + +**Не закріплювати ваду зеленим тестом.** Знайдене під час роботи пішло в звіт і +в `web/TESTING.md`, а не в `expect`. Виняток — коли поточне правило треба +зафіксувати свідомо (сортування `uniq` кодами символів; згортання кирилиці в +`safeName`); тоді причина сказана в коментарі, щоб зміна була свідомим кроком, а +не побічним ефектом. + +## Про `plural` — випадок, вартий запису + +Задача передбачала ЧЕРВОНИЙ тест на відому ваду: функція повертала «число + +слово», а вісім місць виклику ставили число ще раз, і на екрані виходило +«5 5 хостів». Поки писались тести, паралельний агент цю ваду полагодив — +змінив підпис на `plural(n, ['хост','хости','хостів'])`, що повертає ЛИШЕ слово, +і перевів усі ~60 місць виклику (я застав проміжний стан, коли `tsc` давав 60 +помилок «Expected 2 arguments, but got 4»). + +Тому червоного тесту немає, а є зелений інваріант, який тримає виправлення: + +```ts +it('не друкує число — його ставить місце виклику', () => { + for (const n of [0, 1, 2, 5, 11, 21, 100, 1234]) { + expect(plural(n, HOSTS)).not.toMatch(/\d/) + expect(HOSTS).toContain(plural(n, HOSTS)) + } +}) +``` + +Перевірка стоїть на САМІЙ функції, а не на місцях виклику: їх шістдесят, і +завтра буде сімдесят. Поки функція не друкує числа, подвоїти його нема чому. + +## CI без раннера, і що з цим робити + +`.forgejo/workflows/ci.yml` доповнено кроком тестів вебу (до збірки: збірка +коштує хвилини, а падіння тесту видно за секунди) і переведено на +`upload-artifact@v4`. + +Але раннера як не було, так і немає, а перевірка, яку нікому виконати, не ловить +нічого. Тому додано `scripts/check.sh` — та сама послідовність локально: + +``` +gofmt + vet + тести server → gofmt + vet + тести agent → крос-збірка зонда +під 5 платформ → tsc → vitest → vite build +``` + +Три рішення в скрипті варті пояснення: + +- **Не зупиняється на першій невдачі.** Інакше, щоб побачити три поламані місця, + довелось би прогнати його тричі. Ненульовий код виходу все одно повертається — + для гачка pre-push. +- **Не піднімає Postgres.** Тести проти бази самі пропускаються без + `NETPULSE_TEST_DSN`. Це єдине місце, де скрипт свідомо ВУЖЧИЙ за CI, і в + ci.yml про це сказано вголос. +- **`npm ci` лише коли `node_modules` немає.** На кожному прогоні він викидає + залежності й ставить наново — хвилини замість секунд. + +Скрипт відпрацював із першого разу й одразу знайшов те, чого ніхто не бачив: сім +файлів `server/` лежать у робочій копії з CRLF, хоча `.gitattributes` вимагає LF +(`git ls-files --eol` показує `i/lf w/crlf`). На Linux-раннері вони були б +зелені, локально — червоні. Скрипт про цю пастку каже прямо, щоб її не «полагодили» +через `gofmt -w`. + +## Найважливіший артефакт — `web/TESTING.md` + +Половина файла — перелік того, чого тести НЕ покривають, і він стоїть ПЕРЕД +переліком покритого. Це не формальність: доки цей перелік тут і чесний, зелений +прогін не можна прийняти за доказ справності вебу. Головні дірки: перетягування +вузлів на мапі (d3-drag не запускається синтетичними подіями — jsdom не рахує ні +`PointerEvent`, ні матрицю SVG), усе полотняне в `Minimap` (у jsdom +`getContext('2d')` віддає `null`), `pointsOnPath` (немає `getTotalLength`), +`api/*` і `hooks/*` цілком, усі сторінки з `src/pages/`. + +# Вади вебу, знайдені при написанні тестів, — виправлення + +**Стан до:** 103 тести, усі зелені. Частина з них закріплювала ПОМИЛКОВУ +поведінку («кирилиця згортається в підкреслювач», «кожен рядок є в DOM двічі») — +свідомо, щоб перехід був помітним кроком, а не побічним ефектом. + +**Стан після:** 137 тестів у 12 файлах, `tsc --noEmit`, `npm run build` і +`vitest run` зелені. Одинадцять пунктів переліку перевірено кожен окремо: десять +підтверджено й виправлено, один (пункт 10) підтверджено наполовину — про це +нижче. + +**Головне, що варто винести:** жодна з одинадцяти вад не є помилкою типів. +`tsc` і `vite build` проходили з усіма ними. Тому на кожну є тест, а те, що +тестом не ловиться (розміри, фокус, розкладка), перевірено живим браузером — +не читанням. + +--- + +## Як перевірялось, що тест справді щось ловить + +Написати тест ПІСЛЯ виправлення легко так, що він був би зелений і до нього. +Тому всі виправлення тимчасово відкочувались назад (копія файлів, точкові +зворотні заміни) і прогонявся весь набір. Впало рівно 13 + 5 перевірок — тих +самих, що й мали: + +``` +FAIL datatable > рядок із дією отримує фокус і відкривається з клавіатури +FAIL datatable > картка телефона теж відкривається з клавіатури +FAIL format > ago > округлення переносить у більшу одиницю, а не вигадує 60 хв +FAIL format > fmtValue > ненульове не показує нулем +FAIL format > formatBps > рахує межу за модулем: мінус не має ламати одиницю +FAIL interval > очищене поле НЕ перетворюється на нуль +FAIL interval > число нижче межі назовні не йде, поки з поля не пішли +FAIL interval > порожнє поле на виході повертається до чинного значення +FAIL interval > зміна значення ззовні на непресетне вмикає ручний режим +FAIL minimap > контейнер завширшки рівно MAP_W — одне число, а не два +FAIL minimap > ширина не задається класом +FAIL minimap > рядок із самих пробілів не малюється зовсім +FAIL minimap > порожній рядок не бере навіть колір збігу пошуку +FAIL modal > переносить фокус у перше поле вікна +FAIL modal > вікно без полів бере фокус на себе +FAIL modal > повертає фокус туди, звідки вікно відкрили +FAIL modal > Tab із останнього поля повертається на перше +FAIL modal > тло стає inert, поки вікно відкрите +``` + +Після відновлення — 137 зелених. + +--- + +## 1. `safeName` згортав кирилицю (`components/ConfigViewer.tsx`) + +Підтверджено. `\w` у JavaScript — рівно ASCII, тому «Київ-ядро» і «Львів-ядро» +давали ОДНАКОВЕ ім'я файла `_-_`. Людина вивантажувала два конфіги й отримувала +один: другий тихо затирав перший, а помічають це тоді, коли по втраченому вже +щось відновлюють. + +Це третя така функція в проєкті. `sanitizePath` у `server/internal/store/ncm.go` +хворів тим самим і вилікуваний через `unicode.IsLetter`; тут прямий відповідник — +`\p{L}` із прапорцем `u`. Звідти ж узято згортання `..`: ім'я потрапляє в атрибут +`download` як є, і ланцюжок крапок — це спроба вийти за теку завантажень. Крапки +всередині лишаються («10.0.0.1» має бути собою). + +Тест `configview.test.ts` переписано, а не обійдено: замість «згортається в +підкреслювач» тепер «лишає кирилицю», «різні імена лишаються різними» і +«згортає «..», але не чіпає звичайні крапки». + +## 2. `IntervalInput` слав нуль секунд (`components/ui.tsx`) + +Підтверджено обидві частини. + +Перша: у розмітці `min={5}`, у коді `Math.max(0, Number(e.target.value))`, а +`Number('') === 0`. Досить було стерти вміст поля, щоб надрукувати нове число, — +і між двома натисканнями клавіш перевірка вже мала інтервал «0 секунд». Тепер +ручний ввід тримає ВЛАСНИЙ рядок, а назовні йде лише придатне число; межі +притискаються один раз, на виході з поля (`onBlur`), щоб «4» на шляху до «45» не +перетворювалось на 5 і не їхало на сервер. + +Друга: `manual` виводився з `value` лише при монтуванні. Місце, де це справді +трапляється, знайдено: `PrototypeEditor.tsx:173` і `TemplatesPage.tsx:466,618` +мапляться з `key={i}` — прибрали сусідній рядок, і той самий екземпляр поля +дістає чуже значення. При 45 секундах список показував порожній вибір замість +числа. Синхронізація зроблена узаконеним у React способом «правка стану під час +рендера при зміні пропа», і лише в один бік: непресетне значення вмикає ручний +режим, зворотно не вимикає — інакше поле забирали б з-під рук того, хто друкує. + +Новий файл `src/test/interval.test.tsx` (8 перевірок). + +## 3. Мінікарта була стиснута ~2.6× (`components/Minimap.tsx`) + +Підтверджено: полотно малювалось у `MAP_W = 180`, а контейнер мав клас +`w-[68px]` від попередньої ширини. Коментар «Було 68 — і цього замало» лишився, +клас — ні. На екрані це виглядало не як помилка, а як «щось дрібне й +нерозбірливе». + +Джерело тепер одне: ширина йде в `style={{ width: MAP_W }}`, класу немає взагалі. +Саме так, а не «полагодити число в класі»: Tailwind збирає довільні значення +статично, тож клас не може слідувати за константою — і наступна зміна `MAP_W` +розійшлася б із ним так само мовчки. Тест закріплює обидва боки: ширина дорівнює +`MAP_W` і клас `w-[…]` відсутній. + +Виміряно в живому Chrome: контейнер 180 CSS px, `canvas.width` 225 при +`devicePixelRatio` 1.25 — тобто рівно 1:1, стиснення немає. + +## 4. Чотири форматувальники швидкості → один + +Підтверджено: `edgeState.formatBps` (`1.0 Гбіт/с`), `DeviceCollected.fmtBps` +(`1.00 Gbps`), `InterfaceFilterEditor.speedLabel` (`1G`), `MetricChart.fmtValue` +(`1.0G`). Одна й та сама лінія в різних місцях називалась по-різному. + +Зведено до `formatBps(bps, 'full' | 'short')` у `ui.tsx` — не до чотирьох +функцій і не до однієї без параметра. Короткі форми справді потрібні: у колонці +фільтра інтерфейсів і на осі графіка назва одиниці не влазить. Але це РЕЖИМ +одного форматувальника зі спільними порогами, а не окрема реалізація; коротка +форма — це той самий `fmtValue`, тобто число на осі й число в колонці тепер +збігаються. + +`fmtValue` при цьому лишився окремою функцією (теж у `ui.tsx`) і навмисно: він +підписує не лише біти, а й відсотки, градуси та байти, і дописувати йому +«Гбіт/с» означало б поставити біти на вісь температури. + +Точність повної форми: гігабіти завжди з десятою (так було й лишається), +мегабіти й кілобіти — з десятою до 10 одиниць і без неї далі. Це не косметика: +трафік на порту доступу живе саме в діапазоні 1–10 Мбіт/с, і `1 Мбіт/с` замість +`1.5 Мбіт/с` — півтора раза похибки на око. + +## 5. `fmtBytes` було ТРИ, а не дві + +Підтверджено дві названі копії — `BackupDefaultsForm.tsx` (експортована) і +`BulkDeviceForm.tsx` (дослівна локальна). Третю знайшов новий тест: у +`pages/AuditPage.tsx` лежала ще одна, і вона вже РОЗІЙШЛАСЬ — спинялась на +мегабайтах, тож вкладення понад гігабайт показувалось як «1048576.0 МБ». Прибрані +обидві копії. + +`src/test/formatters.test.ts` — тест не поведінки, а тексту джерел: скільки в +дереві оголошень із таким іменем. Вада, яку він стереже, поведінкою не ловиться +взагалі: дві дослівні копії працюють однаково рівно до дня, коли одну правлять, а +другу — ні. Джерела читаються через `import.meta.glob` з `?raw`, а не через +`node:fs`: у проєкті немає `@types/node`, і тягнути їх заради одного тесту +дорожче. + +## 6. `formatBps` не брав модуль + +Підтверджено: `-1e9` давало «-1000000000 біт/с». Тепер межі порівнюються з +модулем — як у `fmtBps`, який саме тому й був другою функцією. + +## 7. `fmtValue` показував нуль для ненульового + +Підтверджено: `0.0001` → `"0.000"`. На осі графіка нуль означає «даних немає», а +не «мало»; на графіку втрат пакетів це різниця між «канал чистий» і «канал +сипле». Тепер: якщо три знаки після коми дають нуль, а значення не нульове — +рахуються значущі цифри (`0.0001`, `0.00025`), а зовсім дрібне йде показником +степеня. Нуль лишається нулем лише коли значення справді нульове. + +## 8. `ago()` перескакував одиницю + +Підтверджено: 3599 с → «60 хв», 86399 с → «24 год». Причина — межа перевірялась +за секундами, а число рахувалось окремо. Тепер одиниця обирається ПІСЛЯ +округлення: 3599 → «1 год», 86399 → «1 дн». «60 хв» поруч зі справжніми «59 хв» і +«1 год» читається як третя одиниця виміру, якої немає, — а дивляться на цей підпис +саме тоді, коли хост щойно перестав відповідати. + +## 9. `Modal` не керував фокусом + +Підтверджено все чотири: не переносив фокус усередину, не тримав його, не +повертав на місце виклику, тло не позначалось. + +Зроблено: +- фокус переїжджає в ПЕРШЕ поле тіла вікна (у підтвердженні видалення це + «Скасувати» — і це правильно, бо Enter одразу після відкриття не має нічого + стирати); вікно без полів бере фокус на себе через `tabIndex={-1}`; +- Tab-кільце по вмісту вікна в обидва боки; +- `inert` на СУСІДІВ кожного предка вікна аж до `body`, а не на один контейнер: + вікно живе всередині сторінки, тож «усе, крім нього» — це саме сусіди по + дорозі нагору. Позначки, які вже стояли, не чіпаються — інакше два відкриті + вікна знімали б їх одне одному; +- фокус повертається туди, звідки вікно відкрили. + +Порядок ефектів має значення й закріплений коментарем: `inert` оголошено ПЕРШИМ, +бо React прибирає ефекти в порядку оголошення — тло встигає перестати бути inert +до того, як фокус повертається на кнопку, яка в ньому лежить. Інакше браузер +відмовив би у фокусі, і вийшла б рівно та вада, яку це лікує. + +Перевірено в живому Chrome, а не лише в jsdom (jsdom `inert` не виконує): +фокус після відкриття стоїть у першому полі, кнопка тла НЕ фокусується, саме +вікно не inert, після закриття фокус на кнопці-відкривачі, `[inert]` у документі +не лишилось жодного. + +## 10. `DataTable`: подвійне малювання і недоступність із клавіатури + +**Спростовано частину діагнозу.** Дві копії рядка ховались через `hidden md:table` +і `md:hidden`, тобто `display: none`. Читач з екрана `display: none` пропускає — +обидві копії він НЕ читає, у дереві доступності в будь-який момент рівно одна. +З тієї ж причини `aria-hidden` на прихованій копії був би не найдешевшим +рішенням, а неправильним: у ній тепер є елементи з `tabIndex`, а фокусований +елемент усередині `aria-hidden` — це вже справжня помилка доступності. + +**Підтверджено решту, і виправлено обидві.** Клавіатура: `onClick` на `` і +`
  • ` не дає ні фокусу, ні Enter — людина без миші просто не могла відкрити хост +зі списку, при тому що клік по рядку там єдиний шлях. Додано `tabIndex` і +обробку Enter/Пробілу (з `preventDefault`, інакше пробіл прокрутив би сторінку). +Ролей `role="button"` на `` навмисно не ставилось: усередині таблиці це +ламає її ж семантику. + +Подвійне малювання прибрано насправді, а не заклеєно: подачу обирає +`useSyncExternalStore` над `matchMedia('(min-width: 768px)')` — той самий поріг, +що й `md:` у Tailwind, тож на око нічого не змінилось. Без `matchMedia` (jsdom, +старі рушії) показується таблиця: дані видно всі, просто густіше. Підписка йде +ще й на `resize` як страхувальна сітка — подія медіазапиту приходить не в кожному +середовищі, а помилка тут тиха: людина повертає телефон і лишається з таблицею на +375 px. + +Перевірено в живому Chrome на обох ширинах: 1280 — таблиця, списку немає, +`tabindex="0"` на ``; 375 — шість карток, таблиці немає, колонка +`hideOnMobile` у картку не потрапила, горизонтального переповнення немає, +мінікарта прихована. Перемикання без перезавантаження перевірено подією `resize`. + +## 11. `Minimap.buildShape`: порожній рядок малювався звичайним + +Підтверджено. Рядок із самих пробілів отримує `KIND_BLANK`, але `len > 0` — тож +доходив до малювання; `KIND_COLOR[0]` не існує, спрацьовував запасний +`KIND_BODY`. А саме порожні рядки й розділяють розділи конфігу: намальовані як +текст, вони стирають межі, по яких людина на мінікарту й дивиться. + +Рішення «яким кольором» винесено в чисту `rowColor(shape, i, hit)`, яка віддає +`null` для рядка, що не малюється. Це єдине місце мінікарти, яке можна перевірити +без полотна (у jsdom 2D-контексту немає взагалі), — і саме там вада й сиділа. + +--- + +## Що перевірено ЛИШЕ читанням коду + +- Ширина мінікарти в режимі порівняння (`ConfigDiff.tsx`): `Minimap` там + вживається двічі з тими самими пропсами й без власних класів ширини — отже, + та сама виправлена ширина. Живим браузером дивився лише `ConfigViewer`. +- Ширші підписи швидкості в картці хоста (`1.0 Гбіт/с` замість `1.00 Gbps`, + на два символи довше): комірки `DataTable` без фіксованих ширин, тож розкладка + не залежить від довжини. Не міряно. +- Поведінка `inert` у вікні поверх вікна (два `Modal` одночасно): логіка + «не чіпати вже позначене» прочитана, але живим сценарієм не відтворювалась. +- Форматування швидкості на самій мапі (`TrafficEdge`, `EdgeInspector`): форма + там не змінювалась (та сама повна українська), тому оком не звірялось. + +## Що НЕ виправлено — і чому + +- `fmtValue` не влито у `formatBps` (пункт 4 просив «звести до одного»). Він + підписує будь-яку метрику, не лише швидкість; спільним у них зроблено те, що + справді спільне — саме скорочення числа. Коротка форма `formatBps` — це виклик + `fmtValue`, тобто реалізація одна. +- `aria-hidden` на прихованій копії `DataTable` (пункт 10) — копії більше немає, + а на `display: none` він і не був потрібен. Причина розписана в пункті 10. +- Попередження збірки про чанк >500 kB — було до цієї роботи, до переліку не + входить, і код-спліт мапи це окрема задача. diff --git a/ROADMAP.md b/ROADMAP.md index 952aba5..de0af33 100644 --- a/ROADMAP.md +++ b/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 не запускається синтетичними подіями; серверний бік цієї дії тестами покритий. diff --git a/agent/cmd/netpulse-agent/main.go b/agent/cmd/netpulse-agent/main.go index 91b4e91..4bf3e67 100644 --- a/agent/cmd/netpulse-agent/main.go +++ b/agent/cmd/netpulse-agent/main.go @@ -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) diff --git a/agent/internal/config/config.go b/agent/internal/config/config.go index 71adadf..80b05cf 100644 --- a/agent/internal/config/config.go +++ b/agent/internal/config/config.go @@ -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 } diff --git a/agent/internal/localfiles/bundle.go b/agent/internal/localfiles/bundle.go new file mode 100644 index 0000000..5c688d7 --- /dev/null +++ b/agent/internal/localfiles/bundle.go @@ -0,0 +1,92 @@ +package localfiles + +import ( + "fmt" + "strings" +) + +// Склейка набору в один текст. +// +// # Чому один текст, а не окрема версія на кожен файл +// +// Питання, з яким приходять до архіву сервера, — «що на ньому змінилось +// із учора», а не «що змінилось у nginx.conf». Один набір відповідає на +// нього одним diff; десять окремих історій довелося б спершу зводити +// докупи очима. +// +// Практичніша половина причини — розкладка Git. Шлях у репозиторії +// виводиться з пари (ім'я хоста, config_type) у ДВОХ місцях: коли конфіг +// приймають (store.StoreConfig) і коли репозиторій відновлюють із бази +// (store.SyncGit). Версія на файл вимагала б переписати формулу шляху в +// обох і вигадати другу розкладку поряд із наявною. Набір лягає в неї як +// є: <хост>/files.cfg, гілка device/, — і дзеркало, ретеншен, +// перегляд і порівняння працюють без жодної правки. +// +// Ціна, яку платимо чесно: `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() +} diff --git a/agent/internal/localfiles/collect.go b/agent/internal/localfiles/collect.go new file mode 100644 index 0000000..f5116a7 --- /dev/null +++ b/agent/internal/localfiles/collect.go @@ -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 "" +} diff --git a/agent/internal/localfiles/deny.go b/agent/internal/localfiles/deny.go new file mode 100644 index 0000000..58a928c --- /dev/null +++ b/agent/internal/localfiles/deny.go @@ -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 "" +} diff --git a/agent/internal/localfiles/localfiles_test.go b/agent/internal/localfiles/localfiles_test.go new file mode 100644 index 0000000..64b014a --- /dev/null +++ b/agent/internal/localfiles/localfiles_test.go @@ -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) + } +} diff --git a/agent/internal/localfiles/machine.go b/agent/internal/localfiles/machine.go new file mode 100644 index 0000000..edb6f89 --- /dev/null +++ b/agent/internal/localfiles/machine.go @@ -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 "" +} diff --git a/agent/internal/localfiles/owner_unix.go b/agent/internal/localfiles/owner_unix.go new file mode 100644 index 0000000..4c0ac31 --- /dev/null +++ b/agent/internal/localfiles/owner_unix.go @@ -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) +} diff --git a/agent/internal/localfiles/owner_windows.go b/agent/internal/localfiles/owner_windows.go new file mode 100644 index 0000000..6bb7c7e --- /dev/null +++ b/agent/internal/localfiles/owner_windows.go @@ -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 "-" } diff --git a/agent/internal/localfiles/policy.go b/agent/internal/localfiles/policy.go new file mode 100644 index 0000000..07a454a --- /dev/null +++ b/agent/internal/localfiles/policy.go @@ -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 +} diff --git a/agent/internal/modules/filecfg/filecfg.go b/agent/internal/modules/filecfg/filecfg.go new file mode 100644 index 0000000..be8499f --- /dev/null +++ b/agent/internal/modules/filecfg/filecfg.go @@ -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()) +} diff --git a/agent/internal/modules/snmp/snmp.go b/agent/internal/modules/snmp/snmp.go index 3f2c560..94e5756 100644 --- a/agent/internal/modules/snmp/snmp.go +++ b/agent/internal/modules/snmp/snmp.go @@ -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) } diff --git a/agent/internal/modules/snmp/walk.go b/agent/internal/modules/snmp/walk.go new file mode 100644 index 0000000..12b8e95 --- /dev/null +++ b/agent/internal/modules/snmp/walk.go @@ -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):], ".") +} diff --git a/agent/internal/modules/snmp/walk_test.go b/agent/internal/modules/snmp/walk_test.go new file mode 100644 index 0000000..2580188 --- /dev/null +++ b/agent/internal/modules/snmp/walk_test.go @@ -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]) + } +} diff --git a/agent/internal/modules/topology/topology.go b/agent/internal/modules/topology/topology.go index df55cdb..39755ea 100644 --- a/agent/internal/modules/topology/topology.go +++ b/agent/internal/modules/topology/topology.go @@ -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, "; ")) } diff --git a/agent/internal/modules/traps/mib.go b/agent/internal/modules/traps/mib.go new file mode 100644 index 0000000..9d613be --- /dev/null +++ b/agent/internal/modules/traps/mib.go @@ -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 +} diff --git a/agent/internal/modules/traps/parse.go b/agent/internal/modules/traps/parse.go new file mode 100644 index 0000000..37c1904 --- /dev/null +++ b/agent/internal/modules/traps/parse.go @@ -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]) + "…" +} diff --git a/agent/internal/modules/traps/receiver.go b/agent/internal/modules/traps/receiver.go new file mode 100644 index 0000000..f777430 --- /dev/null +++ b/agent/internal/modules/traps/receiver.go @@ -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() +} diff --git a/agent/internal/modules/traps/traps_test.go b/agent/internal/modules/traps/traps_test.go new file mode 100644 index 0000000..2ad24eb --- /dev/null +++ b/agent/internal/modules/traps/traps_test.go @@ -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 +} diff --git a/agent/internal/ncmx/apply.go b/agent/internal/ncmx/apply.go new file mode 100644 index 0000000..06ecb3e --- /dev/null +++ b/agent/internal/ncmx/apply.go @@ -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 "" +} diff --git a/agent/internal/ncmx/apply_test.go b/agent/internal/ncmx/apply_test.go new file mode 100644 index 0000000..cbc8688 --- /dev/null +++ b/agent/internal/ncmx/apply_test.go @@ -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("справжню відмову не помічено") + } +} diff --git a/agent/internal/ncmx/collect.go b/agent/internal/ncmx/collect.go index 04ddf53..bc43352 100644 --- a/agent/internal/ncmx/collect.go +++ b/agent/internal/ncmx/collect.go @@ -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) +} diff --git a/agent/internal/ncmx/commands.go b/agent/internal/ncmx/commands.go new file mode 100644 index 0000000..f56be34 --- /dev/null +++ b/agent/internal/ncmx/commands.go @@ -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 +} diff --git a/agent/internal/ncmx/commands_test.go b/agent/internal/ncmx/commands_test.go new file mode 100644 index 0000000..70681d1 --- /dev/null +++ b/agent/internal/ncmx/commands_test.go @@ -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) +} diff --git a/agent/internal/ncmx/transport.go b/agent/internal/ncmx/transport.go index deaa6e7..32614b6 100644 --- a/agent/internal/ncmx/transport.go +++ b/agent/internal/ncmx/transport.go @@ -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, // Ключі мережевого обладнання не звіряються. // // Це свідоме рішення, а не недогляд. Зонд стоїть усередині diff --git a/agent/internal/scheduler/scheduler.go b/agent/internal/scheduler/scheduler.go index 4588ad3..2952c25 100644 --- a/agent/internal/scheduler/scheduler.go +++ b/agent/internal/scheduler/scheduler.go @@ -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{ diff --git a/agent/internal/session/apply_jobs.go b/agent/internal/session/apply_jobs.go new file mode 100644 index 0000000..fe4ad4a --- /dev/null +++ b/agent/internal/session/apply_jobs.go @@ -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()) + } +} diff --git a/agent/internal/session/command_jobs.go b/agent/internal/session/command_jobs.go new file mode 100644 index 0000000..7d2a0e2 --- /dev/null +++ b/agent/internal/session/command_jobs.go @@ -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) + } +} diff --git a/agent/internal/session/config_jobs.go b/agent/internal/session/config_jobs.go index b717115..014f1b0 100644 --- a/agent/internal/session/config_jobs.go +++ b/agent/internal/session/config_jobs.go @@ -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 diff --git a/agent/internal/session/localfiles_jobs.go b/agent/internal/session/localfiles_jobs.go new file mode 100644 index 0000000..4538066 --- /dev/null +++ b/agent/internal/session/localfiles_jobs.go @@ -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) + } +} diff --git a/agent/internal/session/logs.go b/agent/internal/session/logs.go index 3430efb..18ede69 100644 --- a/agent/internal/session/logs.go +++ b/agent/internal/session/logs.go @@ -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 } } diff --git a/agent/internal/session/session.go b/agent/internal/session/session.go index e417dd6..d0ba1a5 100644 --- a/agent/internal/session/session.go +++ b/agent/internal/session/session.go @@ -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 diff --git a/db/profiles/README.md b/db/profiles/README.md index 1720817..1cfb640 100644 --- a/db/profiles/README.md +++ b/db/profiles/README.md @@ -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`). ## Родини diff --git a/db/profiles/apply.generated.sql b/db/profiles/apply.generated.sql new file mode 100644 index 0000000..5f34a8e --- /dev/null +++ b/db/profiles/apply.generated.sql @@ -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'; diff --git a/db/profiles/build.py b/db/profiles/build.py index 88824a8..350c4fc 100644 --- a/db/profiles/build.py +++ b/db/profiles/build.py @@ -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 diff --git a/db/profiles/catalog.json b/db/profiles/catalog.json index 688b4a8..289d2b3 100644 --- a/db/profiles/catalog.json +++ b/db/profiles/catalog.json @@ -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", diff --git a/deploy/.env.example b/deploy/.env.example index 4cedfb6..62549ba 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -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). # Формат: =<32 байти hex>. Id потрібен для зміни ключа: старий # лишається в списку, щоб розшифрувати вже записане. diff --git a/deploy/Dockerfile.server b/deploy/Dockerfile.server index 5eae444..b71d0df 100644 --- a/deploy/Dockerfile.server +++ b/deploy/Dockerfile.server @@ -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/ diff --git a/deploy/README.md b/deploy/README.md index 01a196e..784972d 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -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" +``` + ## Зміна ключа шифрування Ключі перелічуються через кому, новий — першим: diff --git a/deploy/RLS-EXISTING-INSTALL.md b/deploy/RLS-EXISTING-INSTALL.md new file mode 100644 index 0000000..017605a --- /dev/null +++ b/deploy/RLS-EXISTING-INSTALL.md @@ -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 < 'ПЕРШИЙ-КАБІНЕТ-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= +NETPULSE_WORKER_PASSWORD= +``` + +Обидва — разом. `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` без + політики знову з'явиться мовчки. Найдешевше — повторити ту саму + перевірку в наступній міграції, що додає таблиці. diff --git a/deploy/act-runner.config.yml b/deploy/act-runner.config.yml new file mode 100644 index 0000000..a6cafa6 --- /dev/null +++ b/deploy/act-runner.config.yml @@ -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 diff --git a/deploy/docker-compose.ci.yml b/deploy/docker-compose.ci.yml new file mode 100644 index 0000000..08cef03 --- /dev/null +++ b/deploy/docker-compose.ci.yml @@ -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 \ +# --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 diff --git a/deploy/files.conf.example b/deploy/files.conf.example new file mode 100644 index 0000000..bcc24b6 --- /dev/null +++ b/deploy/files.conf.example @@ -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 — власне посвідчення зонда. +# +# Крім того зонд не піде за символьним посиланням, ціль якого не +# дозволена цим файлом сама по собі, і не збереже двійковий чи порожній +# файл. Про кожен пропуск він пише причину — вона видно в стенограмі +# завдання збору. diff --git a/docker-compose.yml b/docker-compose.yml index b9e2edc..0178748 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/gen/go/netpulse/v1/ncm.pb.go b/gen/go/netpulse/v1/ncm.pb.go index 38f8157..4e0d713 100644 --- a/gen/go/netpulse/v1/ncm.pb.go +++ b/gen/go/netpulse/v1/ncm.pb.go @@ -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, }, diff --git a/netpulse b/netpulse new file mode 100644 index 0000000..77a384a --- /dev/null +++ b/netpulse @@ -0,0 +1,1821 @@ +#!/usr/bin/env sh +# +# NetPulse — установка й обслуговування однією командою. +# +# Чому це існує. Розгортання складалося з вісімнадцяти змінних оточення +# й шестисот рядків інструкції. Рішень людини серед тих вісімнадцяти — +# п'ять; решта це або секрети, які не можна давати вводити руками, або +# значення, у яких є одна правильна відповідь, або PG_SHARED_BUFFERS, +# який мусить рахуватись із пам'яті машини: помилка в ньому на хості з +# 4 ГБ — це не «повільніше», це «Postgres не піднявся». +# +# Процедуру, яку клієнт не може виконати правильно з першого разу, не +# рятує докладніша інструкція. Її рятує те, що виконувати нічого. +# +# ./netpulse install поставити (ідемпотентно: можна повторювати) +# ./netpulse check лікар: ті самі твердження на живій системі +# ./netpulse backup дамп бази + ключі, без яких дамп марний +# ./netpulse restore -f відновлення з рамкою TimescaleDB +# ./netpulse upgrade перезбирання, міграції, перевірка +# ./netpulse logs [service] +# +# Головне про install — останній крок. Установник НЕ МАЄ ПРАВА сказати +# «готово» на підставі того, що контейнери піднялись. Рівно так уже +# було: усі служби «Started», а вхід у систему повертав 403, бо під +# новою роллю база віддавала нуль кабінетів. Тому останнє, що робить +# install, — заходить у систему справжнім паролем через HTTP і звіряє, +# що кабінет назвався, а переліки, які наливають міграції, не порожні. +# Не пройшло — не «готово з попередженням», а зупинка з назвою кроку. +# +# POSIX sh, не bash: сервер клієнта може бути будь-яким, і dash тут +# зустрічається частіше, ніж здається. + +set -u + +# CDPATH= — не помилка з пробілом, а гасіння CDPATH: якщо він виставлений +# в оточенні (а на робочих машинах буває), cd мовчки піде не туди, і скрипт +# правитиме .env у чужому каталозі. +# shellcheck disable=SC1007 +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +ENV_FILE="$ROOT/.env" +CONF_FILE="$ROOT/netpulse.conf" + +# Порт API всередині мережі docker. Самоперевірка ходить саме сюди, а не +# крізь проксі: нас цікавить ланцюг «HTTP → автентифікація → база», і +# додавати до нього TLS і DNS означало б плутати дві різні поломки. +API_INTERNAL="http://127.0.0.1:8080" + +DRY=0 +ASSUME_YES=0 +OWNER_PASSWORD="" +RESTORE_FILE="" + +# Заповнюється кроками; підсумок друкується один раз у кінці. +OUT_URL="" +OUT_OWNER_PW="" +OUT_ENROLL="" +OUT_OWNER_EXISTED=0 + +# --------------------------------------------------------------------- +# Вивід +# --------------------------------------------------------------------- +# +# Кольори лише в терміналі: у `./netpulse check > лист.txt`, який +# клієнт надсилає в підтримку, escape-послідовності перетворюють +# зрозумілий звіт на кашу. + +if [ -t 1 ]; then + C_B=$(printf '\033[1m'); C_R=$(printf '\033[31m') + C_Y=$(printf '\033[33m'); C_G=$(printf '\033[32m'); C_0=$(printf '\033[0m') +else + C_B=''; C_R=''; C_Y=''; C_G=''; C_0='' +fi + +STEP_NAME="" +STEP_NO=0 + +say() { printf '%s\n' "$*"; } +ok() { printf ' %sok%s %s\n' "$C_G" "$C_0" "$*"; } +warn() { printf ' %sувага%s %s\n' "$C_Y" "$C_0" "$*"; } +bad() { printf ' %sні%s %s\n' "$C_R" "$C_0" "$*"; } + +step() { + STEP_NO=$((STEP_NO + 1)) + STEP_NAME=$1 + printf '\n%s== крок %d · %s%s\n' "$C_B" "$STEP_NO" "$STEP_NAME" "$C_0" +} + +# die зупиняє все й називає КРОК. Назва кроку тут не оздоба: людина +# читає її вголос у телефон, і від неї залежить, чи розбирається далі +# вона сама, чи надсилає вивід. +die() { + printf '\n%sЗУПИНКА на кроці «%s»%s\n' "$C_R" "$STEP_NAME" "$C_0" + for _l in "$@"; do printf ' %s\n' "$_l"; done + if [ "${CMD:-}" = install ]; then + printf '\n Нічого незворотного не сталося: install можна запускати повторно —\n' + printf ' наявні секрети він підхопить із .env, а не перевипустить.\n' + fi + exit 1 +} + +# --------------------------------------------------------------------- +# docker compose +# --------------------------------------------------------------------- +# +# Дві несумісні реалізації з однаковою назвою. Обгортка визначається +# один раз, щоб решта скрипта не знала, яка з них тут. + +DC_KIND="" + +pick_compose() { + if docker compose version >/dev/null 2>&1; then + DC_KIND="plugin" + elif command -v docker-compose >/dev/null 2>&1; then + DC_KIND="legacy" + else + DC_KIND="" + fi +} + +dc() { + case "$DC_KIND" in + plugin) ( cd "$ROOT" && docker compose "$@" ) ;; + legacy) ( cd "$ROOT" && docker-compose "$@" ) ;; + *) return 127 ;; + esac +} + +# --------------------------------------------------------------------- +# Дрібні помічники +# --------------------------------------------------------------------- + +have() { command -v "$1" >/dev/null 2>&1; } + +# Версія у вигляді числа, щоб порівнювати без sort -V (його немає в +# busybox-середовищах, а установник має працювати й там). +ver_num() { + printf '%s' "$1" | sed 's/^[vV]//; s/[^0-9.].*//' | + awk -F. '{ printf "%d%03d%03d", $1, $2, $3 }' +} + +ver_ge() { [ "$(ver_num "$1")" -ge "$(ver_num "$2")" ] 2>/dev/null; } + +# Значення з .env. Береться ОСТАННЄ входження: саме так читає docker +# compose, і розходження тут означало б, що установник і compose бачать +# різні паролі. +env_get() { + [ -f "$ENV_FILE" ] || return 1 + sed -n "s/^$1=//p" "$ENV_FILE" | tail -1 +} + +# Рядкове поле JSON. Ключ обов'язково після «{» або «,», інакше пошук +# "token" знаходить хвіст "access_token" і повертає не той рядок. +json_str() { + sed -n "s/.*[{,][[:space:]]*\"$1\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" | + head -1 +} + +# Чи є у відповіді хоч один елемент переліку. +# +# Рахувати елементи в sh дорого й крихко, а нам потрібне рівно одне +# твердження: «перелік не порожній». Після прибирання пробілів масив +# об'єктів завжди починається з «[{», а порожній — з «[]». Працює і для +# голого масиву, і для загорнутого в об'єкт, тобто не залежить від +# того, як саме назвали поле. +json_nonempty() { + printf '%s' "$1" | tr -d ' \n\r\t' | grep -q '\[{' +} + +# Секрети — ТІЛЬКИ hex. +# +# `openssl rand -base64 24` дає символи «/», «+» і «=». Пароль їде +# всередині DSN виду postgres://user:пароль@db, де «/» починає ім'я +# бази: пароль обривається мовчки, а помилка приходить як +# «password authentication failed» — тобто вказує не туди. Ця вада вже +# ловилась у цьому проєкті; hex не має жодного символу, який щось +# означає в URL, і коштує нам лише довжини рядка. +rand_hex() { + if have openssl; then + openssl rand -hex "$1" + elif [ -r /dev/urandom ]; then + od -An -tx1 -N "$1" /dev/urandom | tr -d ' \n' + else + return 1 + fi +} + +# Пароль для людини: його читають з екрана й набирають у браузері. +# Алфавіт без 0/O/1/l/I — не з ввічливості, а тому що переплутана +# літера тут виглядає як «пароль не підходить», і розбирають це годину. +rand_pass() { + if [ -r /dev/urandom ]; then + LC_ALL=C tr -dc 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789' \ + < /dev/urandom 2>/dev/null | head -c 24 + printf '\n' + else + return 1 + fi +} + +# --------------------------------------------------------------------- +# Стан хоста +# --------------------------------------------------------------------- + +host_mem_mb() { + if [ -r /proc/meminfo ]; then + awk '/^MemTotal:/ { printf "%d", $2 / 1024; exit }' /proc/meminfo + elif have sysctl && sysctl -n hw.memsize >/dev/null 2>&1; then + sysctl -n hw.memsize | awk '{ printf "%d", $1 / 1048576 }' + else + printf '0' + fi +} + +# Кількість ядер. nproc є не всюди (busybox його не має), тому далі +# /proc/cpuinfo і sysctl. Нуль означає «не знаємо» — і викликач має +# обрати обережне значення, а не вдавати, що ядро одне. +host_cpus() { + if have nproc; then + nproc 2>/dev/null || printf '0' + elif [ -r /proc/cpuinfo ]; then + awk '/^processor/ { c++ } END { printf "%d", c }' /proc/cpuinfo + elif have sysctl && sysctl -n hw.ncpu >/dev/null 2>&1; then + sysctl -n hw.ncpu + else + printf '0' + fi +} + +# Вільне місце в МБ для каталогу. Питаємо і про репозиторій, і про +# /var/lib/docker: томи бази лежать там, і на розрізаному диску це +# різні файлові системи. +free_mb() { + df -Pk "$1" 2>/dev/null | awk 'NR==2 { printf "%d", $4 / 1024 }' +} + +# Чи слухає хтось порт. Три способи, бо ss немає на старих системах, +# netstat викинули з нових, а /proc/net є на будь-якому Linux. +port_busy() { + _p=$1 + _proto=${2:-tcp} + if have ss; then + if [ "$_proto" = udp ]; then + ss -lunH 2>/dev/null | awk '{print $5}' | grep -q "[:.]$_p\$" && return 0 + else + ss -ltnH 2>/dev/null | awk '{print $4}' | grep -q "[:.]$_p\$" && return 0 + fi + return 1 + fi + if have netstat; then + if [ "$_proto" = udp ]; then + netstat -lun 2>/dev/null | awk '{print $4}' | grep -q "[:.]$_p\$" && return 0 + else + netstat -ltn 2>/dev/null | awk '{print $4}' | grep -q "[:.]$_p\$" && return 0 + fi + return 1 + fi + _hex=$(printf ':%04X' "$_p") + for _f in "/proc/net/$_proto" "/proc/net/${_proto}6"; do + [ -r "$_f" ] || continue + if awk -v p="$_hex" -v pr="$_proto" ' + NR > 1 && substr($2, length($2) - 4) == p && + (pr == "udp" || $4 == "0A") { found = 1 } + END { exit !found }' "$_f"; then + return 0 + fi + done + return 1 +} + +host_tz() { + if [ -r /etc/timezone ]; then + tr -d ' \n\r' < /etc/timezone + elif [ -L /etc/localtime ]; then + readlink /etc/localtime | sed 's|.*/zoneinfo/||' + else + printf '' + fi +} + +# Адреса, якою цю машину видно ззовні. Не питаємо інтернет: установник +# не має права ходити в мережу за спиною того, хто його запустив, а на +# закритому контурі такий запит ще й повисне на хвилину. +host_ip() { + if have ip; then + _a=$(ip route get 1.1.1.1 2>/dev/null | sed -n 's/.*src \([0-9.]*\).*/\1/p' | head -1) + [ -n "$_a" ] && { printf '%s' "$_a"; return 0; } + fi + if have hostname; then + _a=$(hostname -I 2>/dev/null | awk '{print $1}') + [ -n "$_a" ] && { printf '%s' "$_a"; return 0; } + fi + if have ifconfig; then + ifconfig 2>/dev/null | + sed -n 's/.*inet \(addr:\)\{0,1\}\([0-9.]*\).*/\2/p' | + grep -v '^127\.' | head -1 + return 0 + fi + printf '' +} + +# --------------------------------------------------------------------- +# КРОК 1. Передпольотна перевірка +# --------------------------------------------------------------------- +# +# Усе, що тут перевіряється, ламається пізніше і дорожче: без місця на +# диску Postgres падає посеред міграцій, без пам'яті не піднімається +# зовсім, а зайнятий 443 виявляється тоді, коли все інше вже працює й +# треба розбирати, чому не відкривається сторінка. + +preflight() { + step "передпольотна перевірка" + + _fatal=0 + + if ! have docker; then + _fatal=1 + bad "docker не знайдено" + else + _dv=$(docker version --format '{{.Server.Version}}' 2>/dev/null) + if [ -z "$_dv" ]; then + _fatal=1 + bad "docker є, але демон не відповідає (docker info падає)" + elif ver_ge "$_dv" 20.10; then + ok "docker $_dv" + else + _fatal=1 + bad "docker $_dv — потрібен 20.10 або новіший" + fi + fi + + pick_compose + case "$DC_KIND" in + plugin) + _cv=$(docker compose version --short 2>/dev/null) + if ver_ge "$_cv" 2.0; then + ok "docker compose $_cv" + else + _fatal=1 + bad "docker compose $_cv — потрібен 2.0 або новіший" + fi + ;; + legacy) + _fatal=1 + bad "знайдено лише docker-compose v1; compose-файл проєкту використовує + можливості v2 (profiles, depends_on.condition). Потрібен пакет + docker-compose-plugin" + ;; + *) + _fatal=1 + bad "docker compose не знайдено" + ;; + esac + + _mem=$(host_mem_mb) + if [ "$_mem" -eq 0 ]; then + warn "не вдалося визначити обсяг пам'яті — розрахунок shared_buffers буде обережним" + elif [ "$_mem" -lt 2048 ]; then + _fatal=1 + bad "пам'яті $_mem МБ. Postgres, TimescaleDB, кеш, API і колектор на + такій машині не вміщуються: перший запис у базу впаде на OOM. + Мінімум — 2 ГБ, робоче значення — 4 ГБ" + elif [ "$_mem" -lt 4096 ]; then + warn "пам'яті $_mem МБ — вистачить на десятки хостів, не на сотні" + else + ok "пам'ять: $_mem МБ" + fi + + _free=$(free_mb "$ROOT") + [ -n "$_free" ] || _free=0 + if [ -d /var/lib/docker ]; then + _freed=$(free_mb /var/lib/docker) + [ -n "$_freed" ] || _freed=0 + [ "$_freed" -lt "$_free" ] && _free=$_freed + fi + if [ "$_free" -eq 0 ]; then + warn "не вдалося виміряти вільне місце" + elif [ "$_free" -lt 20480 ]; then + _fatal=1 + bad "вільно $_free МБ. Образи займають близько 2 ГБ, і це разова + витрата; решту з'їдає телеметрія, яка росте щодня. Нижче 20 ГБ + установка закінчиться переповненим томом за тиждень" + elif [ "$_free" -lt 51200 ]; then + warn "вільно $_free МБ — стартувати вистачить, але профіль зберігання + беріть economy й дивіться на сторінку «Сховище»" + else + ok "вільно на диску: $_free МБ" + fi + + # Порти, зайняті НАШИМИ ж контейнерами, — не конфлікт, а повторний + # запуск. Розрізняти обов'язково, інакше ідемпотентність втрачається + # на другому ж install. + if dc ps --services --filter status=running 2>/dev/null | grep -q '^proxy$'; then + ok "порти 80/443/9443 тримає вже піднятий proxy цієї ж інсталяції" + else + _busy=0 + for _port in 80 443 9443; do + if port_busy "$_port" tcp; then + _fatal=1 + _busy=1 + bad "порт $_port/tcp уже зайнятий. Це майже завжди сторонній + веб-сервер (nginx, apache) — його треба зупинити або перенести, + бо проксі NetPulse займає 80 і 443 цілком" + fi + done + if [ "$_busy" -eq 0 ]; then + ok "порти 80, 443, 9443 вільні" + fi + fi + + if [ "$_fatal" -ne 0 ]; then + if [ "$DRY" -eq 1 ]; then + warn "у сухому прогоні це не зупиняє — на справжній установці зупинило б" + else + die "Перелічене вище треба виправити до установки." + fi + fi +} + +# --------------------------------------------------------------------- +# КРОК 2. Відповіді +# --------------------------------------------------------------------- + +CFG_DOMAIN="" +CFG_EMAIL="" +CFG_TRAPS="" +CFG_RETENTION="" +CFG_TZ="" + +read_conf() { + step "відповіді" + + if [ -f "$CONF_FILE" ]; then + CFG_DOMAIN=$(sed -n 's/^[[:space:]]*DOMAIN=//p' "$CONF_FILE" | tail -1 | tr -d ' \r') + CFG_EMAIL=$(sed -n 's/^[[:space:]]*ADMIN_EMAIL=//p' "$CONF_FILE" | tail -1 | tr -d ' \r') + CFG_TRAPS=$(sed -n 's/^[[:space:]]*TRAPS_FROM=//p' "$CONF_FILE" | tail -1 | tr -d ' \r') + CFG_RETENTION=$(sed -n 's/^[[:space:]]*RETENTION=//p' "$CONF_FILE" | tail -1 | tr -d ' \r') + CFG_TZ=$(sed -n 's/^[[:space:]]*TZ=//p' "$CONF_FILE" | tail -1 | tr -d ' \r') + ok "прочитано $CONF_FILE" + else + ok "netpulse.conf немає — беруться типові відповіді" + say " (зразок із поясненнями: cp netpulse.conf.example netpulse.conf)" + fi + + case "$CFG_RETENTION" in + "") CFG_RETENTION=normal ;; + economy|normal|archive) ;; + *) die "RETENTION=$CFG_RETENTION — такого профілю немає." \ + "Дозволені: economy, normal, archive." ;; + esac + + if [ -n "$CFG_EMAIL" ] && [ -z "$CFG_DOMAIN" ]; then + warn "ADMIN_EMAIL заданий без DOMAIN. Сертифікат буде самопідписаний, + Let's Encrypt не задіяний, пошта нікуди не піде — прибрано" + CFG_EMAIL="" + fi + + if [ -n "$CFG_DOMAIN" ]; then + case "$CFG_DOMAIN" in + *[!a-zA-Z0-9.-]*) die "DOMAIN=$CFG_DOMAIN містить неприпустимі символи." ;; + *.*) ok "домен: $CFG_DOMAIN" ;; + *) die "DOMAIN=$CFG_DOMAIN не схожий на доменне ім'я (немає крапки)." \ + "Якщо домену ще немає — лишіть порожнім, буде самопідписаний сертифікат." ;; + esac + if [ -n "$CFG_EMAIL" ]; then + ok "пошта для Let's Encrypt: $CFG_EMAIL" + else + warn "ADMIN_EMAIL порожній: про проблеми з продовженням сертифіката + ніхто не дізнається листом" + fi + else + ok "домену немає — самопідписаний сертифікат на IP" + fi + + ok "строки зберігання: $CFG_RETENTION" +} + +# --------------------------------------------------------------------- +# КРОК 3. Обчислені значення +# --------------------------------------------------------------------- + +VAL_DOMAIN="" +VAL_TZ="" +VAL_SHBUF="" +VAL_DFMEM="" +VAL_PGBGW="" +VAL_TRAPS_BIND="" +VAL_TRAPS_SRC="" + +compute() { + step "обчислені значення" + + if [ -n "$CFG_DOMAIN" ]; then + VAL_DOMAIN=$CFG_DOMAIN + else + VAL_DOMAIN=$(host_ip) + if [ -z "$VAL_DOMAIN" ]; then + if [ "$DRY" -eq 1 ]; then + VAL_DOMAIN="" + warn "IP визначити не вдалося; у сухому прогоні це не зупиняє" + else + die "Не вдалося визначити IP-адресу цієї машини," \ + "а без адреси проксі не знає, на що відповідати." \ + "Впишіть DOMAIN у netpulse.conf — можна й голий IP." + fi + else + ok "адреса системи: $VAL_DOMAIN (IP цієї машини)" + fi + fi + + VAL_TZ=$CFG_TZ + if [ -z "$VAL_TZ" ]; then + VAL_TZ=$(host_tz) + [ -n "$VAL_TZ" ] || VAL_TZ=Europe/Kyiv + ok "часовий пояс: $VAL_TZ (з хоста)" + else + ok "часовий пояс: $VAL_TZ" + fi + + # shared_buffers — чверть пам'яті, і саме тут установник заробляє + # своє існування. Значення з .env.example (512MB) на машині з 4 ГБ + # разом із рештою служб дає перевищення пам'яті: Postgres резервує + # буфери одразу, ядро вбиває його першим, і виглядає це як «база + # іноді падає», а не як помилка налаштування. + _mem=$(host_mem_mb) + if [ "$_mem" -eq 0 ]; then + VAL_SHBUF=256MB + warn "пам'ять невідома — shared_buffers=256MB (свідомо мало)" + else + _sb=$((_mem / 4)) + [ "$_sb" -lt 128 ] && _sb=128 + [ "$_sb" -gt 8192 ] && _sb=8192 + VAL_SHBUF="${_sb}MB" + ok "shared_buffers=$VAL_SHBUF (чверть від $_mem МБ)" + fi + + # Стеля кешу — з тієї самої цифри, а не окремим розрахунком. + # + # Dragonfly без стелі бере пам'ять із доступної на машині, тобто + # рахує ТУ САМУ, яку щойно порахував shared_buffers. Два незалежні + # «візьму чверть» від одного пирога дають суму більшу за пиріг, і + # ядро вбиває когось третього — найімовірніше API, бо він найлегший. + # Восьма частина: кеш тут допоміжний, у ньому живуть сесії й + # короткочасні лічильники, а не дані. + if [ "$_mem" -eq 0 ]; then + VAL_DFMEM=256mb + else + _df=$((_mem / 8)) + [ "$_df" -lt 128 ] && _df=128 + [ "$_df" -gt 2048 ] && _df=2048 + VAL_DFMEM="${_df}mb" + fi + ok "стеля кешу=$VAL_DFMEM" + + # Фонові робітники TimescaleDB конкурують за ядра з самими запитами. + # Вісім на двох ядрах означає, що стиснення чанків заважає опитуванню + # хостів — а опитування тут головне. + _cpu=$(host_cpus) + if [ "$_cpu" -le 0 ]; then + VAL_PGBGW=4 + warn "кількість ядер невідома — фонових робітників 4" + else + VAL_PGBGW=$((_cpu * 2)) + [ "$VAL_PGBGW" -lt 2 ] && VAL_PGBGW=2 + [ "$VAL_PGBGW" -gt 8 ] && VAL_PGBGW=8 + ok "фонових робітників TimescaleDB=$VAL_PGBGW (ядер: $_cpu)" + fi + + compute_traps +} + +compute_traps() { + VAL_TRAPS_SRC="" + case "$CFG_TRAPS" in + "") + VAL_TRAPS_BIND=127.0.0.1 + ok "трапи: лише з цієї машини (порт не виставлений назовні)" + ;; + any|ANY|any/0|0.0.0.0/0) + VAL_TRAPS_BIND=0.0.0.0 + warn "трапи: приймаються ВІД БУДЬ-КОГО на 162/udp." + say " Порт не має автентифікації: хто знає адресу, той пише вам у базу." + say " Якщо це не було свідомим рішенням — TRAPS_FROM=<ваша підмережа>." + ;; + */*) + VAL_TRAPS_BIND=0.0.0.0 + VAL_TRAPS_SRC=$CFG_TRAPS + ok "трапи: з підмережі $CFG_TRAPS (обмеження — правилом DOCKER-USER)" + ;; + *) + # Адреса цієї машини — прив'язуємось до інтерфейсу, і жодного + # правила у файрволі не треба. Чужа адреса — це «приймати лише + # звідти», а прив'язкою docker такого не висловити. + if host_has_addr "$CFG_TRAPS"; then + VAL_TRAPS_BIND=$CFG_TRAPS + ok "трапи: лише на інтерфейсі $CFG_TRAPS" + else + VAL_TRAPS_BIND=0.0.0.0 + VAL_TRAPS_SRC="$CFG_TRAPS/32" + ok "трапи: лише від $CFG_TRAPS (обмеження — правилом DOCKER-USER)" + fi + ;; + esac + + if [ -n "$VAL_TRAPS_SRC" ] && [ "$DRY" -eq 0 ]; then + have iptables || die \ + "TRAPS_FROM=$CFG_TRAPS означає «приймати лише звідти», а зробити це" \ + "можна лише правилом у ланцюжку DOCKER-USER — iptables на цьому хості немає." \ + "" \ + "Варіанти: поставити iptables; або вказати в TRAPS_FROM адресу" \ + "внутрішнього інтерфейсу цієї машини (тоді обмеження дає сама прив'язка);" \ + "або TRAPS_FROM=any, якщо відкрити порт усім — свідоме рішення." + fi + + if [ -n "$VAL_TRAPS_BIND" ] && [ "$VAL_TRAPS_BIND" != 127.0.0.1 ]; then + if port_busy 162 udp; then + warn "порт 162/udp уже хтось слухає (snmptrapd?) — зонд його не займе" + fi + fi +} + +host_has_addr() { + if have ip; then + ip -o addr show 2>/dev/null | grep -q "inet6\{0,1\} $1/" + elif have hostname; then + hostname -I 2>/dev/null | tr ' ' '\n' | grep -qx "$1" + else + return 1 + fi +} + +# Правило джерела для трапів. Ідемпотентне: -C перевіряє наявність, +# і повторний install не плодить копій. +apply_traps_firewall() { + [ -n "$VAL_TRAPS_SRC" ] || return 0 + _added=0 + if ! iptables -C DOCKER-USER -p udp --dport 162 ! -s "$VAL_TRAPS_SRC" -j DROP 2>/dev/null; then + if iptables -I DOCKER-USER 1 -p udp --dport 162 ! -s "$VAL_TRAPS_SRC" -j DROP 2>/dev/null; then + _added=1 + else + die "Не вдалося додати правило в ланцюжок DOCKER-USER." \ + "Найчастіша причина — запуск не від root." \ + "Правило, яке треба додати руками:" \ + " iptables -I DOCKER-USER 1 -p udp --dport 162 ! -s $VAL_TRAPS_SRC -j DROP" + fi + fi + if [ "$_added" -eq 1 ]; then + ok "правило DOCKER-USER додано: 162/udp лише з $VAL_TRAPS_SRC" + warn "правило живе до перезавантаження. Щоб пережило — + iptables-save (пакет iptables-persistent) або власний unit" + else + ok "правило DOCKER-USER уже на місці" + fi +} + +# --------------------------------------------------------------------- +# КРОК 4. Секрети +# --------------------------------------------------------------------- +# +# Найважливіше тут — те, чого крок НЕ робить: він не перевипускає +# NETPULSE_DEK, якщо той уже є. Новий ключ шифрування означає, що всі +# збережені паролі SSH і SNMP-community перетворюються на нечитний +# шифротекст, і назад їх не дістати ніяк. Тому повторний install +# підхоплює наявні значення, а не генерує свіжі. + +SEC_PG="" +SEC_APP="" +SEC_WORKER="" +SEC_DEK="" +SEC_JWT="" +SEC_REUSED=0 + +# Пароль їде всередині postgres://user:ПАРОЛЬ@db:5432/netpulse. «/» там +# починає ім'я бази, «@» — адресу хоста, «:» — порт: пароль обривається +# на першому такому символі МОВЧКИ, і застосунок доповідає +# «password authentication failed», тобто вказує зовсім не туди. +assert_dsn_safe() { + case "$2" in + *[/+=@:?\#\&]*) + die "У значенні $1 є символ, який щось означає всередині DSN" \ + "(«/», «+», «=», «@», «:», «?», «#», «&»)." \ + "Такий пароль обірветься при розборі postgres://user:пароль@db" \ + "і дасть «password authentication failed» — помилку не про те." \ + "Виправлення: приберіть рядок $1 з .env і повторіть install," \ + "установник згенерує hex, у якому таких символів немає." ;; + esac +} + +secrets() { + step "секрети" + + if [ -f "$ENV_FILE" ]; then + SEC_PG=$(env_get POSTGRES_PASSWORD) + SEC_APP=$(env_get NETPULSE_APP_PASSWORD) + SEC_WORKER=$(env_get NETPULSE_WORKER_PASSWORD) + SEC_DEK=$(env_get NETPULSE_DEK) + SEC_JWT=$(env_get NETPULSE_JWT_SECRET) + if [ -n "$SEC_DEK" ]; then + SEC_REUSED=1 + ok "знайдено .env — наявні секрети зберігаються без змін" + say " (перевипуск NETPULSE_DEK знищив би всі збережені паролі)" + fi + fi + + if ! have openssl && [ ! -r /dev/urandom ]; then + die "Немає ані openssl, ані /dev/urandom — випадкові значення взяти" \ + "нізвідки, а вигадувати секрети скрипт не має права." + fi + + [ -n "$SEC_PG" ] || SEC_PG=$(rand_hex 32) + [ -n "$SEC_APP" ] || SEC_APP=$(rand_hex 32) + [ -n "$SEC_WORKER" ] || SEC_WORKER=$(rand_hex 32) + [ -n "$SEC_JWT" ] || SEC_JWT=$(rand_hex 32) + [ -n "$SEC_DEK" ] || SEC_DEK="np1=$(rand_hex 32)" + + # Перевірка, а не припущення: одного разу пароль із «/» уже обрізав + # DSN, і поламалось воно не тут, а через три кроки, з повідомленням + # про невірний пароль. + assert_dsn_safe POSTGRES_PASSWORD "$SEC_PG" + assert_dsn_safe NETPULSE_APP_PASSWORD "$SEC_APP" + assert_dsn_safe NETPULSE_WORKER_PASSWORD "$SEC_WORKER" + + [ "$SEC_REUSED" -eq 1 ] || ok "згенеровано: пароль БД, паролі ролей netpulse_app і + netpulse_worker, ключ шифрування секретів, ключ підпису сесій" + ok "формат — hex: жоден символ не має значення всередині DSN" +} + +# --------------------------------------------------------------------- +# КРОК 5. .env +# --------------------------------------------------------------------- + +write_env() { + step ".env" + + _enroll="" + [ -f "$ENV_FILE" ] && _enroll=$(env_get NETPULSE_ENROLL) + + if [ "$DRY" -eq 1 ]; then + ok "у сухому прогоні файл не пишеться; вміст (секрети приховані):" + env_body "СХОВАНО" "СХОВАНО" "СХОВАНО" "np1=СХОВАНО" "СХОВАНО" "$_enroll" | + sed 's/^/ /' + return 0 + fi + + # umask до створення, а не chmod після: між створенням і chmod файл + # із паролями видно всім, і це не теоретично — саме в цю щілину + # потрапляють резервні копії каталогу. + _old=$(umask) + umask 077 + env_body "$SEC_PG" "$SEC_APP" "$SEC_WORKER" "$SEC_DEK" "$SEC_JWT" "$_enroll" \ + > "$ENV_FILE.tmp" || die "Не вдалося записати $ENV_FILE.tmp" + umask "$_old" + chmod 600 "$ENV_FILE.tmp" + mv "$ENV_FILE.tmp" "$ENV_FILE" || die "Не вдалося замінити $ENV_FILE" + ok "$ENV_FILE записано, права 0600" +} + +env_body() { + cat </dev/null 2>&1; then + ok "Postgres відповідає" + return 0 + fi + _i=$((_i + 1)) + sleep 2 + done + die "Postgres не піднявся за дві хвилини." \ + "Подивитись причину: ./netpulse logs db" \ + "Найчастіше це shared_buffers більший за пам'ять машини — тоді в журналі" \ + "буде рядок про неможливість виділити спільну пам'ять." +} + +# --------------------------------------------------------------------- +# КРОК 7. Схема й ролі +# --------------------------------------------------------------------- +# +# Порядок тут не довільний: міграція 0063 СТВОРЮЄ ролі netpulse_app і +# netpulse_worker без паролів, і лише після неї їм є що видавати. А +# піднімати API до видачі паролів не можна взагалі: у DSN уже стоїть +# netpulse_app, і застосунок отримає відмову автентифікації. + +migrate_schema() { + step "схема й ролі" + + if [ "$DRY" -eq 1 ]; then + ok "виконалося б: docker compose run --rm migrate" + ok "далі: ALTER ROLE netpulse_app / netpulse_worker з новими паролями" + ok "далі: звірка rolbypassrls — у netpulse_app має бути f" + return 0 + fi + + dc run --rm migrate || die \ + "Міграції не накотились. Схема лишилась у тому стані, у якому була:" \ + "кожна міграція йде окремою транзакцією, недокочена не залишається." \ + "Текст помилки вище називає файл, на якому зупинилось." + ok "схема накочена" + + dc exec -T db psql -v ON_ERROR_STOP=1 -U netpulse -d netpulse >/dev/null </dev/null | tr -d ' \r') + if [ "$_bypass" != "f" ]; then + die "У ролі netpulse_app стоїть BYPASSRLS (rolbypassrls=$_bypass)." \ + "Це означає, що політики ізоляції кабінетів не діятимуть узагалі —" \ + "усе наступне пройде й нічого не змінить." + fi + ok "netpulse_app без BYPASSRLS — політики RLS діють" +} + +# --------------------------------------------------------------------- +# КРОК 8. Служби +# --------------------------------------------------------------------- + +bring_services() { + step "служби" + + if [ "$DRY" -eq 1 ]; then + ok "виконалося б: docker compose up -d --build api collector proxy" + return 0 + fi + + dc up -d --build api collector proxy || die \ + "Не вдалося підняти api, collector або proxy." \ + "Стан служб: ./netpulse logs" + + ok "чекаємо, поки API відповість на /healthz (до 120 с)" + _i=0 + while [ "$_i" -lt 60 ]; do + if dc exec -T api wget -q -O- "$API_INTERNAL/healthz" >/dev/null 2>&1; then + ok "API відповідає" + return 0 + fi + _i=$((_i + 1)) + sleep 2 + done + die "API не відповів за дві хвилини." \ + "Подивитись причину: ./netpulse logs api" \ + "Якщо в журналі «password authentication failed» — паролі ролей і DSN" \ + "розійшлись; повторіть install, він перевидасть паролі з .env." +} + +# --------------------------------------------------------------------- +# КРОК 9. Власник +# --------------------------------------------------------------------- + +create_owner() { + step "власник" + + if [ "$DRY" -eq 1 ]; then + ok "виконалося б: docker compose run --rm --entrypoint netpulse-user cli \\" + say " -tenant default -create-tenant \"NetPulse\" -login admin -role owner" + ok "пароль — 24 символи з /dev/urandom, показується один раз" + return 0 + fi + + # Роллю ВЛАСНИКА, а не netpulse_app: заведення кабінету — рівно те, + # чого роль під RLS не може за побудовою (0063, розділ про + # core.tenants). Без явного DSN утиліта мовчки нічого не знайшла б. + _dsn="postgres://netpulse:$SEC_PG@db:5432/netpulse?sslmode=disable" + + # --entrypoint обов'язковий: у службі api вже прописаний + # entrypoint netpulse-api, і без заміни утиліта потрапила б до нього + # аргументом, а не запустилась. (deploy/README.md у цьому місці + # помиляється — там команда без --entrypoint.) + if dc run --rm -e "NETPULSE_DSN=$_dsn" --entrypoint netpulse-user cli \ + -tenant default -list 2>/dev/null | grep -q '^ admin '; then + OUT_OWNER_EXISTED=1 + ok "власник admin уже є — пароль не змінюється" + return 0 + fi + + OUT_OWNER_PW=$(rand_pass) || die "Не вдалося згенерувати пароль власника." + + # Пароль іде через stdin, а не прапорцем: аргументи командного рядка + # видно в ps будь-кому на машині, і осідають вони ще й в історії + # оболонки. + printf '%s\n' "$OUT_OWNER_PW" | dc run --rm -T -e "NETPULSE_DSN=$_dsn" \ + --entrypoint netpulse-user cli \ + -tenant default -create-tenant "NetPulse" \ + -login admin -role owner -name "Адміністратор" \ + || die "Не вдалося завести власника." \ + "Якщо в тексті «тенанта не знайдено» — база порожня, і це означає," \ + "що міграції відпрацювали не до кінця." + ok "заведено кабінет NetPulse і власника admin" +} + +# --------------------------------------------------------------------- +# КРОК 10. Строки зберігання +# --------------------------------------------------------------------- +# +# Профіль накладається лише на першій установці. Причина та сама, що й у +# самої міграції 0064: наш «правильний» строк на чужій інсталяції — це +# чиясь втрачена історія. Якщо строки вже правили, повторний install їх +# не чіпає. + +retention_rows() { + case "$1" in + economy) + cat <<'EOF' +metrics_raw 7 +metrics_5m 30 +metrics_1h 180 +icmp_raw 7 +icmp_5m 30 +icmp_1h 180 +ifc_raw 7 +ifc_5m 30 +ifc_1h 180 +link_status 30 +device_status 30 +syslog 7 +traps 7 +agent_health 7 +alerts_history 90 +notifications 30 +login_attempts 90 +audit_log 180 +command_runs 30 +ncm_jobs 30 +discovery_runs 30 +EOF + ;; + normal) + cat <<'EOF' +metrics_raw 35 +metrics_5m 180 +metrics_1h 730 +icmp_raw 35 +icmp_5m 180 +icmp_1h 730 +ifc_raw 35 +ifc_5m 180 +ifc_1h 730 +link_status 180 +device_status 180 +syslog 30 +traps 30 +agent_health 14 +alerts_history 365 +notifications 90 +login_attempts 180 +audit_log 365 +command_runs 90 +ncm_jobs 90 +discovery_runs 90 +EOF + ;; + archive) + cat <<'EOF' +metrics_raw 90 +metrics_5m 365 +metrics_1h 1825 +icmp_raw 90 +icmp_5m 365 +icmp_1h 1825 +ifc_raw 90 +ifc_5m 365 +ifc_1h 1825 +link_status 730 +device_status 730 +syslog 180 +traps 180 +agent_health 30 +alerts_history 1825 +notifications 365 +login_attempts 365 +audit_log 1825 +command_runs 365 +ncm_jobs 365 +discovery_runs 365 +EOF + ;; + esac +} + +apply_retention() { + step "строки зберігання" + + if [ "$DRY" -eq 1 ]; then + ok "профіль $CFG_RETENTION розклався б у core.retention_settings так:" + retention_rows "$CFG_RETENTION" | awk '{ printf " %-16s %s діб\n", $1, $2 }' + ok "потім SELECT core.apply_retention_policies() — накласти політики TimescaleDB" + return 0 + fi + + if [ "$OUT_OWNER_EXISTED" -eq 1 ]; then + ok "система вже стояла — строки зберігання не чіпаємо" + say " (змінити: сторінка «Сховище» в інтерфейсі)" + return 0 + fi + + { + printf 'BEGIN;\n' + retention_rows "$CFG_RETENTION" | while read -r _kind _days; do + [ -n "$_kind" ] || continue + printf "UPDATE core.retention_settings SET keep_days = %s WHERE kind = '%s';\n" \ + "$_days" "$_kind" + done + printf 'COMMIT;\n' + printf 'SELECT core.apply_retention_policies();\n' + } | dc exec -T db psql -v ON_ERROR_STOP=1 -U netpulse -d netpulse >/dev/null \ + || die "Не вдалося накласти строки зберігання." \ + "Дані від цього не постраждали: невдала транзакція нічого не змінює." \ + "Строки можна виставити в інтерфейсі: «Сховище»." + + ok "профіль $CFG_RETENTION накладено на 21 вид даних" +} + +# --------------------------------------------------------------------- +# HTTP до API зсередини контейнера +# --------------------------------------------------------------------- +# +# Ходимо wget-ом усередині контейнера api, а не curl-ом з хоста, і це +# свідомо. На чужому сервері curl може бути відсутній, а wget у образі є +# гарантовано — на ньому тримається healthcheck самого compose. Заразом +# зникає питання самопідписаного сертифіката: усередині це звичайний +# http до 127.0.0.1. + +API_TOKEN="" + +api_wget_ok() { + dc exec -T api sh -s <<'INNER' >/dev/null 2>&1 +wget --help 2>&1 | grep -q -- --post-data +INNER +} + +api_get() { + if [ -n "$API_TOKEN" ]; then + dc exec -T api sh -s <&1 +wget -q -O- --header='Authorization: Bearer $API_TOKEN' '$API_INTERNAL$1' +INNER + else + dc exec -T api sh -s <&1 +wget -q -O- '$API_INTERNAL$1' +INNER + fi +} + +api_post() { + if [ -n "$API_TOKEN" ]; then + dc exec -T api sh -s <&1 +wget -q -O- --header='Content-Type: application/json' \ + --header='Authorization: Bearer $API_TOKEN' \ + --post-data='$2' '$API_INTERNAL$1' +INNER + else + dc exec -T api sh -s <&1 +wget -q -O- --header='Content-Type: application/json' \ + --post-data='$2' '$API_INTERNAL$1' +INNER + fi +} + +# --------------------------------------------------------------------- +# КРОК 11. Локальний зонд +# --------------------------------------------------------------------- +# +# Зонд на самому сервері ставиться не для краси. Він проходить увесь +# ланцюг, якого не бачить жодна перевірка HTTP: обмін запрошення на +# постійний токен, gRPC до колектора, реєстрація в core.agents. Якщо +# після установки в системі нуль зондів, вона показує порожні екрани, і +# людина не може відрізнити «ще нічого не налаштовано» від «колектор не +# працює». + +enroll_local_agent() { + step "локальний зонд" + + if [ "$DRY" -eq 1 ]; then + ok "виконалося б: вхід під власником, POST /api/v1/agent-enrollments," + say " docker compose --profile agent up -d agent" + return 0 + fi + + _have=$(env_get NETPULSE_ENROLL) + if [ -n "$_have" ] && dc ps --services --filter status=running 2>/dev/null | grep -q '^agent$'; then + ok "локальний зонд уже працює" + return 0 + fi + + if ! login_as_owner; then + warn "пароля власника немає (система вже стояла) — нове запрошення не + видається. Додати зонд можна в інтерфейсі: Зонди → Додати зонд" + return 0 + fi + + _r=$(api_post /api/v1/agent-enrollments \ + '{"name_hint":"локальний зонд","modules":["icmp","snmp","topology","ncm","traps"]}') + _tok=$(printf '%s' "$_r" | json_str token) + [ -n "$_tok" ] || die "API не видав запрошення для зонда." \ + "Відповідь: $_r" + + # Запрошення лягає в .env, бо зонд обмінює його на постійний токен при + # ПЕРШОМУ старті, а compose читає змінну на кожному up. + sed -i.bak "s|^NETPULSE_ENROLL=.*|NETPULSE_ENROLL=$_tok|" "$ENV_FILE" \ + || die "Не вдалося записати запрошення в .env" + rm -f "$ENV_FILE.bak" + + dc --profile agent up -d --build agent \ + || die "Не вдалося підняти локальний зонд." + ok "локальний зонд запущено" + + apply_traps_firewall +} + +login_as_owner() { + [ -n "$API_TOKEN" ] && return 0 + + _pw=$OWNER_PASSWORD + [ -n "$_pw" ] || _pw=$OUT_OWNER_PW + if [ -z "$_pw" ]; then + return 1 + fi + + _r=$(api_post /api/v1/auth/login "{\"login\":\"admin\",\"password\":\"$_pw\"}") + API_TOKEN=$(printf '%s' "$_r" | json_str access_token) + if [ -z "$API_TOKEN" ]; then + die "Вхід під власником не вдався — тобто зламане саме те, заради чого" \ + "ця перевірка існує." \ + "" \ + "Відповідь API: $_r" \ + "" \ + "Якщо там 403 і no_membership — база віддала нуль кабінетів під роллю" \ + "netpulse_app. Найчастіша причина: порожній NETPULSE_DSN_WORKER, тобто" \ + "не заданий NETPULSE_WORKER_PASSWORD у .env. Шлях входу читає" \ + "core.memberships JOIN core.tenants пулом воркера, і без нього" \ + "падає назад у пул застосунку, який кабінетів не бачить." + fi + return 0 +} + +# --------------------------------------------------------------------- +# КРОК 12. Самоперевірка +# --------------------------------------------------------------------- +# +# Взірець — перевірка, написана після живої поломки: перший її варіант +# питав «чи не видно чужого» і був зелений, бо RLS справді ховає чуже. +# Зламалось протилежне — не видно СВОГО. Тест на ізоляцію дивиться повз +# це місце за побудовою. +# +# Тому тут перевіряється не ізоляція, а ПРОХІДНІСТЬ: шлях, яким людина +# заходить у систему, і переліки, які після цього мають бути непорожні. +# Порожній перелік тут — привід зупинитись, а не «даних ще немає»: усе +# перелічене наливають міграції, тобто воно є на будь-якій інсталяції +# через хвилину після установки. + +SELFCHECK_FAILED="" + +sc_fail() { SELFCHECK_FAILED="$SELFCHECK_FAILED|$1"; bad "$1"; } + +# Переліки, які МАЮТЬ бути непорожні одразу після установки. Кожен — +# із міграцій, і кожен читається через окремий шлях у store. +sc_must_lists() { + cat <<'EOF' +/api/v1/templates шаблони +/api/v1/ncm/profiles профілі_NCM +/api/v1/check-types типи_перевірок +/api/v1/roles ролі +/api/v1/permissions права +/api/v1/team користувачі +/api/v1/storage/retention строки_зберігання +EOF +} + +# Тут порожньо законно: свіжа система ще не має ані хостів, ані мап. +# Перевіряємо лише те, що ендпоїнт ВІДПОВІДАЄ, — 4xx або 5xx означав +# би, що під роллю без BYPASSRLS зламався запит, а не що даних немає. +sc_may_lists() { + cat <<'EOF' +/api/v1/me я +/api/v1/devices хости +/api/v1/device-groups групи +/api/v1/credentials доступи +/api/v1/maps мапи +/api/v1/dashboards панелі +/api/v1/alerts алерти +/api/v1/alert-rules тригери +/api/v1/audit?limit=20 журнал_аудиту +/api/v1/queues черги +/api/v1/agent-enrollments запрошення_зондів +/api/v1/storage сховище +EOF +} + +selfcheck() { + step "самоперевірка" + + if [ "$DRY" -eq 1 ]; then + ok "справжній вхід POST /api/v1/auth/login справжнім паролем" + ok "звірка, що кабінет НАЗВАВСЯ (tenant_name), а не лише має id" + ok "звірка, що вхід повернув перелік прав" + sc_must_lists | awk '{ gsub(/_/, " ", $2); printf " непорожньо: %s\n", $2 }' + sc_may_lists | awk '{ gsub(/_/, " ", $2); printf " відповідає: %s\n", $2 }' + ok "зонд зареєструвався: /api/v1/agents не порожній" + ok "проксі відповідає HTTPS на публічній адресі" + return 0 + fi + + SELFCHECK_FAILED="" + + api_wget_ok || die \ + "wget у образі api не вміє --post-data, тому справжній вхід звідси не зробити." \ + "Установник не має права оголосити систему готовою без цієї перевірки." \ + "Зробіть її з машини, де є curl:" \ + " curl -sk -X POST https://$VAL_DOMAIN/api/v1/auth/login \\" \ + " -H 'Content-Type: application/json' \\" \ + " -d '{\"login\":\"admin\",\"password\":\"<пароль>\"}'" + + # --- 1. Вхід: рівно той шлях, що ламався -------------------------- + _pw=$OWNER_PASSWORD + [ -n "$_pw" ] || _pw=$OUT_OWNER_PW + if [ -z "$_pw" ]; then + warn "пароля власника немає — повний вхід не перевіряється" + say " (система вже стояла; щоб перевірити вхід: ./netpulse check -p <пароль>)" + else + API_TOKEN="" + _r=$(api_post /api/v1/auth/login "{\"login\":\"admin\",\"password\":\"$_pw\"}") + API_TOKEN=$(printf '%s' "$_r" | json_str access_token) + if [ -z "$API_TOKEN" ]; then + die "ВХІД НЕ ПРОЙШОВ. Контейнери працюють, сторінка відкривається," \ + "а зайти в систему не можна — саме цей стан колись назвали «готово»." \ + "" \ + "Відповідь API: $_r" \ + "" \ + "403 no_membership означає, що база віддала нуль кабінетів під роллю" \ + "netpulse_app. Перевірити NETPULSE_WORKER_PASSWORD у .env: шлях входу" \ + "читає кабінети пулом воркера, і без пароля цей пул стає пулом" \ + "застосунку, який кабінетів не бачить за політикою RLS." + fi + ok "вхід: пройшов" + + # Не наявність ключа tenant, а НАЗВА. id приїжджає з членства й + # лишився б на місці навіть тоді, коли рядок кабінету не віддався, + # — це тиха половина тієї самої поломки. + _tname=$(printf '%s' "$_r" | json_str tenant_name) + if [ -n "$_tname" ]; then + ok "кабінет назвався: $_tname" + else + sc_fail "вхід не повернув назви кабінету — core.tenants не віддалась" + fi + + if printf '%s' "$_r" | tr -d ' \n' | grep -q '"permissions":\["'; then + ok "вхід повернув права" + else + sc_fail "вхід не повернув прав" + fi + fi + + if [ -z "$API_TOKEN" ]; then + warn "далі йдуть лише перевірки, що не потребують входу" + else + # --- 2. Переліки, які МАЮТЬ бути непорожні ---------------------- + # + # Через тимчасовий файл, а не конвеєром: тіло циклу, запущене в + # конвеєрі, працює в підоболонці, і sc_fail дописував би провали в + # її власну змінну. Підсумок після цього виявився б зеленим при + # червоних рядках вище — рівно та брехня, проти якої вся перевірка. + sc_must_lists > /tmp/np_must.$$ + while read -r _path _label; do + [ -n "$_path" ] || continue + _b=$(api_get "$_path") + if json_nonempty "$_b"; then + ok "$(printf '%s' "$_label" | tr '_' ' '): непорожньо" + else + sc_fail "$(printf '%s' "$_label" | tr '_' ' '): ПОРОЖНЬО (наливається міграціями, тобто має бути)" + fi + done < /tmp/np_must.$$ + rm -f /tmp/np_must.$$ + + sc_may_lists > /tmp/np_may.$$ + while read -r _path _label; do + [ -n "$_path" ] || continue + if api_get "$_path" >/dev/null 2>&1; then + ok "$(printf '%s' "$_label" | tr '_' ' '): відповідає" + else + sc_fail "$(printf '%s' "$_label" | tr '_' ' '): не відповів 200" + fi + done < /tmp/np_may.$$ + rm -f /tmp/np_may.$$ + + # --- 3. Зонд дійшов до колектора --------------------------------- + # Реєстрація йде gRPC-каналом, якого HTTP-перевірки не бачать + # зовсім. Чекаємо, бо обмін запрошення на токен займає секунди. + _i=0 + _agents="" + while [ "$_i" -lt 20 ]; do + _agents=$(api_get /api/v1/agents) + json_nonempty "$_agents" && break + _i=$((_i + 1)) + sleep 3 + done + if json_nonempty "$_agents"; then + ok "зонд зареєструвався в колекторі" + else + sc_fail "жоден зонд не зареєструвався за хвилину — колектор або запрошення" + fi + fi + + # --- 4. Проксі -------------------------------------------------- + check_proxy + + if [ -n "$SELFCHECK_FAILED" ]; then + printf '\n' + die "Самоперевірка не пройшла. Перелічене вище — не попередження:" \ + "система в такому стані виглядає працюючою й не працює." \ + "$(printf '%s' "$SELFCHECK_FAILED" | tr '|' ' ')" + fi + ok "усі вхідні шляхи проходять" +} + +# Проксі перевіряємо окремо й м'якше: TLS на самопідписаному +# сертифікаті поводиться по-різному в різних збірках wget, і зупиняти +# установку через версію busybox було б неправдою про стан системи. +check_proxy() { + if ! dc ps --services --filter status=running 2>/dev/null | grep -q '^proxy$'; then + sc_fail "контейнер proxy не працює — назовні система недоступна" + return + fi + if dc exec -T api sh -s </dev/null 2>&1 +wget -q --no-check-certificate -O- --header='Host: $VAL_DOMAIN' 'https://proxy/healthz' +INNER + then + ok "проксі віддає HTTPS для $VAL_DOMAIN" + else + warn "проксі працює, але перевірити HTTPS зсередини не вдалося. + Перевірте з робочої машини: https://$VAL_DOMAIN/healthz" + fi +} + +# --------------------------------------------------------------------- +# Підсумок +# --------------------------------------------------------------------- + +summary() { + printf '\n%s== готово%s\n\n' "$C_B" "$C_0" + printf ' Адреса: https://%s\n' "$OUT_URL" + if [ -z "$CFG_DOMAIN" ]; then + printf ' сертифікат самопідписаний — браузер попередить, це очікувано\n' + fi + printf ' Логін: admin\n' + if [ -n "$OUT_OWNER_PW" ]; then + printf ' Пароль: %s%s%s\n' "$C_B" "$OUT_OWNER_PW" "$C_0" + else + printf ' Пароль: без змін (власник уже існував)\n' + fi + if [ -n "$OUT_ENROLL" ]; then + printf '\n Запрошення для першого віддаленого зонда (дійсне добу):\n' + printf ' %s\n' "$OUT_ENROLL" + printf ' Наступні — в інтерфейсі: Зонди → Додати зонд.\n' + fi + printf '\n %sЦе показано востаннє.%s Пароль і запрошення ніде не зберігаються\n' "$C_R" "$C_0" + printf ' у відкритому вигляді: у базі лежать лише їхні хеші.\n' + printf '\n Ключі шифрування — у .env (права 0600). Втрата NETPULSE_DEK\n' + printf ' означає втрату всіх збережених паролів SSH і SNMP: ./netpulse backup\n' + printf ' кладе їх поруч із дампом, і зберігати їх треба ОКРЕМО від нього.\n\n' +} + +# --------------------------------------------------------------------- +# install +# --------------------------------------------------------------------- + +cmd_install() { + printf '%sNetPulse · установка%s\n' "$C_B" "$C_0" + [ "$DRY" -eq 1 ] && printf 'Сухий прогін: нічого не запускається й не пишеться.\n' + + preflight + read_conf + compute + secrets + write_env + bring_db + migrate_schema + bring_services + create_owner + apply_retention + enroll_local_agent + selfcheck + + OUT_URL=$VAL_DOMAIN + + if [ "$DRY" -eq 1 ]; then + printf '\n%s== сухий прогін завершено%s\n' "$C_B" "$C_0" + printf ' Жодного контейнера не запущено, .env не змінено.\n' + printf ' Прибрати --dry-run, щоб поставити насправді.\n\n' + return 0 + fi + + # Друге запрошення — для першого зонда клієнта. Перше вже витрачене + # локальним зондом, а показувати витрачений токен означає навчити + # людину не вірити тому, що написано на екрані. + if [ -n "$API_TOKEN" ]; then + _r=$(api_post /api/v1/agent-enrollments \ + '{"name_hint":"перший зонд","modules":["icmp","snmp","topology","ncm"]}') + OUT_ENROLL=$(printf '%s' "$_r" | json_str token) + fi + + summary +} + +# --------------------------------------------------------------------- +# check +# --------------------------------------------------------------------- +# +# Ті самі твердження, але на живій системі й у вигляді, придатному для +# «надішліть мені вивід цієї команди»: кожен рядок каже, ЩО саме не так, +# а не «помилка». +# +# Пароля власника тут зазвичай немає, тому перевірки поділені на дві +# групи. Ті, що не потребують пароля, б'ють у те саме місце з боку бази: +# запит, яким шлях входу дістає кабінети, виконується роллю воркера +# напряму. Якщо він порожній — вхід поверне 403, і це видно без входу. + +cmd_check() { + printf '%sNetPulse · перевірка%s\n' "$C_B" "$C_0" + SELFCHECK_FAILED="" + + step "оточення" + pick_compose + [ -n "$DC_KIND" ] || die "docker compose не знайдено." + [ -f "$ENV_FILE" ] || die "Немає $ENV_FILE — систему тут не встановлювали." + ok "docker compose на місці, .env знайдено" + + # ls, а не stat: формат stat різний у GNU, BusyBox і BSD, а нам треба + # рівно десять символів прав — і вони однакові скрізь. Ім'я файлу тут + # наше власне й незмінне, тобто застереження про дивні імена не про цей + # випадок. + # shellcheck disable=SC2012 + _perm=$(ls -l "$ENV_FILE" 2>/dev/null | cut -c1-10) + case "$_perm" in + -rw-------) ok ".env має права 0600" ;; + *) sc_fail ".env має права $_perm — паролі бази й ключі шифрування читає будь-хто" ;; + esac + + VAL_DOMAIN=$(env_get NETPULSE_DOMAIN) + OUT_URL=$VAL_DOMAIN + CFG_DOMAIN=$VAL_DOMAIN + + step "служби" + _running=$(dc ps --services --filter status=running 2>/dev/null) + for _s in db cache api collector proxy; do + if printf '%s\n' "$_running" | grep -q "^$_s\$"; then + ok "$_s працює" + else + sc_fail "$_s НЕ працює" + fi + done + if printf '%s\n' "$_running" | grep -q '^agent$'; then + ok "локальний зонд працює" + else + warn "локального зонда немає (не помилка, якщо зонди стоять окремо)" + fi + + step "місце на диску" + _free=$(free_mb "$ROOT") + [ -n "$_free" ] || _free=0 + if [ "$_free" -lt 5120 ]; then + sc_fail "вільно лише $_free МБ. Від переповненого тому першим падає Postgres, + тобто весь продукт одночасно. Сторінка «Сховище» показує приріст" + elif [ "$_free" -lt 20480 ]; then + warn "вільно $_free МБ — час подивитись на строки зберігання" + else + ok "вільно $_free МБ" + fi + + step "ролі бази" + _app_pw=$(env_get NETPULSE_APP_PASSWORD) + _worker=$(env_get NETPULSE_WORKER_PASSWORD) + if [ -n "$_app_pw" ]; then + ok "застосунок ходить роллю netpulse_app (RLS діє)" + if [ -z "$_worker" ]; then + sc_fail "NETPULSE_WORKER_PASSWORD порожній при заданому NETPULSE_APP_PASSWORD. + Це рівно та комбінація, у якій вхід повертає 403: шлях входу читає + кабінети пулом воркера, а без пароля цей пул стає пулом застосунку, + який кабінетів не бачить за політикою RLS" + else + ok "NETPULSE_DSN_WORKER заповнений" + fi + _b=$(dc exec -T db psql -tAX -U netpulse -d netpulse \ + -c "SELECT rolbypassrls FROM pg_roles WHERE rolname='netpulse_app'" 2>/dev/null | tr -d ' \r') + case "$_b" in + f) ok "netpulse_app без BYPASSRLS" ;; + t) sc_fail "netpulse_app має BYPASSRLS — політики ізоляції не діють узагалі" ;; + *) sc_fail "не вдалося спитати pg_roles: база не відповідає" ;; + esac + else + warn "застосунок ходить роллю netpulse (суперкористувач) — RLS не діє. + Ізоляцію кабінетів тримає лише предикат tenant_id у коді" + fi + + step "шлях входу з боку бази" + # Той самий запит, що й у store.userMemberships. Порожній результат + # тут — це 403 на формі входу, і побачити його можна без пароля. + _n=$(dc exec -T db psql -tAX -U netpulse -d netpulse -c " + SELECT count(*) FROM core.memberships m + JOIN core.tenants t ON t.id = m.tenant_id + JOIN core.roles r ON r.id = m.role_id + WHERE m.accepted_at IS NOT NULL" 2>/dev/null | tr -d ' \r') + if [ -z "$_n" ]; then + sc_fail "не вдалося виконати запит до бази" + elif [ "$_n" = "0" ]; then + sc_fail "нуль членств у кабінетах: зайти в систему не зможе ніхто. + Завести власника: docker compose run --rm --entrypoint netpulse-user cli + -tenant default -login admin -role owner" + else + ok "членств у кабінетах: $_n" + fi + + step "API" + if dc exec -T api wget -q -O- "$API_INTERNAL/healthz" >/dev/null 2>&1; then + ok "/healthz відповідає" + else + sc_fail "/healthz не відповідає — API живий як контейнер, але не як застосунок" + fi + + # Повний вхід — лише якщо пароль дали. Без нього перевіряємо, що + # форма входу взагалі доходить до звірки пароля: 401 тут означає, що + # core.users читається, а 500 — що ні. + if [ -n "$OWNER_PASSWORD" ]; then + step "справжній вхід" + selfcheck_live + else + step "форма входу" + _r=$(api_post /api/v1/auth/login '{"login":"admin","password":"__свідомо_невірний__"}') + case "$_r" in + *bad_credentials*) ok "форма входу доходить до звірки пароля" ;; + *too_many_attempts*) warn "вхід тимчасово заблокований після невдалих спроб" ;; + *) sc_fail "форма входу відповіла не тим: $_r" ;; + esac + say " Повний вхід перевіряється лише з паролем: ./netpulse check -p <пароль>" + fi + + step "проксі" + check_proxy + + printf '\n%s== підсумок%s\n' "$C_B" "$C_0" + if [ -n "$SELFCHECK_FAILED" ]; then + printf ' %sНЕ ГАРАЗД:%s\n' "$C_R" "$C_0" + printf '%s' "$SELFCHECK_FAILED" | tr '|' '\n' | sed '/^$/d' | sed 's/^/ - /' + printf '\n Цей вивід можна надіслати цілком: у ньому немає паролів.\n\n' + exit 1 + fi + printf ' %sУсе гаразд.%s Адреса системи: https://%s\n\n' "$C_G" "$C_0" "$OUT_URL" +} + +# Повний вхід у режимі check: те саме, що робить установка, але без +# права зупиняти світ — тут це діагностика, а не установка. +selfcheck_live() { + API_TOKEN="" + _r=$(api_post /api/v1/auth/login "{\"login\":\"admin\",\"password\":\"$OWNER_PASSWORD\"}") + API_TOKEN=$(printf '%s' "$_r" | json_str access_token) + if [ -z "$API_TOKEN" ]; then + sc_fail "вхід не пройшов: $_r" + return + fi + ok "вхід: пройшов" + _tname=$(printf '%s' "$_r" | json_str tenant_name) + if [ -n "$_tname" ]; then + ok "кабінет назвався: $_tname" + else + sc_fail "вхід не повернув назви кабінету — core.tenants не віддалась" + fi + + sc_must_lists > /tmp/np_must.$$ + while read -r _path _label; do + [ -n "$_path" ] || continue + _b=$(api_get "$_path") + if json_nonempty "$_b"; then + ok "$(printf '%s' "$_label" | tr '_' ' '): непорожньо" + else + sc_fail "$(printf '%s' "$_label" | tr '_' ' '): ПОРОЖНЬО" + fi + done < /tmp/np_must.$$ + rm -f /tmp/np_must.$$ + + sc_may_lists > /tmp/np_may.$$ + while read -r _path _label; do + [ -n "$_path" ] || continue + if api_get "$_path" >/dev/null 2>&1; then + ok "$(printf '%s' "$_label" | tr '_' ' '): відповідає" + else + sc_fail "$(printf '%s' "$_label" | tr '_' ' '): не відповів 200" + fi + done < /tmp/np_may.$$ + rm -f /tmp/np_may.$$ +} + +# --------------------------------------------------------------------- +# backup / restore / upgrade / logs +# --------------------------------------------------------------------- + +cmd_backup() { + printf '%sNetPulse · бекап%s\n' "$C_B" "$C_0" + pick_compose + [ -f "$ENV_FILE" ] || die "Немає $ENV_FILE." + + _dir="$ROOT/backups" + mkdir -p "$_dir" || die "Не вдалося створити $_dir" + _stamp=$(date +%Y-%m-%d-%H%M) + _dump="$_dir/netpulse-$_stamp.dump" + _keys="$_dir/netpulse-$_stamp.keys" + + step "дамп бази" + # Формат custom, а не простий SQL: стискається і дозволяє відновлювати + # вибірково. --no-owner — бо ролі на цільовій машині можуть бути інші. + dc exec -T db pg_dump -U netpulse -d netpulse -Fc --no-owner > "$_dump" \ + || die "pg_dump не відпрацював; неповний файл лишився як $_dump" + _size=$(wc -c < "$_dump" | tr -d ' ') + [ "${_size:-0}" -gt 1024 ] || die \ + "Дамп вийшов розміром $_size байтів — це порожній або обірваний файл." \ + "Такий файл виглядає як бекап і не є ним." + ok "$_dump, $((_size / 1024)) КБ" + + step "ключі" + # Без цих двох рядків дамп не відновлюється в робочу систему: у базі + # лежить лише шифротекст секретів. + _old=$(umask); umask 077 + { + printf '# Ключі до дампа netpulse-%s.dump\n' "$_stamp" + printf '# ЗБЕРІГАТИ ОКРЕМО ВІД ДАМПА: разом вони — готовий доступ до всіх\n' + printf '# паролів SSH і SNMP-community з інвентарю.\n' + printf 'NETPULSE_DEK=%s\n' "$(env_get NETPULSE_DEK)" + printf 'NETPULSE_JWT_SECRET=%s\n' "$(env_get NETPULSE_JWT_SECRET)" + } > "$_keys" + umask "$_old" + chmod 600 "$_keys" + ok "$_keys (права 0600)" + + printf '\n Дамп без ключів не відновлюється, ключі поруч із дампом\n' + printf ' скасовують шифрування. Переносьте їх різними шляхами.\n\n' +} + +cmd_restore() { + printf '%sNetPulse · відновлення%s\n' "$C_B" "$C_0" + pick_compose + [ -n "$RESTORE_FILE" ] || die "Не вказано файл: ./netpulse restore -f <дамп>" + [ -f "$RESTORE_FILE" ] || die "Файл $RESTORE_FILE не знайдено." + + if [ "$ASSUME_YES" -eq 0 ]; then + printf '\n %sПоточна база буде знищена%s і замінена вмістом\n' "$C_R" "$C_0" + printf ' %s\n' "$RESTORE_FILE" + printf ' Повторіть із -y, якщо це саме те, що потрібно.\n\n' + exit 1 + fi + + step "зупинка застосунку" + dc stop api collector || die "Не вдалося зупинити api і collector." + ok "api і collector зупинені" + + step "наливання" + # Рамка timescaledb_pre_restore/post_restore обов'язкова: без неї + # фонові процеси агрегації втручаються в наливання, і дамп лягає + # пошкодженим — мовчки. + dc exec -T db psql -v ON_ERROR_STOP=1 -U netpulse -d postgres \ + -c 'DROP DATABASE IF EXISTS netpulse; CREATE DATABASE netpulse;' >/dev/null \ + || die "Не вдалося перестворити базу." + dc exec -T db psql -v ON_ERROR_STOP=1 -U netpulse -d netpulse \ + -c 'CREATE EXTENSION IF NOT EXISTS timescaledb; SELECT timescaledb_pre_restore();' >/dev/null \ + || die "Не вдалося перевести TimescaleDB у режим відновлення." + dc exec -T db pg_restore -U netpulse -d netpulse --no-owner < "$RESTORE_FILE" \ + || warn "pg_restore повернув помилки — перегляньте їх перед тим, як користуватись" + dc exec -T db psql -v ON_ERROR_STOP=1 -U netpulse -d netpulse \ + -c 'SELECT timescaledb_post_restore();' >/dev/null \ + || die "Не вдалося вивести TimescaleDB із режиму відновлення." + ok "дані налиті" + + step "запуск" + dc up -d api collector || die "Не вдалося підняти api і collector." + ok "api і collector підняті" + + printf '\n У .env має лежати ТОЙ САМИЙ NETPULSE_DEK, що й на момент дампа.\n' + printf ' Інакше система підніметься, а кожна спроба скористатись збереженим\n' + printf ' паролем поверне помилку розшифрування — і виглядатиме це як\n' + printf ' зламані креденшели, а не як втрачений ключ.\n\n' + printf ' Перевірити стан: ./netpulse check\n\n' +} + +cmd_upgrade() { + printf '%sNetPulse · оновлення%s\n' "$C_B" "$C_0" + pick_compose + [ -f "$ENV_FILE" ] || die "Немає $ENV_FILE." + + # Відкат схеми не передбачений: зворотні міграції на телеметрії + # коштують дорожче, ніж відновлення з дампа. Тому дамп тут не + # ввічливість, а єдиний шлях назад. + cmd_backup + + step "перезбирання" + dc build || die "Збірка образів не вдалася." + ok "образи зібрані" + + step "міграції" + dc run --rm migrate || die \ + "Міграції не накотились — API навмисно не піднімається зі старою схемою." \ + "Уже застосований файл зі зміненою контрольною сумою зупиняє запуск:" \ + "це захист від мовчазного розходження схеми з кодом." + ok "схема накочена" + + step "перезапуск" + dc up -d || die "Не вдалося перезапустити служби." + ok "служби перезапущені" + + printf '\n Перевірити: ./netpulse check\n\n' +} + +cmd_logs() { + pick_compose + if [ $# -gt 0 ]; then + dc logs -f --tail=200 "$@" + else + dc logs -f --tail=200 + fi +} + +# --------------------------------------------------------------------- +# Розбір командного рядка +# --------------------------------------------------------------------- + +usage() { + cat <<'USAGE' +NetPulse — установка й обслуговування. + + ./netpulse install [--dry-run] поставити; повторний запуск безпечний + ./netpulse check [-p ПАРОЛЬ] перевірити живу систему + ./netpulse backup дамп бази + ключі до нього + ./netpulse restore -f ДАМП -y відновити з дампа + ./netpulse upgrade перезібрати, накотити міграції + ./netpulse logs [служба] журнали + +Відповіді на п'ять питань, яких система не може вирішити сама, — +у netpulse.conf. Зразок із поясненнями: netpulse.conf.example. +Без цього файлу install ставить робочу систему на типових відповідях. +USAGE +} + +CMD=${1:-} +[ $# -gt 0 ] && shift + +while [ $# -gt 0 ]; do + case "$1" in + --dry-run|-n) DRY=1 ;; + -y|--yes) ASSUME_YES=1 ;; + -p) shift; OWNER_PASSWORD=${1:-} ;; + -f) shift; RESTORE_FILE=${1:-} ;; + -c) shift; CONF_FILE=${1:-} ;; + -h|--help) usage; exit 0 ;; + *) break ;; + esac + shift +done + +case "$CMD" in + install) cmd_install ;; + check) cmd_check ;; + backup) cmd_backup ;; + restore) cmd_restore ;; + upgrade) cmd_upgrade ;; + logs) cmd_logs "$@" ;; + ""|-h|--help|help) usage ;; + *) printf 'невідома команда: %s\n\n' "$CMD"; usage; exit 2 ;; +esac diff --git a/netpulse.conf.example b/netpulse.conf.example new file mode 100644 index 0000000..86dc296 --- /dev/null +++ b/netpulse.conf.example @@ -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 diff --git a/proto/netpulse/v1/ncm.proto b/proto/netpulse/v1/ncm.proto index 97ee91b..ba0bf79 100644 --- a/proto/netpulse/v1/ncm.proto +++ b/proto/netpulse/v1/ncm.proto @@ -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 { diff --git a/scripts/check-eol.sh b/scripts/check-eol.sh new file mode 100644 index 0000000..8988439 --- /dev/null +++ b/scripts/check-eol.sh @@ -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/… шлях»: розділювач +# перед шляхом — саме табуляція, тому шляхи з пробілами не розсипаються. +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" diff --git a/scripts/check.sh b/scripts/check.sh new file mode 100644 index 0000000..6bfbf66 --- /dev/null +++ b/scripts/check.sh @@ -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 "усе зелене" diff --git a/server/API.md b/server/API.md index 0ea932c..b147279 100644 --- a/server/API.md +++ b/server/API.md @@ -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`, а токен — у diff --git a/server/cmd/netpulse-api/main.go b/server/cmd/netpulse-api/main.go index 1186f2e..7dc3f16 100644 --- a/server/cmd/netpulse-api/main.go +++ b/server/cmd/netpulse-api/main.go @@ -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{ diff --git a/server/cmd/netpulse-gitsync/main.go b/server/cmd/netpulse-gitsync/main.go index 5a4d900..b49965a 100644 --- a/server/cmd/netpulse-gitsync/main.go +++ b/server/cmd/netpulse-gitsync/main.go @@ -14,6 +14,14 @@ // netpulse-gitsync -dsn ... -dek np1= -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=[,...]") 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} diff --git a/server/cmd/netpulse-migrate/main.go b/server/cmd/netpulse-migrate/main.go index 00ddd09..eeed961 100644 --- a/server/cmd/netpulse-migrate/main.go +++ b/server/cmd/netpulse-migrate/main.go @@ -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 не кодування, а його відсутність: сервер просто пропускає diff --git a/server/cmd/netpulse-migrate/roles_test.go b/server/cmd/netpulse-migrate/roles_test.go new file mode 100644 index 0000000..4488f43 --- /dev/null +++ b/server/cmd/netpulse-migrate/roles_test.go @@ -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 треба переписати") + } +} diff --git a/server/cmd/netpulse-server/main.go b/server/cmd/netpulse-server/main.go index 79cede8..73da4f0 100644 --- a/server/cmd/netpulse-server/main.go +++ b/server/cmd/netpulse-server/main.go @@ -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) }() diff --git a/server/internal/alerting/engine.go b/server/internal/alerting/engine.go index 525a138..b4b415c 100644 --- a/server/internal/alerting/engine.go +++ b/server/internal/alerting/engine.go @@ -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) + } } } } diff --git a/server/internal/alerting/escalation.go b/server/internal/alerting/escalation.go new file mode 100644 index 0000000..9b9c1ea --- /dev/null +++ b/server/internal/alerting/escalation.go @@ -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 +} diff --git a/server/internal/alerting/escalation_test.go b/server/internal/alerting/escalation_test.go new file mode 100644 index 0000000..f0d642c --- /dev/null +++ b/server/internal/alerting/escalation_test.go @@ -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) + } +} diff --git a/server/internal/alerting/events.go b/server/internal/alerting/events.go new file mode 100644 index 0000000..7e3109c --- /dev/null +++ b/server/internal/alerting/events.go @@ -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 +} diff --git a/server/internal/alerting/events_test.go b/server/internal/alerting/events_test.go new file mode 100644 index 0000000..fd539ef --- /dev/null +++ b/server/internal/alerting/events_test.go @@ -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))) + } +} diff --git a/server/internal/alerting/notify.go b/server/internal/alerting/notify.go index 83fee78..1ff2766 100644 --- a/server/internal/alerting/notify.go +++ b/server/internal/alerting/notify.go @@ -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) diff --git a/server/internal/alerting/telegram_bot.go b/server/internal/alerting/telegram_bot.go new file mode 100644 index 0000000..61e6bf7 --- /dev/null +++ b/server/internal/alerting/telegram_bot.go @@ -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 +} diff --git a/server/internal/alerting/telegram_callback.go b/server/internal/alerting/telegram_callback.go new file mode 100644 index 0000000..8ac8b01 --- /dev/null +++ b/server/internal/alerting/telegram_callback.go @@ -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 +} diff --git a/server/internal/alerting/telegram_callback_test.go b/server/internal/alerting/telegram_callback_test.go new file mode 100644 index 0000000..9e10470 --- /dev/null +++ b/server/internal/alerting/telegram_callback_test.go @@ -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) + } +} diff --git a/server/internal/alerting/traps_test.go b/server/internal/alerting/traps_test.go new file mode 100644 index 0000000..8c0c0d9 --- /dev/null +++ b/server/internal/alerting/traps_test.go @@ -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) + } +} diff --git a/server/internal/gitstore/branches.go b/server/internal/gitstore/branches.go new file mode 100644 index 0000000..344b193 --- /dev/null +++ b/server/internal/gitstore/branches.go @@ -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 +} diff --git a/server/internal/gitstore/branches_test.go b/server/internal/gitstore/branches_test.go new file mode 100644 index 0000000..ed4559a --- /dev/null +++ b/server/internal/gitstore/branches_test.go @@ -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) + } +} diff --git a/server/internal/gitstore/deploykey.go b/server/internal/gitstore/deploykey.go new file mode 100644 index 0000000..0486e25 --- /dev/null +++ b/server/internal/gitstore/deploykey.go @@ -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 +} diff --git a/server/internal/gitstore/deploykey_test.go b/server/internal/gitstore/deploykey_test.go new file mode 100644 index 0000000..28b225c --- /dev/null +++ b/server/internal/gitstore/deploykey_test.go @@ -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("дві згенеровані пари однакові") + } +} diff --git a/server/internal/gitstore/mirror.go b/server/internal/gitstore/mirror.go new file mode 100644 index 0000000..666c0cd --- /dev/null +++ b/server/internal/gitstore/mirror.go @@ -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/ сьогодні одна за видом, але наступний вид даних, який +// хтось покладе в цей самий репозиторій, поїде без жодної правки тут — +// саме тому перелік гілок не зашитий. +// +// 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: » — це + // два боки однієї відмови: перший приходить із перевірки набору + // команд, другий із відповіді сервера на конкретну гілку. + 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") +} diff --git a/server/internal/gitstore/mirror_test.go b/server/internal/gitstore/mirror_test.go new file mode 100644 index 0000000..b2b8c91 --- /dev/null +++ b/server/internal/gitstore/mirror_test.go @@ -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) + } +} diff --git a/server/internal/gitstore/store.go b/server/internal/gitstore/store.go index c9359cf..b88d16c 100644 --- a/server/internal/gitstore/store.go +++ b/server/internal/gitstore/store.go @@ -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 +} diff --git a/server/internal/grpcapi/integration_test.go b/server/internal/grpcapi/integration_test.go index 3e04ffd..659c3bb 100644 --- a/server/internal/grpcapi/integration_test.go +++ b/server/internal/grpcapi/integration_test.go @@ -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 { diff --git a/server/internal/grpcapi/ncm_commands.go b/server/internal/grpcapi/ncm_commands.go new file mode 100644 index 0000000..292012c --- /dev/null +++ b/server/internal/grpcapi/ncm_commands.go @@ -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) + } +} diff --git a/server/internal/grpcapi/ncm_dispatch.go b/server/internal/grpcapi/ncm_dispatch.go index 127b09a..fc58421 100644 --- a/server/internal/grpcapi/ncm_dispatch.go +++ b/server/internal/grpcapi/ncm_dispatch.go @@ -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) + } +} diff --git a/server/internal/grpcapi/ncm_mirror.go b/server/internal/grpcapi/ncm_mirror.go new file mode 100644 index 0000000..1cb96aa --- /dev/null +++ b/server/internal/grpcapi/ncm_mirror.go @@ -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 + } +} diff --git a/server/internal/grpcapi/ncm_retention.go b/server/internal/grpcapi/ncm_retention.go new file mode 100644 index 0000000..d2d43ca --- /dev/null +++ b/server/internal/grpcapi/ncm_retention.go @@ -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) + } + } +} diff --git a/server/internal/grpcapi/ncm_rollback.go b/server/internal/grpcapi/ncm_rollback.go new file mode 100644 index 0000000..404c6a0 --- /dev/null +++ b/server/internal/grpcapi/ncm_rollback.go @@ -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) + } +} diff --git a/server/internal/grpcapi/ncm_schedule.go b/server/internal/grpcapi/ncm_schedule.go index eee675c..89b5d10 100644 --- a/server/internal/grpcapi/ncm_schedule.go +++ b/server/internal/grpcapi/ncm_schedule.go @@ -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 diff --git a/server/internal/grpcapi/plan_sync.go b/server/internal/grpcapi/plan_sync.go index fc764bf..bbc670f 100644 --- a/server/internal/grpcapi/plan_sync.go +++ b/server/internal/grpcapi/plan_sync.go @@ -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) } } } diff --git a/server/internal/grpcapi/service.go b/server/internal/grpcapi/service.go index 256f836..fe254d8 100644 --- a/server/internal/grpcapi/service.go +++ b/server/internal/grpcapi/service.go @@ -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(), diff --git a/server/internal/grpcapi/storage_retention.go b/server/internal/grpcapi/storage_retention.go new file mode 100644 index 0000000..804a875 --- /dev/null +++ b/server/internal/grpcapi/storage_retention.go @@ -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, + "що_зробити", "вказати ємність тому або змонтувати том бази в збірник") + } +} diff --git a/server/internal/grpcapi/streams.go b/server/internal/grpcapi/streams.go index 10b37b9..1e94ffd 100644 --- a/server/internal/grpcapi/streams.go +++ b/server/internal/grpcapi/streams.go @@ -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, diff --git a/server/internal/httpapi/alerts.go b/server/internal/httpapi/alerts.go index 04d1c30..2076646 100644 --- a/server/internal/httpapi/alerts.go +++ b/server/internal/httpapi/alerts.go @@ -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) +} diff --git a/server/internal/httpapi/audit.go b/server/internal/httpapi/audit.go new file mode 100644 index 0000000..02a8a49 --- /dev/null +++ b/server/internal/httpapi/audit.go @@ -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 +} diff --git a/server/internal/httpapi/checks.go b/server/internal/httpapi/checks.go index 8e5368c..eda5a2e 100644 --- a/server/internal/httpapi/checks.go +++ b/server/internal/httpapi/checks.go @@ -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}) +} diff --git a/server/internal/httpapi/compliance.go b/server/internal/httpapi/compliance.go index 7ec5a1b..8121a0f 100644 --- a/server/internal/httpapi/compliance.go +++ b/server/internal/httpapi/compliance.go @@ -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) } diff --git a/server/internal/httpapi/device_detail.go b/server/internal/httpapi/device_detail.go new file mode 100644 index 0000000..cffda74 --- /dev/null +++ b/server/internal/httpapi/device_detail.go @@ -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}) +} diff --git a/server/internal/httpapi/devices_bulk.go b/server/internal/httpapi/devices_bulk.go new file mode 100644 index 0000000..9780b2f --- /dev/null +++ b/server/internal/httpapi/devices_bulk.go @@ -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, + }) +} diff --git a/server/internal/httpapi/downloads.go b/server/internal/httpapi/downloads.go new file mode 100644 index 0000000..5c8a17f --- /dev/null +++ b/server/internal/httpapi/downloads.go @@ -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) + } +} diff --git a/server/internal/httpapi/git_mirror.go b/server/internal/httpapi/git_mirror.go new file mode 100644 index 0000000..c0a98f9 --- /dev/null +++ b/server/internal/httpapi/git_mirror.go @@ -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}) +} diff --git a/server/internal/httpapi/maps_write.go b/server/internal/httpapi/maps_write.go index 494d437..6ad9013 100644 --- a/server/internal/httpapi/maps_write.go +++ b/server/internal/httpapi/maps_write.go @@ -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") { diff --git a/server/internal/httpapi/metrics_devices.go b/server/internal/httpapi/metrics_devices.go new file mode 100644 index 0000000..1182258 --- /dev/null +++ b/server/internal/httpapi/metrics_devices.go @@ -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), + }) +} diff --git a/server/internal/httpapi/ncm_commands.go b/server/internal/httpapi/ncm_commands.go new file mode 100644 index 0000000..6638cc1 --- /dev/null +++ b/server/internal/httpapi/ncm_commands.go @@ -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) +} diff --git a/server/internal/httpapi/ncm_config_devices.go b/server/internal/httpapi/ncm_config_devices.go new file mode 100644 index 0000000..28dbfdc --- /dev/null +++ b/server/internal/httpapi/ncm_config_devices.go @@ -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 +} diff --git a/server/internal/httpapi/ncm_configs.go b/server/internal/httpapi/ncm_configs.go new file mode 100644 index 0000000..c82cda7 --- /dev/null +++ b/server/internal/httpapi/ncm_configs.go @@ -0,0 +1,176 @@ +package httpapi + +import ( + "errors" + "net/http" + + "github.com/netpulse/netpulse/server/internal/store" +) + +// Очистка сховища версій конфігів: політика й ручне видалення. +// +// Два різні права, і це навмисно. +// +// Політику читає й пише той, хто вже керує збором (ncm:read / ncm:write): +// це налаштування, воно нічого не знищує в момент збереження, і живе +// поруч зі спільним розкладом, з яким його й налаштовують за один захід. +// +// Ручне видалення — окреме право ncm:delete. Різниця не в масштабі, а в +// природі дії: розклад можна повернути назад, видалену версію — ні. +// Архів конфігів це те, чим доводять, ЯК виглядало залізо в конкретний +// день; право стирати цей доказ не має видаватись разом із правом +// правити розклад. Той самий висновок, що й у 0036 для ncm:exec. + +const deletePerm = "ncm:delete" + +// handleGetRetention — політика очистки разом із тим, що вона зробить. +func (s *Server) handleGetRetention(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "ncm:read") { + return + } + pol, err := s.store.GetRetention(r.Context(), p.TenantID) + if err != nil { + s.writeStoreError(w, "політика очистки конфігів", err) + return + } + writeJSON(w, http.StatusOK, pol) +} + +func (s *Server) handleSetRetention(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "ncm:write") { + return + } + var in struct { + Enabled bool `json:"enabled"` + Versions int `json:"versions"` + Days int `json:"days"` + } + if !decodeBody(w, r, &in) { + return + } + + if err := s.store.SetRetention(r.Context(), p.TenantID, store.RetentionPolicy{ + Enabled: in.Enabled, Versions: in.Versions, Days: in.Days, + }); 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.AuditActionRetentionUpdate, + ObjectType: store.AuditObjectBackupDefaults, + Meta: map[string]any{ + "enabled": in.Enabled, "versions": in.Versions, "days": in.Days, + }, + }); err != nil { + s.log.Error("аудит політики очистки", "err", err) + } + + pol, err := s.store.GetRetention(r.Context(), p.TenantID) + if err != nil { + s.writeStoreError(w, "політика очистки конфігів", err) + return + } + writeJSON(w, http.StatusOK, pol) +} + +// handleDeleteConfigs видаляє обрані версії. +// +// POST зі списком, а не DELETE /configs/{id} по одній. Дві причини, і +// обидві не про смаки в REST. Підтвердження людина дає один раз на весь +// набір — і саме цей набір має або пройти, або лишити зрозумілий звіт; +// N окремих запитів дали б N рядків аудиту й половину видаленого при +// обриві мережі. І тіло в DELETE підтримується транспортом настільки +// по-різному, що складати список у рядок запиту довелося б власним +// кодуванням масиву. +// +// Захищені версії не роблять запит помилковим: оператор міг обрати +// десять, з яких одна виявилась останньою. Відповідь каже, що зникло, а +// що лишилось і чому. +func (s *Server) handleDeleteConfigs(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, deletePerm) { + return + } + + var in struct { + IDs []string `json:"ids"` + } + if !decodeBody(w, r, &in) { + return + } + if len(in.IDs) == 0 { + writeError(w, http.StatusBadRequest, "no_ids", "не обрано жодної версії") + return + } + // Стеля на розмір запиту: перелік складає клієнт, і без неї одне + // натискання могло б забрати весь архів тенанта. + if len(in.IDs) > 500 { + writeError(w, http.StatusBadRequest, "too_many", + "за один раз можна видалити не більше 500 версій") + return + } + + scope := p.Scope() + res, err := s.store.DeleteConfigs(r.Context(), p.TenantID, in.IDs, scope.CanWrite) + if errors.Is(err, store.ErrNothingToDelete) { + // 409, а не 400: запит правильний, просто жодна з цих версій + // видаленню не підлягає — і клієнт має показати перелік причин. + writeJSON(w, http.StatusConflict, map[string]any{ + "error": map[string]string{ + "code": "nothing_to_delete", "message": store.ErrNothingToDelete.Error(), + }, + "kept": res.Kept, + }) + return + } + if isBadUUID(err) { + writeError(w, http.StatusBadRequest, "bad_id", "некоректний ідентифікатор версії") + return + } + if err != nil { + s.writeStoreError(w, "видалення версій конфігу", err) + return + } + + // Аудит після успіху й не блокує відповідь: версій уже немає, і + // приховати це від людини було б гірше, ніж лишити слід лише в + // журналі сервера. Дати й імена хостів пишуться в meta саме тому, + // що самих версій уже не існує — з'ясувати їх потім не буде як. + items := make([]map[string]any, 0, len(res.Deleted)) + for _, d := range res.Deleted { + items = append(items, map[string]any{ + "id": d.ID, "device_id": d.DeviceID, "device": d.DeviceName, + "collected_at": d.CollectedAt, "size_bytes": d.SizeBytes, + }) + } + if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{ + ActorUserID: p.UserID, + ActorTokenID: p.TokenID, + ActorIP: clientIP(r), + Action: store.AuditActionConfigDelete, + ObjectType: store.AuditObjectConfig, + Meta: map[string]any{ + "deleted": len(res.Deleted), + "freed_bytes": res.FreedBytes, + "kept": len(res.Kept), + "versions": items, + }, + }); err != nil { + s.log.Error("аудит видалення конфігів", "err", err) + } + + s.log.Info("видалено версії конфігів", + "кількість", len(res.Deleted), "байтів", res.FreedBytes, + "користувач", p.Username, "user_id", p.UserID) + + if res.Kept == nil { + res.Kept = []store.KeptConfig{} + } + writeJSON(w, http.StatusOK, res) +} diff --git a/server/internal/httpapi/ncm_localfiles.go b/server/internal/httpapi/ncm_localfiles.go new file mode 100644 index 0000000..d351d5e --- /dev/null +++ b/server/internal/httpapi/ncm_localfiles.go @@ -0,0 +1,138 @@ +package httpapi + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/netpulse/netpulse/server/internal/store" +) + +// Конфіг-файли серверів: хости, які збирають самі себе. +// +// Ручок рівно три, і жодна з них не приймає шляху до файлу. Це не +// економія, а суть задачі: зонд читає файли машини своїми правами, і +// поле «які саме», кероване з кабінету, дало б будь-кому з правом +// ncm:write читання довільного файлу на ВСІХ серверах клієнта одразу. +// Перелік файлів живе лише в локальному конфізі зонда, і в HTTP-шарі +// його не видно взагалі — ані щоб задати, ані щоб прочитати. +// +// Збір «зараз» і збір за розкладом навмисно НЕ мають тут своїх ручок: +// і те, і те вже робить ncm.jobs через handleCollectConfig і +// ScheduleBackups. Друга кнопка «зібрати файли» означала б другу чергу +// й другий розклад, які довелося б тримати узгодженими вручну. + +// handleListServerFiles — сторінка «Конфіги серверів». +func (s *Server) handleListServerFiles(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "ncm:read") { + return + } + + devices, err := s.store.ListSelfDevices(r.Context(), p.TenantID, p.Scope()) + if err != nil { + s.writeStoreError(w, "перелік серверів зі збором файлів", err) + return + } + if devices == nil { + devices = []store.SelfDeviceRow{} + } + + // Довідник зондів їде разом зі списком, а не окремим запитом: він + // потрібен рівно тут, у формі прив'язки, і другий похід у мережу + // заради десятка рядків подвоїв би затримку відкриття сторінки. + agents, err := s.store.SelfAgentOptions(r.Context(), p.TenantID) + if err != nil { + s.writeStoreError(w, "перелік зондів", err) + return + } + if agents == nil { + agents = []store.SelfAgentOption{} + } + + writeJSON(w, http.StatusOK, map[string]any{ + "devices": devices, + "agents": agents, + }) +} + +// handleSetSelfAgent оголошує хост машиною зонда. +// +// Право devices:write, а не ncm:write, і це не дрібниця. ncm:write +// означає «може міняти розклад збору» — дія про час, а не про предмет. +// Тут же вирішується, ЩО саме є цим хостом: картка перестає бути +// записом про залізо в мережі й починає означати конкретну машину. +// Заразом дія переставляє inv.devices.agent_id, тобто змінює інвентар. +func (s *Server) handleSetSelfAgent(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 + } + + var body struct { + AgentID string `json:"agent_id"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.AgentID == "" { + writeError(w, http.StatusBadRequest, "bad_json", "не вказано зонда") + return + } + + if err := s.store.BindSelfDevice(r.Context(), p.TenantID, deviceID, body.AgentID); err != nil { + if errors.Is(err, store.ErrNotFound) { + writeError(w, http.StatusNotFound, "not_found", "хост не знайдено") + return + } + s.writeStoreError(w, "прив'язка хоста до власного зонда", err) + return + } + + s.auditSelfAgent(r, p, store.AuditActionDeviceSelfAgent, deviceID, + map[string]any{"agent_id": body.AgentID}) + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +// handleClearSelfAgent знімає прив'язку. +// +// Вона ж і єдиний спосіб перезакріпити відбиток машини: зонд, +// перенесений на інший сервер, отримає відмову в прийомі набору доти, +// доки людина свідомо не зніме й не поставить прив'язку заново. Автомат +// «ну добре, значить переїхав» зробив би всю перевірку декоративною. +func (s *Server) handleClearSelfAgent(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 + } + + if err := s.store.UnbindSelfDevice(r.Context(), p.TenantID, deviceID); err != nil { + if errors.Is(err, store.ErrNotFound) { + writeError(w, http.StatusNotFound, "not_found", "хост не знайдено") + return + } + s.writeStoreError(w, "зняття прив'язки хоста до зонда", err) + return + } + + s.auditSelfAgent(r, p, store.AuditActionDeviceSelfAgentUn, deviceID, nil) + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (s *Server) auditSelfAgent(r *http.Request, p *Principal, action, deviceID string, meta map[string]any) { + if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{ + ActorUserID: p.UserID, + ActorTokenID: p.TokenID, + ActorIP: clientIP(r), + Action: action, + ObjectType: store.AuditObjectDevice, + ObjectID: deviceID, + Meta: meta, + }); err != nil { + s.log.Error("аудит прив'язки хоста до зонда", "дія", action, "хост", deviceID, "err", err) + } +} diff --git a/server/internal/httpapi/ncm_rollback.go b/server/internal/httpapi/ncm_rollback.go new file mode 100644 index 0000000..f9c3ae5 --- /dev/null +++ b/server/internal/httpapi/ncm_rollback.go @@ -0,0 +1,368 @@ +package httpapi + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + + "github.com/netpulse/netpulse/server/internal/store" +) + +// Відкат конфігурації: намір, погодження, перегляд результату. +// +// ПРАВА, і чому саме такі. +// +// Усе, крім політики, — під ncm:rollback. Це право заведене ще в 0010 і +// свідомо не входить у роль «Інженер»: інженер має ncm:write, тобто +// може налаштувати збір, і давати йому разом із цим можливість писати +// конфіг на живе залізо означало б роздати найнебезпечнішу дію системи +// всім, хто вже працює. Той самий висновок, що й для ncm:exec у 0036. +// +// Читання теж під ncm:rollback, а не під ncm:read. Причина та, що й у +// масовому виконанні команд: перелік команд і стенограма сесії +// показують стан заліза детальніше за будь-яку іншу сторінку, і +// віддавати їх усім, хто має право дивитись конфіги, означало б зробити +// обхідний шлях до того, заради чого й заведено окреме право. +// +// А от ПОЛІТИКА погодження читається з ncm:rollback, але змінюється +// лише з settings:write — і це найважливіший рядок у файлі. Вимикач +// «погодження не потрібне» не має належати тому, хто робить відкати: +// інакше двоетапне погодження перетворюється на одну галочку, яку +// знімає той самий, кого воно мало стримати. + +const rollbackPerm = "ncm:rollback" + +// handleRollbackPlan — що саме зробить відкат, без жодних змін. +// +// Окремий крок перед створенням наміру. Не заради зручності: план і є +// те, що людина погоджує. Без нього підтвердження звучало б як +// «відкотити на версію від 12 березня» — тобто як обіцянка без змісту, +// під якою нема чого підписувати. +func (s *Server) handleRollbackPlan(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, rollbackPerm) { + return + } + + var in struct { + DeviceID string `json:"device_id"` + TargetConfigID string `json:"target_config_id"` + } + if !decodeBody(w, r, &in) { + return + } + if in.DeviceID == "" || in.TargetConfigID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "потрібні device_id і target_config_id") + return + } + // Право змінювати саме цей хост, а не просто «право на відкати». + // Обмеження видимості — окремий від прав вимір: інженер над однією + // філією й інженер над усією мережею мають однакову роль. + if !p.Scope().CanWrite(in.DeviceID) { + writeError(w, http.StatusForbidden, "forbidden", "цей хост поза вашим доступом") + return + } + if !s.hasKeyring(w) { + return + } + + prev, err := s.store.PrepareRollback(r.Context(), p.TenantID, in.DeviceID, in.TargetConfigID, s.keyring) + if err != nil { + s.writeRollbackError(w, "план відкату", err) + return + } + if prev.Commands == nil { + prev.Commands = []string{} + } + if prev.Manual == nil { + prev.Manual = []string{} + } + writeJSON(w, http.StatusOK, prev) +} + +func (s *Server) handleCreateRollback(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, rollbackPerm) { + return + } + + var in store.RollbackInput + if !decodeBody(w, r, &in) { + return + } + if in.DeviceID == "" || in.TargetConfigID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "потрібні device_id і target_config_id") + return + } + if !p.Scope().CanWrite(in.DeviceID) { + writeError(w, http.StatusForbidden, "forbidden", "цей хост поза вашим доступом") + return + } + if !s.hasKeyring(w) { + return + } + + rb, err := s.store.CreateRollback(r.Context(), p.TenantID, p.UserID, in, s.keyring) + if err != nil { + s.writeRollbackError(w, "створення відкату", err) + return + } + + // Аудит пишеться після успіху й не блокує відповідь: намір уже + // існує, і приховати його від людини було б гірше, ніж лишити слід + // лише в журналі сервера. У meta лягає ЗАМАСКОВАНИЙ план — той + // самий, що бачила людина: журнал аудиту читають ширше, ніж + // сторінку відкатів, і секретам там місця тим більше немає. + if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{ + ActorUserID: p.UserID, + ActorTokenID: p.TokenID, + ActorIP: clientIP(r), + Action: store.AuditActionRollbackCreate, + ObjectType: store.AuditObjectRollback, + ObjectID: rb.ID, + Meta: map[string]any{ + "device": rb.DeviceName, + "device_id": rb.DeviceID, + "target_config_id": rb.TargetConfigID, + "base_config_id": rb.BaseConfigID, + "commands": rb.Commands, + "manual": rb.Manual, + "reason": rb.Reason, + "requires_approval": rb.RequiresApproval, + }, + }); err != nil { + s.log.Error("аудит створення відкату", "rollback", rb.ID, "err", err) + } + + s.log.Info("створено намір відкату", + "rollback", rb.ID, "device", rb.DeviceName, + "команд", len(rb.Commands), "вручну", len(rb.Manual), + "погодження", rb.RequiresApproval, "користувач", p.Username) + + writeJSON(w, http.StatusOK, rb) +} + +// handleApproveRollback — та сама друга людина. +// +// Машинному токену тут відмовляємо окремо й навмисно. Погодження — це +// підпис людини під незворотною дією; токен інтеграції підписати +// нічого не може, а можливість погодити ним звела б двоетапність до +// одного curl із того самого скрипта, який намір і створив. +func (s *Server) handleApproveRollback(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, rollbackPerm) { + return + } + if !p.IsUser() { + writeError(w, http.StatusForbidden, "human_required", + "відкат погоджує людина — машинний токен для цього не годиться") + return + } + + var in struct { + Note string `json:"note"` + } + _ = json.NewDecoder(r.Body).Decode(&in) + + id := r.PathValue("id") + rb, err := s.store.ApproveRollback(r.Context(), p.TenantID, id, p.UserID, in.Note) + if err != nil { + s.writeRollbackError(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.AuditActionRollbackApprove, + ObjectType: store.AuditObjectRollback, + ObjectID: id, + Meta: map[string]any{ + "device": rb.DeviceName, + "requested_by": rb.RequestedBy, + "note": in.Note, + }, + }); err != nil { + s.log.Error("аудит погодження відкату", "rollback", id, "err", err) + } + + s.log.Info("відкат погоджено", + "rollback", id, "device", rb.DeviceName, + "автор", rb.RequestedBy, "погодив", p.Username) + + writeJSON(w, http.StatusOK, rb) +} + +func (s *Server) handleRejectRollback(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, rollbackPerm) { + return + } + + var in struct { + Note string `json:"note"` + } + _ = json.NewDecoder(r.Body).Decode(&in) + + id := r.PathValue("id") + rb, err := s.store.RejectRollback(r.Context(), p.TenantID, id, p.UserID, in.Note) + if err != nil { + s.writeRollbackError(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.AuditActionRollbackReject, + ObjectType: store.AuditObjectRollback, + ObjectID: id, + Meta: map[string]any{"device": rb.DeviceName, "note": in.Note}, + }); err != nil { + s.log.Error("аудит відмови у відкаті", "rollback", id, "err", err) + } + + writeJSON(w, http.StatusOK, rb) +} + +func (s *Server) handleListRollbacks(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, rollbackPerm) { + return + } + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + list, err := s.store.ListRollbacks(r.Context(), p.TenantID, + r.URL.Query().Get("device_id"), limit) + if err != nil { + s.writeRollbackError(w, "перелік відкатів", err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"rollbacks": list}) +} + +// handleGetRollback — один намір; стенограма за окремим проханням. +// +// Стенограма не віддається завжди з тієї самої причини, що й у прогоні +// команд: сторінка перечитує намір раз на кілька секунд, поки він +// живий, а стенограма сесії заливки важить сотні кілобайт. +func (s *Server) handleGetRollback(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, rollbackPerm) { + return + } + withLog := r.URL.Query().Get("transcript") == "1" + rb, err := s.store.GetRollback(r.Context(), p.TenantID, r.PathValue("id"), withLog) + if err != nil { + s.writeRollbackError(w, "відкат", err) + return + } + writeJSON(w, http.StatusOK, rb) +} + +func (s *Server) handleGetRollbackPolicy(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, rollbackPerm) { + return + } + pol, err := s.store.GetRollbackPolicy(r.Context(), p.TenantID) + if err != nil { + s.writeRollbackError(w, "політика відкату", err) + return + } + // Клієнту важливо не лише значення, а й те, чи має він право його + // змінити: кнопка, яка дає 403 після натискання, гірша за відсутню. + writeJSON(w, http.StatusOK, map[string]any{ + "policy": pol, + "editable": p.Can("settings:write"), + }) +} + +// handleSetRollbackPolicy — окреме право, і не ncm:rollback. +// +// Див. коментар на початку файлу: вимикач вимоги «другої людини» не має +// належати тому, кого ця вимога стримує. +func (s *Server) handleSetRollbackPolicy(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "settings:write") { + return + } + + var in store.RollbackPolicySettings + if !decodeBody(w, r, &in) { + return + } + + pol, err := s.store.SetRollbackPolicy(r.Context(), p.TenantID, p.UserID, in) + if err != nil { + s.writeRollbackError(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.AuditActionRollbackPolicy, + ObjectType: store.AuditObjectRollbackPolicy, + Meta: map[string]any{ + "require_approval": pol.RequireApproval, + "allow_self_approve": pol.AllowSelfApprove, + }, + }); err != nil { + s.log.Error("аудит політики відкату", "err", err) + } + + s.log.Info("політику відкату змінено", + "погодження", pol.RequireApproval, "самопогодження", pol.AllowSelfApprove, + "користувач", p.Username) + + writeJSON(w, http.StatusOK, map[string]any{"policy": pol, "editable": true}) +} + +// writeRollbackError розкладає помилки відкату на коди відповіді. +// +// Окремо від writeStoreError, бо тут майже кожна помилка — не збій, а +// стан, у якому дію робити не можна, і клієнт має показати різні тексти +// різними способами. 409 проти 400 тут не педантизм: «запит +// неправильний» людина виправляє в полях форми, а «конфіг на пристрої +// змінився» — перезавантаженням сторінки й новим поглядом на різницю. +func (s *Server) writeRollbackError(w http.ResponseWriter, what string, err error) { + switch { + case errors.Is(err, store.ErrRollbackStale): + writeError(w, http.StatusConflict, "plan_stale", err.Error()) + case errors.Is(err, store.ErrRollbackSelfApprove): + writeError(w, http.StatusConflict, "self_approve", err.Error()) + case errors.Is(err, store.ErrRollbackState): + writeError(w, http.StatusConflict, "bad_state", err.Error()) + case errors.Is(err, store.ErrRollbackNotSupported): + // 422, а не 400: запит правильний, просто ця родина заліза не + // вміє того, про що просять. Клієнт показує пояснення профілю, + // а не «перевірте поля». + writeError(w, http.StatusUnprocessableEntity, "not_supported", err.Error()) + case errors.Is(err, store.ErrRollbackNothing): + writeError(w, http.StatusUnprocessableEntity, "nothing_to_do", err.Error()) + case errors.Is(err, store.ErrRollbackTooBig): + writeError(w, http.StatusUnprocessableEntity, "too_big", err.Error()) + case errors.Is(err, store.ErrNoProfile): + writeError(w, http.StatusUnprocessableEntity, "no_profile", err.Error()) + case errors.Is(err, store.ErrConfigNotFound): + writeError(w, http.StatusNotFound, "not_found", err.Error()) + case isBadUUID(err): + writeError(w, http.StatusBadRequest, "bad_id", "некоректний ідентифікатор") + default: + s.writeStoreError(w, what, err) + } +} + +// hasKeyring — тіла конфігів зашифровані, і без ключа план не побудувати. +// +// Окремою перевіркою, а не «спробуємо й побачимо»: без ключа помилка +// прийшла б із глибини розшифровки й читалась би як поломка даних, а не +// як «сервер запущено без NETPULSE_MASTER_KEY». +func (s *Server) hasKeyring(w http.ResponseWriter) bool { + if s.keyring == nil { + writeError(w, http.StatusServiceUnavailable, "no_keyring", + "сервер запущено без ключа шифрування") + return false + } + return true +} diff --git a/server/internal/httpapi/queues.go b/server/internal/httpapi/queues.go new file mode 100644 index 0000000..4890f2c --- /dev/null +++ b/server/internal/httpapi/queues.go @@ -0,0 +1,231 @@ +package httpapi + +import ( + "context" + "errors" + "net/http" + "slices" + "sync" + "time" + + "github.com/netpulse/netpulse/server/internal/store" +) + +// Сторінка «Черги»: чи система встигає, і якщо ні — де саме. +// +// Ціна самої сторінки тут важить не менше за її користь. Сторінка, яку +// відкривають саме тоді, коли системі важко, і яка щодві секунди рахує +// агрегати по гіпертаблицях, добиває базу, яку мала стерегти. Тому: +// +// - один знімок на всі черги, а не ендпоїнт на кожну; +// - знімок кешується на кілька секунд і ділиться між усіма, хто +// дивиться: десять відкритих вкладок мають коштувати як одна; +// - самі запити читають хвости за частковими індексами, а не +// перераховують таблиці (див. store/queues.go). +const ( + // Вікно, за яким рахуються надходження, обробка й втрати. + // Година, а не хвилини: бекап конфігу трапляється раз на добу на + // хост, і на п'ятнадцятихвилинному вікні здорова система показувала + // б нулі в усіх стовпчиках. + queueWindow = time.Hour + + // Скільки живе знімок. П'ять секунд — це менше, ніж такт + // диспетчера, тож нічого не пропускається, і водночас достатньо, + // щоб опитування з кількох вкладок злилось в один запит до бази. + queueTTL = 5 * time.Second + + // Через скільки оновлюється точка відліку для накопичувальних + // лічильників. Дві хвилини: менше — і приріст стрибав би від + // одного heartbeat (їх раз на півхвилини), більше — і сторінка + // довго показувала б «щойно втрачено» після того, як усе минулось. + queueBaseAge = 2 * time.Minute +) + +// queueTenantState — пам'ять спостерігача про один кабінет. +// +// Живе в процесі, а не в базі: потрібна лише для того, щоб відрізнити +// «лічильник ріс щойно» від «лічильник ненульовий з учора». Після +// перезапуску API спостереження чесно починається заново — і сторінка +// про це так і каже, замість вигадати приріст. +type queueTenantState struct { + report *store.QueueReport + reportAt time.Time + + // Знімок, з якого зроблено звіт, і приріст до нього. Потрібні + // детальному переліку: він мусить пояснювати рівно те число, яке + // людина щойно бачила на картці. Перезняти їх у момент кліку + // означало б показати перелік, що суперечить числу над ним. + facts *store.QueueFacts + deltas store.QueueDeltas + + // Деталі кешуються окремо від звіту й окремо по кожній черзі: + // відкритий перелік теж оновлюється, а платити за нього повним + // знімком усіх черг немає за що. + details map[string]*store.QueueDetail + detailsAt map[string]time.Time + + baseAt time.Time + baseDropped map[string]int64 + basePool int64 +} + +type queueWatcher struct { + mu sync.Mutex + tenants map[string]*queueTenantState +} + +var queues = &queueWatcher{tenants: map[string]*queueTenantState{}} + +// report віддає знімок — свіжий або щойно зроблений. +// +// Збір відбувається під тим самим замком, що й читання кешу, навмисно: +// коли база пригальмовує, десяток одночасних запитів має чекати на один +// збір, а не влаштувати десять власних. Саме в цю мить сторінка й +// небезпечна для бази. +func (w *queueWatcher) report(ctx context.Context, st *store.Store, + tenantID string) (*store.QueueReport, error) { + + w.mu.Lock() + defer w.mu.Unlock() + + t := w.tenants[tenantID] + if t == nil { + t = &queueTenantState{} + w.tenants[tenantID] = t + } + now := time.Now() + if t.report != nil && now.Sub(t.reportAt) < queueTTL { + return t.report, nil + } + + facts, err := st.CollectQueueFacts(ctx, tenantID, queueWindow) + if err != nil { + return nil, err + } + + d := store.QueueDeltas{AgentDropped: map[string]int64{}} + if !t.baseAt.IsZero() { + d.ObservedSec = int(now.Sub(t.baseAt).Seconds()) + for _, a := range facts.Agents { + if a.Dropped == nil { + continue + } + was, ok := t.baseDropped[a.ID] + // Лічильник, що поїхав назад, означає перезапуск зонда, а не + // втрату «мінус сто»: за точку відліку беремо нове значення. + if ok && *a.Dropped > was { + d.AgentDropped[a.ID] = *a.Dropped - was + } + } + if facts.Pool.EmptyAcquires > t.basePool { + d.PoolEmpty = facts.Pool.EmptyAcquires - t.basePool + } + } + + if t.baseAt.IsZero() || now.Sub(t.baseAt) >= queueBaseAge { + t.baseAt = now + t.basePool = facts.Pool.EmptyAcquires + t.baseDropped = make(map[string]int64, len(facts.Agents)) + for _, a := range facts.Agents { + if a.Dropped != nil { + t.baseDropped[a.ID] = *a.Dropped + } + } + } + + t.report, t.reportAt = store.BuildQueueReport(facts, d), now + t.facts, t.deltas = facts, d + // Новий знімок робить старі переліки застарілими: лишити їх означало + // б показувати під новим числом старі рядки. + t.details, t.detailsAt = nil, nil + return t.report, nil +} + +// detail віддає перелік за однією чергою. +// +// Знімок для нього не збирається окремо: береться той самий, з якого +// зроблено картку (за потреби — свіжий, через report). Інакше перелік +// пояснював би одне число, а на екрані поруч стояло б інше. +func (w *queueWatcher) detail(ctx context.Context, st *store.Store, + tenantID, key string) (*store.QueueDetail, error) { + + // Знімок береться поза власним замком — report бере його сам. + if _, err := w.report(ctx, st, tenantID); err != nil { + return nil, err + } + + w.mu.Lock() + defer w.mu.Unlock() + + t := w.tenants[tenantID] + if t == nil || t.facts == nil { + return nil, errNoSnapshot + } + now := time.Now() + if at, ok := t.detailsAt[key]; ok && now.Sub(at) < queueTTL { + return t.details[key], nil + } + + det, err := st.QueueDetailFor(ctx, tenantID, key, queueWindow, t.facts, t.deltas) + if err != nil { + return nil, err + } + if t.details == nil { + t.details, t.detailsAt = map[string]*store.QueueDetail{}, map[string]time.Time{} + } + t.details[key], t.detailsAt[key] = det, now + return det, nil +} + +var errNoSnapshot = errors.New("знімка черг ще немає") + +// handleQueues — GET /api/v1/queues. +// +// Право agents:read, а не власне: сторінка не показує нічого, чого не +// показують «Зонди», — вона лише складає з цього висновок. Заводити під +// висновок окреме право означало б, що людина, яка відповідає за збір, +// не бачить, чи він устигає. +func (s *Server) handleQueues(w http.ResponseWriter, r *http.Request, p *Principal) { + if !p.Can("agents:read") { + writeError(w, http.StatusForbidden, "forbidden", "немає права agents:read") + return + } + + rep, err := queues.report(r.Context(), s.store, p.TenantID) + if err != nil { + s.log.Error("знімок черг", "err", err) + writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка") + return + } + writeJSON(w, http.StatusOK, rep) +} + +// handleQueueDetail — GET /api/v1/queues/{key}. +// +// Окремий виклик, а не поле у знімку, навмисно. Знімок їде раз на п'ять +// секунд усім, хто відкрив сторінку; перелік зачеплених хостів потрібен +// одному й лише після того, як він натиснув на картку. Возити його +// разом зі знімком означало б платити за нього постійно, а дивитись — +// зрідка. +func (s *Server) handleQueueDetail(w http.ResponseWriter, r *http.Request, p *Principal) { + if !p.Can("agents:read") { + writeError(w, http.StatusForbidden, "forbidden", "немає права agents:read") + return + } + + key := r.PathValue("key") + // Білий список, а не довільний ключ у switch: інакше друкарська + // помилка в адресі давала б 500 замість зрозумілої відмови. + if !slices.Contains(store.QueueDetailKeys(), key) { + writeError(w, http.StatusNotFound, "not_found", "немає такої черги: "+key) + return + } + + det, err := queues.detail(r.Context(), s.store, p.TenantID, key) + if err != nil { + s.log.Error("деталі черги", "key", key, "err", err) + writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка") + return + } + writeJSON(w, http.StatusOK, det) +} diff --git a/server/internal/httpapi/roles.go b/server/internal/httpapi/roles.go new file mode 100644 index 0000000..c945ea4 --- /dev/null +++ b/server/internal/httpapi/roles.go @@ -0,0 +1,293 @@ +package httpapi + +import ( + "errors" + "net/http" + "sort" + "strings" + + "github.com/netpulse/netpulse/server/internal/store" +) + +// Ролі та права. +// +// Редагування ролей іде під тим самим правом, що й керування +// користувачами (users:write), і окремого права під це НЕ заведено +// свідомо. Той, хто може призначити людині роль «Адмін», уже може все, +// що вміє адмін; додаткова галочка «а ще й правити самі ролі» розділяла +// б те, чого немає різниці розділяти, і водночас створювала б ілюзію +// двох рівнів доступу там, де рівень один. +// +// Опис users:write у 0053 переписано так, щоб це було видно з самого +// опису: «Заводити користувачів, змінювати ролі та склад самих ролей». + +const rolesReadPerm = "users:read" +const rolesWritePerm = "users:write" + +// maxRoleName — межа не з бази (там text), а з екрана: назва ролі +// стоїть у випадному списку поруч із іменем людини, і довша за це вона +// або обрізається, або ламає рядок. +const maxRoleName = 64 +const maxRoleDescription = 300 + +// handlePermissionCatalog — довідник прав для екрана редактора. +// +// Окрема ручка, а не поле всередині /api/v1/roles: перелік прав — це +// властивість збірки продукту, однакова для всіх ролей і всіх кабінетів, +// і повторювати двадцять чотири описи в кожній із п'яти ролей означало +// б слати ту саму сторінку тексту п'ять разів. +func (s *Server) handlePermissionCatalog(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, rolesReadPerm) { + return + } + groups, err := s.store.PermissionCatalog(r.Context()) + if err != nil { + s.writeStoreError(w, "довідник прав", err) + return + } + if groups == nil { + groups = []store.PermissionGroup{} + } + writeJSON(w, http.StatusOK, map[string]any{"groups": groups}) +} + +// handleRoleMemberCounts не існує окремо: скільки людей у кожній ролі, +// сторінка бачить із /api/v1/team, який вона й так завантажує. Друга +// ручка на те саме розійшлася б із першою рівно тоді, коли їх покажуть +// поруч на одному екрані. + +type roleBody struct { + Name string `json:"name"` + Description string `json:"description"` + Permissions []string `json:"permissions"` +} + +func (in *roleBody) normalize() { + in.Name = strings.TrimSpace(in.Name) + in.Description = strings.TrimSpace(in.Description) + sort.Strings(in.Permissions) +} + +func (in *roleBody) validate(w http.ResponseWriter) bool { + if in.Name == "" { + writeError(w, http.StatusBadRequest, "invalid", "у ролі має бути назва") + return false + } + if len([]rune(in.Name)) > maxRoleName { + writeError(w, http.StatusBadRequest, "invalid", "назва ролі задовга") + return false + } + if len([]rune(in.Description)) > maxRoleDescription { + writeError(w, http.StatusBadRequest, "invalid", "опис ролі задовгий") + return false + } + return true +} + +// checkGrantable — заборона роздати більше, ніж маєш сам. +// +// Класичне підвищення привілеїв: людина з users:write, але без ncm:exec, +// заводить роль із ncm:exec і садить у неї підставний обліковий запис — +// або просто колегу, який попросив. Своєї ролі вона змінити не може +// (handleSetRole забороняє це окремо), а от створити роль сильнішу за +// власну — цілком, і жодне з наявних правил цього не ловить. +// +// Правило одне й перевіряється на кожному ключі: віддати можна лише те, +// що маєш. Власника воно не зачіпає взагалі — у нього всі 24 права, — +// тобто ціна правила падає рівно на того, кому доступ і звужували. +func checkGrantable(w http.ResponseWriter, p *Principal, perms []string) bool { + var missing []string + for _, k := range perms { + if !p.Can(k) { + missing = append(missing, k) + } + } + if len(missing) == 0 { + return true + } + writeError(w, http.StatusForbidden, "not_grantable", + "не можна надати роль права, яких немає у вас самих: "+strings.Join(missing, ", ")) + return false +} + +func (s *Server) handleCreateRole(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, rolesWritePerm) { + return + } + var in roleBody + if !decodeBody(w, r, &in) { + return + } + in.normalize() + if !in.validate(w) || !checkGrantable(w, p, in.Permissions) { + return + } + + role, err := s.store.CreateRole(r.Context(), p.TenantID, store.RoleInput{ + Name: in.Name, + Description: in.Description, + Permissions: in.Permissions, + }) + if err != nil { + s.writeRoleError(w, "створення ролі", err) + return + } + + s.auditRole(r, p, store.AuditActionRoleCreate, role.ID, map[string]any{ + "key": role.Key, + "name": role.Name, + "after": map[string]any{ + "name": role.Name, + "description": role.Description, + "permissions": role.Permissions, + }, + }) + writeJSON(w, http.StatusCreated, role) +} + +func (s *Server) handleUpdateRole(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, rolesWritePerm) { + return + } + var in roleBody + if !decodeBody(w, r, &in) { + return + } + in.normalize() + if !in.validate(w) || !checkGrantable(w, p, in.Permissions) { + return + } + + roleID := r.PathValue("id") + before, after, err := s.store.UpdateRole(r.Context(), p.TenantID, roleID, store.RoleInput{ + Name: in.Name, + Description: in.Description, + Permissions: in.Permissions, + }) + if err != nil { + s.writeRoleError(w, "зміна ролі", err) + return + } + + added, removed := diffPerms(before.Permissions, after.Permissions) + s.auditRole(r, p, store.AuditActionRoleUpdate, after.ID, map[string]any{ + "key": after.Key, + "before": map[string]any{ + "name": before.Name, + "description": before.Description, + "permissions": before.Permissions, + }, + "after": map[string]any{ + "name": after.Name, + "description": after.Description, + "permissions": after.Permissions, + }, + // Два переліки поруч із двома знімками: знімки відповідають на + // «як було й як стало», а ці — на «що саме змінилось». Друге + // шукають першим, а рахувати різницю двох масивів очима в + // журналі — саме те, чого від журналу не чекають. + "granted": added, + "revoked": removed, + }) + writeJSON(w, http.StatusOK, after) +} + +func (s *Server) handleDeleteRole(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, rolesWritePerm) { + return + } + roleID := r.PathValue("id") + + gone, err := s.store.DeleteRole(r.Context(), p.TenantID, roleID) + if err != nil { + s.writeRoleError(w, "видалення ролі", err) + return + } + + s.auditRole(r, p, store.AuditActionRoleDelete, gone.ID, map[string]any{ + "key": gone.Key, + "name": gone.Name, + // Знімок саме тут найцінніший: після видалення ролі більше + // немає ніде, і питання «а що вона давала» лишається без + // відповіді назавжди. + "before": map[string]any{ + "name": gone.Name, + "description": gone.Description, + "permissions": gone.Permissions, + }, + }) + w.WriteHeader(http.StatusNoContent) +} + +// --------------------------------------------------------------------- + +// auditRole пише слід зміни доступу. +// +// Аудит іде після успіху й не перетворює збій журналу на помилку +// запиту: роль уже змінено, і сховати це від людини було б гірше, ніж +// лишити слід лише в логах сервера. Той самий порядок, що й у прогонах +// команд. +// +// «Було» і «стало» лягають у meta, а не в колонки before/after таблиці: +// store.AuditEntry цих полів ще не має, а дописувати їх треба разом із +// рештою місць, які пишуть аудит. Пошук по журналу від цього не +// страждає — індекс audit_search_trgm_idx із 0050 накриває meta так +// само, як before і after. +func (s *Server) auditRole(r *http.Request, p *Principal, action, roleID string, meta map[string]any) { + if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{ + ActorUserID: p.UserID, + ActorTokenID: p.TokenID, + ActorIP: clientIP(r), + Action: action, + ObjectType: store.AuditObjectRole, + ObjectID: roleID, + Meta: meta, + }); err != nil { + s.log.Error("аудит зміни ролі", "дія", action, "роль", roleID, "err", err) + } +} + +func diffPerms(before, after []string) (added, removed []string) { + was := make(map[string]bool, len(before)) + for _, k := range before { + was[k] = true + } + now := make(map[string]bool, len(after)) + for _, k := range after { + now[k] = true + } + for _, k := range after { + if !was[k] { + added = append(added, k) + } + } + for _, k := range before { + if !now[k] { + removed = append(removed, k) + } + } + return added, removed +} + +// writeRoleError — три відмови, які людина мусить зрозуміти з першого +// разу, бо кожна з них означає окрему подальшу дію. +func (s *Server) writeRoleError(w http.ResponseWriter, what string, err error) { + switch { + case errors.Is(err, store.ErrRoleSystem): + writeError(w, http.StatusForbidden, "role_system", + "це вбудована роль — вона спільна для всієї інсталяції й не редагується. "+ + "Зробіть її копію та змініть копію") + + case errors.Is(err, store.ErrRoleInUse): + writeError(w, http.StatusConflict, "role_in_use", + err.Error()+". Спершу переведіть їх в іншу роль") + + case errors.Is(err, store.ErrLastAdmin): + writeError(w, http.StatusConflict, "last_admin", + "після цієї зміни в організації не лишиться жодного учасника з правом "+ + "users:write — повернути його не зміг би ніхто, навіть ви") + + default: + s.writeStoreError(w, what, err) + } +} diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index adece2e..8a0c6a5 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -29,6 +29,12 @@ type Server struct { // а /api/v1/channels відповідає 503 із поясненням. keyring *crypto.Keyring notifier *alerting.Notifier + + // Приймач подій для правил джерела `compliance`: прогін + // відповідності запускається саме звідси, і саме він — та мить, + // коли порушення стає подією. nil означає, що движок алертів на + // цій інсталяції вимкнено. + events *alerting.EventSink } // New створює сервер. signer може бути nil лише в тестах, які не @@ -52,6 +58,27 @@ func (s *Server) WithNotifications(ring *crypto.Keyring, n *alerting.Notifier) * return s } +// WithEventAlerts вмикає подієві алерти на прогоні відповідності. +// +// Окремо від WithNotifications, бо це різні речі: там — доставка, тут — +// сама поява алерту. Інсталяція без каналів усе одно має бачити +// порушення на дошці. +func (s *Server) WithEventAlerts(sink *alerting.EventSink) *Server { + s.events = sink + return s +} + +// WithKeyring дає серверу ключі шифрування без движка алертів. +// +// До появи дзеркала конфігів ключ приходив лише разом із каналами +// сповіщень (WithNotifications), і на інсталяції з вимкненим движком +// алертів його не було зовсім. Секрет дзеркала до алертів стосунку не +// має, тож і залежності такої бути не повинно. +func (s *Server) WithKeyring(ring *crypto.Keyring) *Server { + s.keyring = ring + return s +} + // Hub — доступ до трансляції для зовнішнього коду (тести, метрики). func (s *Server) Hub() *Hub { return s.hub } @@ -73,11 +100,23 @@ func (s *Server) Handler() http.Handler { mux.Handle("GET /api/v1/me", s.authenticated(s.handleMe)) mux.Handle("POST /api/v1/auth/password", s.authenticated(s.handleChangePassword)) + // Прив'язка telegram-акаунта. Ендпоїнта, який приймав би натискання + // кнопок, тут немає й не буде: Telegram до цього сервера не + // достукається (самопідписаний TLS на IP), тому оновлення забирає + // довге опитування — див. alerting/telegram_bot.go. + mux.Handle("GET /api/v1/me/telegram", s.authenticated(s.handleGetTelegramLink)) + mux.Handle("POST /api/v1/me/telegram/code", s.authenticated(s.handleCreateTelegramLinkCode)) + mux.Handle("DELETE /api/v1/me/telegram", s.authenticated(s.handleDeleteTelegramLink)) + mux.Handle("GET /api/v1/team", s.authenticated(s.handleListTeam)) mux.Handle("POST /api/v1/team", s.authenticated(s.handleCreateUser)) mux.Handle("PATCH /api/v1/team/{id}", s.authenticated(s.handleSetRole)) mux.Handle("DELETE /api/v1/team/{id}", s.authenticated(s.handleRemoveMember)) mux.Handle("GET /api/v1/roles", s.authenticated(s.handleListRoles)) + mux.Handle("POST /api/v1/roles", s.authenticated(s.handleCreateRole)) + mux.Handle("PATCH /api/v1/roles/{id}", s.authenticated(s.handleUpdateRole)) + mux.Handle("DELETE /api/v1/roles/{id}", s.authenticated(s.handleDeleteRole)) + mux.Handle("GET /api/v1/permissions", s.authenticated(s.handlePermissionCatalog)) mux.Handle("GET /api/v1/dashboards", s.authenticated(s.handleListDashboards)) mux.Handle("POST /api/v1/dashboards", s.authenticated(s.handleSaveDashboard)) @@ -102,6 +141,8 @@ func (s *Server) Handler() http.Handler { mux.Handle("DELETE /api/v1/maps/{id}", s.authenticated(s.handleDeleteMap)) mux.Handle("POST /api/v1/maps/{id}/build", s.authenticated(s.handleBuildMap)) mux.Handle("POST /api/v1/maps/{id}/undo", s.authenticated(s.handleUndoMap)) + mux.Handle("GET /api/v1/maps/{id}/interfaces", s.authenticated(s.handleMapInterfaces)) + mux.Handle("POST /api/v1/maps/{id}/discover", s.authenticated(s.handleMapDiscover)) mux.Handle("GET /api/v1/maps/{id}/permissions", s.authenticated(s.handleListMapPermissions)) mux.Handle("PUT /api/v1/maps/{id}/permissions", s.authenticated(s.handleSetMapPermissions)) mux.Handle("GET /api/v1/icons", s.authenticated(s.handleListIcons)) @@ -110,12 +151,29 @@ func (s *Server) Handler() http.Handler { mux.Handle("DELETE /api/v1/icons/{id}", s.authenticated(s.handleDeleteIcon)) mux.Handle("GET /api/v1/devices", s.authenticated(s.handleListDevices)) mux.Handle("POST /api/v1/devices", s.authenticated(s.handleCreateDevice)) + mux.Handle("GET /api/v1/devices/{id}", s.authenticated(s.handleGetDevice)) mux.Handle("PATCH /api/v1/devices/{id}", s.authenticated(s.handleUpdateDevice)) mux.Handle("DELETE /api/v1/devices/{id}", s.authenticated(s.handleDeleteDevice)) + // Картка хоста: паспорт, останні дані з походженням, порти. + mux.Handle("GET /api/v1/devices/{id}/overview", s.authenticated(s.handleDeviceOverview)) + mux.Handle("GET /api/v1/devices/{id}/latest", s.authenticated(s.handleDeviceLatest)) + mux.Handle("GET /api/v1/devices/{id}/interfaces", s.authenticated(s.handleDeviceInterfaces)) + + // Масова правка й масове видалення. Перегляд набору — окремий крок + // і окрема ручка, як і в масовому виконанні команд: підтвердження + // без точного переліку не вважається підтвердженням. + mux.Handle("POST /api/v1/devices/bulk-targets", s.authenticated(s.handleBulkDeviceTargets)) + mux.Handle("POST /api/v1/devices/bulk-update", s.authenticated(s.handleBulkUpdateDevices)) + mux.Handle("POST /api/v1/devices/bulk-delete", s.authenticated(s.handleBulkDeleteDevices)) + mux.Handle("POST /api/v1/devices/bulk-restore", s.authenticated(s.handleBulkRestoreDevices)) + + mux.Handle("POST /api/v1/devices/{id}/identify", s.authenticated(s.handleIdentifyDevice)) mux.Handle("POST /api/v1/devices/{id}/collect-config", s.authenticated(s.handleCollectConfig)) mux.Handle("GET /api/v1/devices/{id}/config-jobs", s.authenticated(s.handleListConfigJobs)) mux.Handle("GET /api/v1/devices/{id}/configs", s.authenticated(s.handleListConfigs)) + // Добір хостів на сторінці конфігів: спільний фільтр плюс стан архіву. + mux.Handle("POST /api/v1/ncm/config-devices", s.authenticated(s.handleConfigDevices)) mux.Handle("GET /api/v1/configs/{id}", s.authenticated(s.handleGetConfig)) mux.Handle("GET /api/v1/configs/{id}/diff", s.authenticated(s.handleDiffConfigs)) mux.Handle("GET /api/v1/devices/{id}/backup-policy", s.authenticated(s.handleGetBackupPolicy)) @@ -134,19 +192,94 @@ func (s *Server) Handler() http.Handler { mux.Handle("GET /api/v1/ncm/compliance/results", s.authenticated(s.handleListComplianceResults)) mux.Handle("POST /api/v1/ncm/compliance/run", s.authenticated(s.handleRunCompliance)) + // Масове виконання команд. Перегляд переліку хостів — окремий крок + // і окремий ендпоїнт: підтвердження без точного переліку не + // вважається підтвердженням, і клієнт не має змоги його оминути. + mux.Handle("POST /api/v1/ncm/command-targets", s.authenticated(s.handlePreviewCommandTargets)) + mux.Handle("GET /api/v1/ncm/command-runs", s.authenticated(s.handleListCommandRuns)) + mux.Handle("POST /api/v1/ncm/command-runs", s.authenticated(s.handleCreateCommandRun)) + mux.Handle("GET /api/v1/ncm/command-runs/{id}", s.authenticated(s.handleGetCommandRun)) + mux.Handle("POST /api/v1/ncm/command-runs/{id}/cancel", s.authenticated(s.handleCancelCommandRun)) + mux.Handle("DELETE /api/v1/ncm/command-runs/{id}", s.authenticated(s.handleDeleteCommandRun)) + // Звіт віддається не тут, а за квитком: файл тягне завантажувач + // браузера, який заголовка Authorization не додає. Див. downloads.go. + mux.Handle("POST /api/v1/ncm/command-runs/{id}/report-link", s.authenticated(s.handleCommandRunReportLink)) + + // Відкат конфігу. План — окремий крок і окремий ендпоїнт, з тієї ж + // причини, що й перегляд хостів у масових командах: підтвердження + // без точного переліку того, що поїде на пристрій, підтвердженням + // не є. Погодження й відмова — окремі дії, бо в них інший автор. + // + // Політика лежить поруч, але змінюється іншим правом + // (settings:write): вимикач вимоги «другої людини» не має належати + // тому, кого ця вимога стримує. Див. ncm_rollback.go. + mux.Handle("POST /api/v1/ncm/rollbacks/plan", s.authenticated(s.handleRollbackPlan)) + mux.Handle("GET /api/v1/ncm/rollbacks", s.authenticated(s.handleListRollbacks)) + mux.Handle("POST /api/v1/ncm/rollbacks", s.authenticated(s.handleCreateRollback)) + mux.Handle("GET /api/v1/ncm/rollbacks/{id}", s.authenticated(s.handleGetRollback)) + mux.Handle("POST /api/v1/ncm/rollbacks/{id}/approve", s.authenticated(s.handleApproveRollback)) + mux.Handle("POST /api/v1/ncm/rollbacks/{id}/reject", s.authenticated(s.handleRejectRollback)) + mux.Handle("GET /api/v1/ncm/rollback-policy", s.authenticated(s.handleGetRollbackPolicy)) + mux.Handle("PUT /api/v1/ncm/rollback-policy", s.authenticated(s.handleSetRollbackPolicy)) + + // Завантаження файлу за короткоживучим квитком. Друга (після режиму + // NOC TV) частина API без входу — право лежить у токені шляху. + mux.HandleFunc("GET /api/v1/downloads/{token}", s.handleDownload) + + // Конфіг-файли серверів: хости, які збирають самі себе. Збір і + // розклад окремих ручок не мають — і те, і те вже робить черга + // ncm.jobs (collect-config вище, ScheduleBackups у grpcapi). + mux.Handle("GET /api/v1/ncm/server-files", s.authenticated(s.handleListServerFiles)) + mux.Handle("PUT /api/v1/devices/{id}/self-agent", s.authenticated(s.handleSetSelfAgent)) + mux.Handle("DELETE /api/v1/devices/{id}/self-agent", s.authenticated(s.handleClearSelfAgent)) + mux.Handle("GET /api/v1/ncm/backup-defaults", s.authenticated(s.handleGetBackupDefaults)) mux.Handle("PUT /api/v1/ncm/backup-defaults", s.authenticated(s.handleSetBackupDefaults)) + // Очистка сховища версій: політика поруч зі спільним розкладом, + // ручне видалення — окремим правом ncm:delete (див. ncm_configs.go). + mux.Handle("GET /api/v1/ncm/retention", s.authenticated(s.handleGetRetention)) + mux.Handle("PUT /api/v1/ncm/retention", s.authenticated(s.handleSetRetention)) + mux.Handle("POST /api/v1/ncm/configs/delete", s.authenticated(s.handleDeleteConfigs)) + + // Дзеркало архіву на зовнішній Git. Стан видно з ncm:read (помітити + // відмову має могти будь-хто, хто дивиться на конфіги), а міняти + // налаштування — лише з ncm:mirror. Самого push тут немає: його + // робить фоновий такт колектора, див. git_mirror.go. + mux.Handle("GET /api/v1/ncm/mirror", s.authenticated(s.handleGetMirror)) + mux.Handle("PUT /api/v1/ncm/mirror", s.authenticated(s.handleSetMirror)) + mux.Handle("POST /api/v1/ncm/mirror/test", s.authenticated(s.handleTestMirror)) + mux.Handle("POST /api/v1/ncm/mirror/push", s.authenticated(s.handlePushMirror)) + mux.Handle("POST /api/v1/ncm/mirror/deploy-key", s.authenticated(s.handleMirrorDeployKey)) + mux.Handle("DELETE /api/v1/ncm/mirror/secret", s.authenticated(s.handleForgetMirrorSecret)) + + // Журнал SNMP-трапів. Окремим розділом, а не вкладкою в хості: + // трап приходить від АДРЕСИ, і половина цікавого в ньому — це саме + // ті адреси, за якими хоста немає. Всередині картки хоста такий + // трап не показати ніде. + mux.Handle("GET /api/v1/traps", s.authenticated(s.handleListTraps)) + mux.Handle("GET /api/v1/traps/meta", s.authenticated(s.handleTrapsMeta)) + mux.Handle("PUT /api/v1/traps/names", s.authenticated(s.handleSaveTrapName)) + mux.Handle("DELETE /api/v1/traps/names/{oid}", s.authenticated(s.handleDeleteTrapName)) + mux.Handle("DELETE /api/v1/traps/unknown/{ip}", s.authenticated(s.handleForgetUnknownTrapSource)) + mux.Handle("GET /api/v1/check-types", s.authenticated(s.handleListCheckTypes)) mux.Handle("GET /api/v1/devices/{id}/checks", s.authenticated(s.handleListDeviceChecks)) mux.Handle("PUT /api/v1/devices/{id}/checks", s.authenticated(s.handleSetDeviceChecks)) mux.Handle("GET /api/v1/devices/{id}/series", s.authenticated(s.handleListSeries)) mux.Handle("GET /api/v1/devices/{id}/metrics", s.authenticated(s.handleQueryMetrics)) + // Добір хостів для сторінки метрик — тим самим фільтром, що в + // «Командах», масових діях і «Конфігах». + mux.Handle("POST /api/v1/metrics/devices", s.authenticated(s.handleMetricDevices)) mux.Handle("GET /api/v1/templates", s.authenticated(s.handleListTemplates)) mux.Handle("POST /api/v1/templates", s.authenticated(s.handleSaveTemplate)) mux.Handle("GET /api/v1/templates/export", s.authenticated(s.handleExportTemplates)) mux.Handle("POST /api/v1/templates/import", s.authenticated(s.handleImportTemplates)) + // Перевірка фільтра складу портів на справжньому хості — до + // збереження шаблону, а не після. + mux.Handle("GET /api/v1/templates/interface-devices", s.authenticated(s.handleInterfaceFilterDevices)) + mux.Handle("POST /api/v1/templates/interface-preview", s.authenticated(s.handleInterfaceFilterPreview)) mux.Handle("GET /api/v1/templates/{id}", s.authenticated(s.handleGetTemplate)) mux.Handle("PUT /api/v1/templates/{id}", s.authenticated(s.handleSaveTemplate)) mux.Handle("DELETE /api/v1/templates/{id}", s.authenticated(s.handleDeleteTemplate)) @@ -178,6 +311,29 @@ func (s *Server) Handler() http.Handler { mux.Handle("POST /api/v1/agent-enrollments", s.authenticated(s.handleCreateEnrollment)) mux.Handle("DELETE /api/v1/agent-enrollments/{id}", s.authenticated(s.handleDeleteEnrollment)) + // Один ендпоїнт на всі черги: питання «чи все встигає» стосується + // системи цілком, і відповідь, зібрана з семи запитів у різні + // секунди, суперечила б сама собі. Див. queues.go. + mux.Handle("GET /api/v1/queues", s.authenticated(s.handleQueues)) + // А деталі — окремо й на вимогу: перелік зачеплених хостів потрібен + // після кліку по картці, а знімок їде всім раз на п'ять секунд. + mux.Handle("GET /api/v1/queues/{key}", s.authenticated(s.handleQueueDetail)) + + // Журнал аудиту — лише GET, і це не тимчасово: ручки, яка міняє чи + // стирає запис, не буде. Див. audit.go. + // Сховище: розміри, приріст і строки зберігання. Дивитись — + // devices:read (нахил має бачити той, хто щодня дивиться на + // систему), міняти — settings:write. Див. storage.go. + mux.Handle("GET /api/v1/storage", s.authenticated(s.handleStorageUsage)) + mux.Handle("PUT /api/v1/storage/config", s.authenticated(s.handleSetStorageConfig)) + mux.Handle("GET /api/v1/storage/retention", s.authenticated(s.handleGetRetentionSettings)) + mux.Handle("POST /api/v1/storage/retention/preview", s.authenticated(s.handleRetentionPreview)) + mux.Handle("PUT /api/v1/storage/retention", s.authenticated(s.handleSetRetentionSettings)) + + mux.Handle("GET /api/v1/audit", s.authenticated(s.handleListAudit)) + mux.Handle("GET /api/v1/audit/meta", s.authenticated(s.handleAuditMeta)) + mux.Handle("GET /api/v1/audit/{id}", s.authenticated(s.handleGetAuditEvent)) + mux.Handle("GET /api/v1/alerts", s.authenticated(s.handleListAlerts)) mux.Handle("POST /api/v1/alerts/{id}/ack", s.authenticated(s.handleAckAlert)) mux.Handle("POST /api/v1/alerts/{id}/close", s.authenticated(s.handleCloseAlert)) @@ -189,6 +345,14 @@ func (s *Server) Handler() http.Handler { mux.Handle("PATCH /api/v1/alert-rules/{id}", s.authenticated(s.handlePatchAlertRule)) mux.Handle("DELETE /api/v1/alert-rules/{id}", s.authenticated(s.handleDeleteAlertRule)) + // Драбини ескалації. Читання під alerts:read разом із рештою + // сповіщень: побачити, хто буде розбуджений другим, має право той + // самий, хто бачить алерти. Зміна — під alerts:write. + mux.Handle("GET /api/v1/escalation-policies", s.authenticated(s.handleListEscalationPolicies)) + mux.Handle("POST /api/v1/escalation-policies", s.authenticated(s.handleSaveEscalationPolicy)) + mux.Handle("PUT /api/v1/escalation-policies/{id}", s.authenticated(s.handleSaveEscalationPolicy)) + mux.Handle("DELETE /api/v1/escalation-policies/{id}", s.authenticated(s.handleDeleteEscalationPolicy)) + mux.Handle("GET /api/v1/channels", s.authenticated(s.handleListChannels)) mux.Handle("POST /api/v1/channels", s.authenticated(s.handleCreateChannel)) mux.Handle("PUT /api/v1/channels/{id}", s.authenticated(s.handleCreateChannel)) @@ -361,7 +525,23 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request, p *Pr return } - devices, err := s.store.ListDevices(r.Context(), p.TenantID, p.Scope()) + // ?view=archived — перелік прибраних хостів. + // + // Окреме значення, а не прапорець «і прибраних теж»: перелік або + // про те, що працює, або про те, що прибрано, і третього стану + // («усе разом») тут немає навмисно — дії над живим і прибраним + // хостом різні, і один перелік на обидва означав би кнопку, яка + // для половини рядків не має сенсу. + // + // Невідоме значення читається як «живі»: клієнт старішої збірки, + // який дописав щось своє, має отримати колишню поведінку, а не + // відмову. + view := store.DeviceViewLive + if r.URL.Query().Get("view") == string(store.DeviceViewArchived) { + view = store.DeviceViewArchived + } + + devices, err := s.store.ListDevices(r.Context(), p.TenantID, p.Scope(), view) if err != nil { s.log.Error("перелік пристроїв", "err", err) writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка") diff --git a/server/internal/httpapi/storage.go b/server/internal/httpapi/storage.go new file mode 100644 index 0000000..31cb053 --- /dev/null +++ b/server/internal/httpapi/storage.go @@ -0,0 +1,236 @@ +package httpapi + +import ( + "errors" + "net/http" + + "github.com/netpulse/netpulse/server/internal/store" +) + +// Сховище: розміри, приріст і строки зберігання даних. +// +// ПРО ПРАВА +// +// Дивитись — devices:read, тобто кожен, хто взагалі бачить систему. +// Це те саме рішення, що й для дзеркала конфігів (0054): помітити, що +// диск закінчується, має могти той, хто щодня дивиться на моніторинг, а +// не лише власник кабінету. Ховати розмір бази немає від кого, а от не +// побачити нахил вчасно коштує всієї інсталяції. +// +// Міняти — settings:write. Строк зберігання незворотний за наслідками й +// діє на всю інсталяцію, тобто це рівно те, що описує це право: +// «налаштування організації». Досі його не питав жоден обробник (воно +// значилось у dormantPerms як «налаштувань організації ще немає») — +// тепер вони є. +// +// Попередній перегляд теж під settings:write, хоч він нічого й не +// змінює. Дві причини: він частина шляху зміни, а не самостійна +// довідка, і він дорогий — точний підрахунок рядків по хвосту +// гіпертаблиці має право запускати той, хто збирається натиснути +// «застосувати», а не будь-яка відкрита вкладка. + +// handleStorageUsage — скільки що важить і на скільки вистачить місця. +func (s *Server) handleStorageUsage(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "devices:read") { + return + } + out, err := s.store.StorageUsage(r.Context()) + if err != nil { + s.writeStoreError(w, "розміри сховища", err) + return + } + writeJSON(w, http.StatusOK, out) +} + +// handleSetStorageConfig зберігає все, що людина знає про том: його +// ємність (або шлях, за яким її можна виміряти) і пороги попередження. +// +// Ємність питають у людини, бо більше ні в кого: функції «скільки +// вільного на диску» в Postgres немає, а процес API живе в іншому +// контейнері. Без цього числа сторінка чесно не показує запасу — і це +// краще, ніж показати дату переповнення, взяту зі стелі. З 0067 у +// цього числа є й другий шлях: том, змонтований у контейнер збірника, +// міряється, а не вводиться. +// +// Ємність і пороги — одна ручка навмисно. Вони не просто «поруч», вони +// одна річ: поріг «лишилось менше N діб» без ємності не рахується +// взагалі, а ємність без порога нікого ні про що не сповіщає. Дві ручки +// дали б стан «ємність указали, попередження лишили вимкненим» — прилад +// без сигналізації, зібраний тим, хто щойно прийшов його налаштувати. +func (s *Server) handleSetStorageConfig(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "settings:write") { + return + } + // Усі поля попередження — вказівники, і не з любові до вказівників. + // Відсутність поля має означати «не чіпай», а не «постав нуль»: + // інакше перше ж збереження ємності зі старої вкладки тихо вимкнуло + // б попередження й обнулило пороги. Для вимикача ця різниця + // вирішальна — «не сказали» й «вимкни» тут протилежні за наслідками. + var in struct { + DiskBytes int64 `json:"disk_bytes"` + WarnPct int `json:"warn_pct"` + + DataPath *string `json:"data_path"` + AlertEnabled *bool `json:"alert_enabled"` + DaysWarn *int `json:"alert_days_warn"` + DaysCrit *int `json:"alert_days_crit"` + } + if !decodeBody(w, r, &in) { + return + } + if in.WarnPct == 0 { + in.WarnPct = 80 + } + if err := s.store.SetStorageConfig(r.Context(), in.DiskBytes, in.WarnPct, p.UserID); err != nil { + s.writeStoreError(w, "збереження ємності тому", err) + return + } + + // Поточні значення читаються ДО накладання нових: форма може + // прислати лише те, що на ній змінили, а решта має лишитись такою, + // якою була, а не такою, якою її вгадає нуль структури. + cur, err := s.store.StorageAlertSettings(r.Context()) + if err != nil { + s.writeStoreError(w, "налаштування попередження про місце", err) + return + } + if in.AlertEnabled != nil { + cur.Enabled = *in.AlertEnabled + } + if in.DaysWarn != nil { + cur.DaysWarn = *in.DaysWarn + } + if in.DaysCrit != nil { + cur.DaysCrit = *in.DaysCrit + } + if err := s.store.SetStorageAlert(r.Context(), cur, in.DataPath, p.UserID); err != nil { + s.writeStoreError(w, "збереження порогів попередження", err) + return + } + out, err := s.store.StorageUsage(r.Context()) + if err != nil { + s.writeStoreError(w, "розміри сховища", err) + return + } + writeJSON(w, http.StatusOK, out) +} + +// handleGetRetentionSettings — строки разом зі словником видів даних. +// +// Словник їде тим самим запитом, а не окремим: без пояснення «від чого +// залежить обсяг цього виду» форма перетворюється на двадцять полів із +// числами, і заповнюють її навмання. +func (s *Server) handleGetRetentionSettings(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "devices:read") { + return + } + rows, err := s.store.RetentionSettings(r.Context()) + if err != nil { + s.writeStoreError(w, "строки зберігання", err) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "kinds": rows, + // Чи вільно цій людині міняти строки. Клієнт міг би вивести це + // сам зі свого переліку прав, але тоді правило жило б у двох + // місцях і розійшлося б на першій же зміні. + "can_edit": p.Can("settings:write"), + }) +} + +// retentionBody — набір строків цілком. +// +// Саме цілком, а не «що змінилось»: половина перевірок тут про +// стосунки між видами («згортка не живе менше за джерело»), і на +// одному полі їх не перевірити. Значення — вказівник: null означає «не +// видаляти», і це не те саме, що нуль. +type retentionBody struct { + Keep map[string]*int `json:"keep"` +} + +// handleRetentionPreview — «що зникне, якщо застосувати ці строки». +// +// Окремий крок і окрема ручка, як у масовому видаленні хостів: +// підтвердження без точного переліку не вважається підтвердженням. +// Різниця лише в тому, що тут зникає не об'єкт зі списку, а хвіст +// історії — і побачити його інакше ніяк. +func (s *Server) handleRetentionPreview(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "settings:write") { + return + } + var in retentionBody + if !decodeBody(w, r, &in) { + return + } + if err := store.ValidateRetention(in.Keep); err != nil { + s.writeRetentionError(w, err) + return + } + + out, err := s.store.PreviewRetention(r.Context(), in.Keep) + if err != nil { + s.writeStoreError(w, "попередній перегляд очистки", err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"items": out}) +} + +// handleSetRetentionSettings зберігає строки й застосовує політики. +func (s *Server) handleSetRetentionSettings(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "settings:write") { + return + } + var in retentionBody + if !decodeBody(w, r, &in) { + return + } + + // Знімок ДО збереження: у журнал має поїхати «з 35 діб на 7», а не + // «зберегли форму». Читаємо до, бо після цієї відповіді вже немає. + before, err := s.store.RetentionSettings(r.Context()) + if err != nil { + s.writeStoreError(w, "строки зберігання", err) + return + } + + if _, err := s.store.SaveRetention(r.Context(), in.Keep, p.UserID); err != nil { + s.writeRetentionError(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.AuditActionRetentionSettings, + ObjectType: store.AuditObjectRetention, + Meta: store.RetentionAuditMeta(before, in.Keep), + }); err != nil { + s.log.Error("аудит строків зберігання", "err", err) + } + + rows, err := s.store.RetentionSettings(r.Context()) + if err != nil { + s.writeStoreError(w, "строки зберігання", err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"kinds": rows, "can_edit": true}) +} + +// writeRetentionError відрізняє «числа суперечать одне одному» від +// решти. +// +// Обидва — 400, але код різний, і це не формальність: клієнт показує +// такий текст цілком, і «строки суперечать» треба показати поруч із +// полями, а не в загальній смужці помилки нагорі. +func (s *Server) writeRetentionError(w http.ResponseWriter, err error) { + if errors.Is(err, store.ErrRetentionInvalid) { + writeError(w, http.StatusBadRequest, "retention_conflict", err.Error()) + return + } + s.writeStoreError(w, "строки зберігання", err) +} diff --git a/server/internal/httpapi/telegram_link.go b/server/internal/httpapi/telegram_link.go new file mode 100644 index 0000000..736804e --- /dev/null +++ b/server/internal/httpapi/telegram_link.go @@ -0,0 +1,97 @@ +package httpapi + +import ( + "net/http" + + "github.com/netpulse/netpulse/server/internal/store" +) + +// Прив'язка telegram-акаунта до користувача NetPulse. +// +// Навіщо це взагалі в API, адже саму прив'язку робить бот. Бот робить +// другу половину: він приймає код і зіставляє акаунти. Першу половину — +// «видати код тому, хто зараз увійшов» — може зробити лише те місце, де +// людина вже довела, ким вона є, тобто сесія в браузері. +// +// Права окремого немає навмисно. Прив'язка не дає нічого, чого людина +// не має в інтерфейсі: кнопка в Telegram виконує рівно ті самі дії й +// рівно з тими самими перевірками прав і доступу до хостів. Вимагати +// ще й окреме право означало б, що адміністратор має роздати його всім, +// хто вже має alerts:ack, — тобто зайвий крок без жодного рішення. + +// handleGetTelegramLink — що показувати в профілі. +func (s *Server) handleGetTelegramLink(w http.ResponseWriter, r *http.Request, p *Principal) { + if !p.IsUser() { + writeError(w, http.StatusForbidden, "forbidden", "машинний токен не має профілю") + return + } + link, err := s.store.TelegramLink(r.Context(), p.TenantID, p.UserID) + if err != nil { + s.log.Error("прив'язка telegram", "err", err) + writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка") + return + } + writeJSON(w, http.StatusOK, map[string]any{"link": link}) +} + +// handleCreateTelegramLinkCode видає одноразовий код. +// +// Код повертається рівно тут і більше ніде: у базі лежить лише його +// sha256, тож «показати ще раз» неможливо навіть теоретично — можна +// тільки взяти новий, і старий тоді згасне. +func (s *Server) handleCreateTelegramLinkCode(w http.ResponseWriter, r *http.Request, p *Principal) { + if !p.IsUser() { + writeError(w, http.StatusForbidden, "forbidden", "машинний токен не має профілю") + return + } + + code, expires, err := s.store.CreateTelegramLinkCode(r.Context(), p.TenantID, p.UserID) + if err != nil { + s.log.Error("код прив'язки telegram", "err", err) + writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка") + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "code": code, + "expires_at": expires, + // Команду складає сервер, а не браузер: формат розбирає бот + // (parseLinkCommand), і дві його копії розійшлися б рівно тоді, + // коли формат зміниться. + "command": "/link " + code, + }) +} + +// handleDeleteTelegramLink знімає прив'язку. +// +// Разом із нею гаснуть і невикористані коди: лишити чинний код після +// «відв'язати» означало б, що прив'язка повертається сама, варто +// комусь надіслати боту рядок, який людина вже вважає скасованим. +func (s *Server) handleDeleteTelegramLink(w http.ResponseWriter, r *http.Request, p *Principal) { + if !p.IsUser() { + writeError(w, http.StatusForbidden, "forbidden", "машинний токен не має профілю") + return + } + + link, err := s.store.TelegramLink(r.Context(), p.TenantID, p.UserID) + if err != nil { + s.log.Error("прив'язка telegram", "err", err) + writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка") + return + } + if err := s.store.UnlinkTelegram(r.Context(), p.TenantID, p.UserID); err != nil { + s.log.Error("зняття прив'язки telegram", "err", err) + writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка") + return + } + if link != nil { + _ = s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{ + ActorUserID: p.UserID, + ActorIP: clientIP(r), + Action: store.AuditActionTelegramUnlink, + ObjectType: store.AuditObjectTelegram, + ObjectID: link.ID, + Meta: map[string]any{"tg_user_id": link.TgUserID, "tg_username": link.TgUsername}, + }) + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/server/internal/httpapi/templates.go b/server/internal/httpapi/templates.go index 72b3ea6..cd81cbe 100644 --- a/server/internal/httpapi/templates.go +++ b/server/internal/httpapi/templates.go @@ -53,7 +53,10 @@ func (s *Server) handleSaveTemplate(w http.ResponseWriter, r *http.Request, p *P Name string `json:"name"` Description string `json:"description"` Vendor string `json:"vendor"` - Items []struct { + // Фільтр складу портів для snmp.if. Приходить тим самим + // документом, що й решта шаблону: у формі це сусідня вкладка. + IfFilter *store.InterfaceFilter `json:"if_filter"` + Items []struct { Key string `json:"key"` Name string `json:"name"` CheckType string `json:"check_type"` @@ -65,6 +68,11 @@ func (s *Server) handleSaveTemplate(w http.ResponseWriter, r *http.Request, p *P Enabled *bool `json:"enabled"` Params json.RawMessage `json:"params"` } `json:"items"` + // Прототипи їдуть тим самим документом, що й елементи: у формі + // це сусідня вкладка, і окрема ручка означала б, що шаблон + // можна зберегти наполовину — з новими метриками й старим + // правилом виявлення рядків. + Prototypes []store.TemplatePrototype `json:"prototypes"` } if err := json.NewDecoder(r.Body).Decode(&in); err != nil { writeError(w, http.StatusBadRequest, "bad_json", "не вдалося прочитати тіло запиту") @@ -78,12 +86,62 @@ func (s *Server) handleSaveTemplate(w http.ResponseWriter, r *http.Request, p *P return } + // Зразок перевіряємо тут, а не там, де він застосується: криву + // дужку показують тому, хто її щойно надрукував, а не через добу + // в журналі сервера рядком «фільтр не компілюється». + if in.IfFilter != nil && !in.IfFilter.IsEmpty() { + if err := store.ValidateInterfaceFilter(*in.IfFilter); err != nil { + writeError(w, http.StatusBadRequest, "bad_filter", "фільтр портів: "+err.Error()) + return + } + } + + // Прототипи перевіряємо тут із тих самих міркувань, що й фільтр + // портів: криву дужку в зразку показують тому, хто її щойно + // надрукував. Прототип без OID виявлення мовчки не знайшов би + // жодного рядка — тобто виглядав би збереженим і не робив нічого. + for i := range in.Prototypes { + pr := &in.Prototypes[i] + pr.Key = strings.TrimSpace(pr.Key) + pr.Name = strings.TrimSpace(pr.Name) + if pr.Key == "" { + pr.Key = slugify(pr.Name) + } + if pr.Key == "" { + pr.Key = fmt.Sprintf("proto-%d", i+1) + } + if strings.TrimSpace(pr.DiscoverOID) == "" { + writeError(w, http.StatusBadRequest, "bad_request", + "прототип «"+pr.Name+"» без OID виявлення рядків") + return + } + if pr.RowFilter != nil && !pr.RowFilter.IsEmpty() { + if err := store.ValidateRowFilter(*pr.RowFilter); err != nil { + writeError(w, http.StatusBadRequest, "bad_filter", + "фільтр рядків прототипу «"+pr.Name+"»: "+err.Error()) + return + } + } + for _, m := range pr.Metrics { + // Метрика без OID або без ключа не збирає нічого й нікуди: + // зберегти її означало б показати прототип, який виглядає + // робочим. Те саме правило, що для елементів snmp.get. + if strings.TrimSpace(m.OID) == "" || strings.TrimSpace(m.MetricKey) == "" { + writeError(w, http.StatusBadRequest, "bad_request", + "метрика прототипу «"+pr.Name+"» потребує OID і ключ метрики") + return + } + } + } + t := store.Template{ ID: r.PathValue("id"), Key: in.Key, Name: in.Name, Description: strings.TrimSpace(in.Description), Vendor: strings.TrimSpace(in.Vendor), + IfFilter: in.IfFilter, + Prototypes: in.Prototypes, } for _, i := range in.Items { checkType := i.CheckType @@ -164,6 +222,14 @@ func (s *Server) handleSaveTemplate(w http.ResponseWriter, r *http.Request, p *P "шаблон із ключем «"+t.Key+"» уже є — виберіть інший ключ") return } + // Фільтр, який зануляє склад портів на живому хості, — це не + // «внутрішня помилка», а рішення, яке треба переграти. Шаблон + // при цьому лишився таким, яким був: перевірка стоїть усередині + // тієї самої транзакції. + if errors.Is(err, store.ErrInterfaceFilterEmpty) { + writeError(w, http.StatusConflict, "filter_empty", err.Error()) + return + } s.writeStoreError(w, "збереження шаблону", err) return } @@ -391,8 +457,15 @@ func (s *Server) handleSaveTemplateTriggers(w http.ResponseWriter, r *http.Reque if t.Source == "" { t.Source = "metric" } - if !validSources[t.Source] { - writeError(w, http.StatusBadRequest, "bad_source", "невідоме джерело тригера: "+t.Source) + // Відмова стосується лише ввімкнених тригерів. + // + // Шаблон — документ, який редагують цілком: якби нереалізоване + // джерело блокувало збереження, людина з одним старим тригером + // на трапи не могла б виправити жоден інший. Вимкнений тригер + // нічого не обіцяє — він не розгортається в правило взагалі. + if reason := store.UnsupportedSourceReason(t.Source); reason != "" && t.Enabled { + writeError(w, http.StatusBadRequest, "bad_source", + "тригер «"+t.Name+"»: "+reason) return } if t.Severity == "" { @@ -407,6 +480,13 @@ func (s *Server) handleSaveTemplateTriggers(w http.ResponseWriter, r *http.Reque "тригер «"+t.Name+"» без умови") return } + if t.Enabled { + if err := store.ValidateRuleCondition(t.Source, t.Condition); err != nil { + writeError(w, http.StatusBadRequest, "bad_condition", + "тригер «"+t.Name+"»: "+err.Error()) + return + } + } if t.ForSeconds < 0 || t.ForSeconds > 86400 { writeError(w, http.StatusBadRequest, "bad_request", "тригер «"+t.Name+"»: витримка поза межами 0…86400 с") diff --git a/server/internal/httpapi/templates_iffilter.go b/server/internal/httpapi/templates_iffilter.go new file mode 100644 index 0000000..c50e034 --- /dev/null +++ b/server/internal/httpapi/templates_iffilter.go @@ -0,0 +1,77 @@ +package httpapi + +import ( + "encoding/json" + "net/http" + + "github.com/netpulse/netpulse/server/internal/store" +) + +// Попередній перегляд фільтра складу портів. +// +// Окремі ручки, а не поле у відповіді шаблону: зразок перевіряють ДО +// збереження й багато разів поспіль — по разу на кожну правку. Змусити +// зберігати шаблон, щоб побачити наслідок, означало б застосувати +// неперевірений зразок до всіх прив'язаних хостів саме для того, щоб +// дізнатись, чи він правильний. + +// handleInterfaceFilterDevices — на чому можна перевірити зразок. +func (s *Server) handleInterfaceFilterDevices(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "devices:read") { + return + } + list, err := s.store.ListInterfaceFilterDevices(r.Context(), p.TenantID, + r.URL.Query().Get("template_id")) + if err != nil { + s.writeStoreError(w, "хости з портами", err) + return + } + + // Хости поза видимістю оператора прибираємо тут, а не в SQL: + // правило доступу живе в Scope, і другий його примірник у запиті + // розійшовся б із першим на найближчій правці. + scope := p.Scope() + out := make([]store.InterfaceFilterDevice, 0, len(list)) + for _, d := range list { + if scope.CanRead(d.ID) { + out = append(out, d) + } + } + writeJSON(w, http.StatusOK, map[string]any{"devices": out}) +} + +// handleInterfaceFilterPreview проганяє зразок по справжньому хосту. +func (s *Server) handleInterfaceFilterPreview(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "devices:read") { + return + } + + var in struct { + DeviceID string `json:"device_id"` + TemplateID string `json:"template_id"` + Filter store.InterfaceFilter `json:"filter"` + } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeError(w, http.StatusBadRequest, "bad_json", "не вдалося прочитати тіло запиту") + return + } + if in.DeviceID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "не вказано хост для перевірки") + return + } + if !p.Scope().CanRead(in.DeviceID) { + writeError(w, http.StatusForbidden, "forbidden", "немає доступу до цього хоста") + return + } + + // Помилку в зразку віддаємо як 400 з текстом RE2: людина зараз + // дивиться на поле, у якому її зробила, і повідомлення «внутрішня + // помилка» відправило б її шукати проблему деінде. + res, err := s.store.PreviewInterfaceFilter(r.Context(), p.TenantID, + in.DeviceID, in.TemplateID, in.Filter) + if err != nil { + s.writeStoreError(w, "перевірка фільтра портів", err) + return + } + writeJSON(w, http.StatusOK, res) +} diff --git a/server/internal/httpapi/traps.go b/server/internal/httpapi/traps.go new file mode 100644 index 0000000..87f3093 --- /dev/null +++ b/server/internal/httpapi/traps.go @@ -0,0 +1,223 @@ +package httpapi + +import ( + "net/http" + "strconv" + "strings" + "time" + + "github.com/netpulse/netpulse/server/internal/store" +) + +// SNMP-трапи: журнал, словник назв і перелік невідомих відправників. +// +// ПРО ПРАВА +// +// Дивитись — devices:read. Те саме рішення, що для сховища й дзеркала: +// трап — це подія з мережі, і бачити її має той, хто щодня дивиться на +// мережу, а не лише власник кабінету. +// +// Правити словник і прибирати рядки з переліку невідомих — +// settings:write. Словник міняє те, ЯК підписані події для всіх у +// кабінеті, а видалення з переліку невідомих означає «я з цим +// розібрався». І перше, і друге — рішення про кабінет, а не про свій +// екран. + +const ( + trapsReadPerm = "devices:read" + trapsWritePerm = "settings:write" +) + +// trapsMaxRange — стеля глибини вибірки. +// +// Та сама, що в аудиті, і з тих самих міркувань: журнал росте вічно, а +// «покажи все» — це не глибина, а відсутність питання. Строк зберігання +// трапів усе одно 90 діб (0005), тож рік тут — із запасом на кабінети, +// де його подовжили. +const trapsMaxRange = 366 * 24 * time.Hour + +func (s *Server) handleListTraps(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, trapsReadPerm) { + return + } + + q := r.URL.Query() + f := store.TrapFilter{ + DeviceIDs: splitParam(q, "device"), + OIDs: splitParam(q, "oid"), + SourceIP: strings.TrimSpace(q.Get("source_ip")), + Query: strings.TrimSpace(q.Get("q")), + Cursor: q.Get("cursor"), + // «Тільки невідомі» — окремий фільтр, а не порожній device. + // Порожнє значення в адресному рядку губиться, а це питання + // ставлять достатньо часто, щоб на нього давали посилання. + OnlyUnknown: q.Get("unknown") == "1", + } + + 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(-24 * time.Hour) + } + if !f.From.Before(f.To) { + writeError(w, http.StatusBadRequest, "invalid", "початок періоду має бути раніше за кінець") + return + } + if f.To.Sub(f.From) > trapsMaxRange { + writeError(w, http.StatusBadRequest, "invalid", + "період не більший за рік — звузьте діапазон або скористайтесь фільтрами") + return + } + if n, err := strconv.Atoi(q.Get("limit")); err == nil { + f.Limit = n + } + + page, err := s.store.ListTraps(r.Context(), p.TenantID, f) + if err != nil { + s.writeStoreError(w, "журнал трапів", err) + return + } + writeJSON(w, http.StatusOK, page) +} + +// handleTrapsMeta — усе, що сторінці треба знати на момент відкриття. +// +// Одним викликом, а не трьома: словник, перелік невідомих відправників +// і перелік сліпих зон — це стан сторінки, і зібраний із трьох +// відповідей у різні секунди він нічого не виграє, зате додає два +// стани завантаження на екран. +func (s *Server) handleTrapsMeta(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, trapsReadPerm) { + return + } + + custom, err := s.store.TrapNames(r.Context(), p.TenantID) + if err != nil { + s.writeStoreError(w, "словник трапів", err) + return + } + unknown, err := s.store.ListUnknownTrapSources(r.Context(), p.TenantID) + if err != nil { + s.writeStoreError(w, "невідомі джерела трапів", err) + return + } + + type namedOID struct { + OID string `json:"oid"` + store.TrapMeaning + } + names := []namedOID{} + for oid, m := range store.BuiltinTrapNames() { + names = append(names, namedOID{OID: oid, TrapMeaning: m}) + } + for oid, m := range custom { + names = append(names, namedOID{OID: oid, TrapMeaning: m}) + } + + writeJSON(w, http.StatusOK, map[string]any{ + "names": names, + "unknown": unknown, + // Сліпі зони переліком, а не текстом на сторінці: порожній + // журнал без цього блоку читається як «трапів не було», хоча + // частіше означає «їх нікому приймати». Саме це й було станом + // продукту до 0065 — порт слухав ніхто, а екран мовчав. + "blind_spots": trapBlindSpots, + }) +} + +// trapBlindSpots — те, чого приймач трапів НЕ бачить. +// +// Перелік тут, а не в тексті сторінки, з тієї ж причини, що й у +// журналі аудиту: він мусить жити поруч із кодом, який його спростовує. +// Рядок, який перестав бути правдою, прибирає той, хто це полагодив. +var trapBlindSpots = []map[string]string{ + { + "title": "Трапи приймає лише зонд із увімкненим модулем «traps»", + "note": "Модуль вмикається переліком -modules на самому зонді, і йому потрібен " + + "порт 162/udp. Порт нижче 1024, тож у Linux зонду треба CAP_NET_BIND_SERVICE " + + "або перенаправлення 162 → 1162 правилом фаєрвола. Поки цього немає, " + + "пристрої шлють трапи в нікуди, і виглядає це як спокійна мережа.", + }, + { + "title": "SNMPv3 приймається, але не перевіряється", + "note": "Трап v3 розбирається й зберігається, проте підпис і шифрування зонд не " + + "звіряє: для цього потрібні облікові записи USM на кожен пристрій, і " + + "поки їх немає, «перевірено» було б неправдою. Ім'я користувача " + + "зберігається як контекст, а не як доказ.", + }, + { + "title": "Словник MIB — лише шість стандартних трапів плюс ваші записи", + "note": "Вбудовані назви є для coldStart, warmStart, linkDown, linkUp, " + + "authenticationFailure та egpNeighborLoss — їх визначає сам протокол. " + + "Усе інше показується сирим OID, доки ви не додасте назву самі. " + + "Ми навмисно не вгадуємо назви за схожістю OID.", + }, + { + "title": "Зонд обмежує потік", + "note": "Не більше 20 трапів за секунду з однієї адреси й 10 000 у черзі; " + + "надлишок відкидається, а його кількість видно в журналі зонда. " + + "Без цього порт, що фліпає, поклав би і зонд, і базу.", + }, +} + +// handleSaveTrapName додає або міняє власну назву трапа. +func (s *Server) handleSaveTrapName(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, trapsWritePerm) { + return + } + var in struct { + OID string `json:"oid"` + Name string `json:"name"` + Description string `json:"description"` + } + if !decodeBody(w, r, &in) { + return + } + if err := s.store.SaveTrapName(r.Context(), p.TenantID, in.OID, in.Name, in.Description); err != nil { + s.writeStoreError(w, "запис словника трапів", err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (s *Server) handleDeleteTrapName(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, trapsWritePerm) { + return + } + if err := s.store.DeleteTrapName(r.Context(), p.TenantID, r.PathValue("oid")); err != nil { + s.writeStoreError(w, "видалення зі словника трапів", err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +// handleForgetUnknownTrapSource прибирає адресу з переліку невідомих. +// +// Це не видалення даних: самі трапи лишаються в журналі, і якщо з цієї +// адреси прийде наступний, вона в переліку з'явиться знову. Прибирається +// саме ПИТАННЯ — «а що це таке», — на яке людина вже відповіла. +func (s *Server) handleForgetUnknownTrapSource(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, trapsWritePerm) { + return + } + ip := strings.TrimSpace(r.PathValue("ip")) + if ip == "" { + writeError(w, http.StatusBadRequest, "invalid", "потрібна адреса") + return + } + if err := s.store.ForgetUnknownTrapSource(r.Context(), p.TenantID, ip); err != nil { + s.writeStoreError(w, "видалення невідомого джерела", err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} diff --git a/server/internal/httpapi/tv.go b/server/internal/httpapi/tv.go index 21cae29..70bccbb 100644 --- a/server/internal/httpapi/tv.go +++ b/server/internal/httpapi/tv.go @@ -144,7 +144,8 @@ func (s *Server) handleTVDevices(w http.ResponseWriter, r *http.Request) { if !ok { return } - devices, err := s.store.ListDevices(r.Context(), tv.TenantID, store.Scope{Unrestricted: true}) + devices, err := s.store.ListDevices(r.Context(), tv.TenantID, + store.Scope{Unrestricted: true}, store.DeviceViewLive) if err != nil { s.log.Error("хости телевізора", "err", err) writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка") diff --git a/server/internal/store/agents.go b/server/internal/store/agents.go index ea4bd6f..d1094b6 100644 --- a/server/internal/store/agents.go +++ b/server/internal/store/agents.go @@ -66,7 +66,7 @@ func (s *Store) AuthenticateAgent(ctx context.Context, token string) (*Agent, er limits map[string]any ) - err := s.pool.QueryRow(ctx, ` + err := s.bg.QueryRow(ctx, ` SELECT id::text, tenant_id::text, name, site_id::text, token_hash, status::text, enabled_modules, limits FROM core.agents @@ -184,21 +184,35 @@ func (s *Store) RecordHeartbeat(ctx context.Context, a *Agent, hb *npv1.Heartbea 'queue_depth', $4::int, 'dropped_samples', $5::bigint, 'tasks_running', $6::int, - 'clock_skew_ms', $7::bigint + 'clock_skew_ms', $7::bigint, + -- Черга планувальника зонда. Зонд шле її в кожному + -- heartbeat від початку, а в зведенні її не було: без + -- неї видно, скільки задач виконується, і не видно, + -- скільки не встигло початись. + 'tasks_queued', $8::int ) WHERE id = $1 AND tenant_id = $2 `, a.ID, a.TenantID, int64(h.GetRssBytes()), int32(h.GetQueueDepth()), int64(h.GetDroppedSamples()), int32(hb.GetTasksRunning()), - h.GetClockSkew().AsDuration().Milliseconds()) + h.GetClockSkew().AsDuration().Milliseconds(), int32(hb.GetTasksQueued())) - res := s.pool.SendBatch(ctx, batch) - defer res.Close() - for i := 0; i < batch.Len(); i++ { - if _, err := res.Exec(); err != nil { - return fmt.Errorf("heartbeat[%d]: %w", i, err) + // Пакет виконується в тенантній транзакції, хоча перший запит іде в + // гіпертаблицю, де RLS немає. Річ у другому: core.agents під + // tenant_isolation, і без app.tenant_id цей UPDATE зачіпав би нуль + // рядків — мовчки. Зонд слав би heartbeat кожні кілька секунд, а в + // інтерфейсі назавжди лишався б offline із застиглим часом + // останнього зв'язку. Помилки при цьому не було б ніде: SendBatch + // не вважає «0 рядків» збоєм. + return s.InTenantTx(ctx, a.TenantID, func(tx pgx.Tx) error { + res := tx.SendBatch(ctx, batch) + defer res.Close() + for i := 0; i < batch.Len(); i++ { + if _, err := res.Exec(); err != nil { + return fmt.Errorf("heartbeat[%d]: %w", i, err) + } } - } - return nil + return nil + }) } // RecordTaskStatus оновлює core.checks за доповіддю агента. diff --git a/server/internal/store/alerts.go b/server/internal/store/alerts.go index 1b8b446..2de6a75 100644 --- a/server/internal/store/alerts.go +++ b/server/internal/store/alerts.go @@ -23,6 +23,12 @@ type Rule struct { Condition Condition ForSeconds int DependsOnTopology bool + + // Строк життя подієвого алерту й мінімальний проміжок між + // зверненнями до нього. Для опитуваних джерел обидва не мають + // сенсу й лишаються нулями. + AutoCloseSeconds int + MinIntervalSeconds int } // Selector — до чого застосовується правило. Порожній означає «до всього»: @@ -56,6 +62,44 @@ type Condition struct { Value float64 `json:"value"` Agg string `json:"agg"` MetricKey string `json:"metric_key"` // для source=metric: ts.series.metric_key + + // --- подієві джерела --- + // + // У них немає ні порогу, ні вікна: подія або сталася, або ні. + // Тому й поля інші — вони описують не «скільки», а «яка саме». + + // syslog: {"regex":"%LINK-3-UPDOWN.*down","severity_lte":4,"tag":"LINK"} + Regex string `json:"regex,omitempty"` + Tag string `json:"tag,omitempty"` + SeverityLTE *int `json:"severity_lte,omitempty"` + + // ncm: {"event":"changed"} | {"event":"backup_failed"} + // compliance: {"event":"violation"} + Event string `json:"event,omitempty"` + + // compliance: звузити до конкретних правил відповідності й до + // порога їхньої серйозності. Порожньо — усі. + RuleIDs []string `json:"rule_ids,omitempty"` + MinSeverity string `json:"min_severity,omitempty"` + + // trap: {"trap_oid":"1.3.6.1.6.3.1.1.5.3", + // "varbind_oid":"1.3.6.1.2.1.2.2.1.1","varbind_value":"7", + // "source_ip":"10.20.0.0/24"} + // + // Три питання, і рівно ті, які до трапа ставлять: ЩО сталося (OID), + // ЗВІДКИ прийшло (адреса — потрібна окремо від селектора, бо + // селектор оперує хостами, а трап приходить і з адрес, яких в + // інвентарі немає) і З ЯКИМ значенням (varbind). + // + // Чого тут немає й не буде мовчки: зразка (regex) по тексту трапа. + // Трап — це не рядок, а набір типізованих полів, і «пошук по + // трапу» довелося б визначати як пошук по конкатенації чогось із + // чимось. Форма про це каже вголос (ValidateRuleCondition), а не + // приймає regex і не використовує його. + TrapOID string `json:"trap_oid,omitempty"` + VarbindOID string `json:"varbind_oid,omitempty"` + VarbindValue string `json:"varbind_value,omitempty"` + SourceIP string `json:"source_ip,omitempty"` } // Candidate — об'єкт, який щойно задовольнив умову правила. @@ -91,11 +135,12 @@ func (c Candidate) DedupKey(ruleID string) string { // по горутині на тенант означало б платити з'єднанням до БД за кожного // клієнта, у якого може не бути жодного правила. func (s *Store) ActiveRules(ctx context.Context) ([]Rule, error) { - rows, err := s.pool.Query(ctx, ` + rows, err := s.bg.Query(ctx, ` SELECT r.id::text, r.tenant_id::text, r.name, COALESCE(r.description,''), r.source::text, r.severity::text, r.selector::text, r.condition::text, - r.for_seconds, r.depends_on_topology + r.for_seconds, r.depends_on_topology, + r.auto_close_seconds, r.min_interval_seconds FROM alr.rules r JOIN core.tenants t ON t.id = r.tenant_id WHERE r.enabled AND t.status NOT IN ('suspended','cancelled') @@ -112,7 +157,8 @@ func (s *Store) ActiveRules(ctx context.Context) ([]Rule, error) { var sel, cond string if err := rows.Scan(&r.ID, &r.TenantID, &r.Name, &r.Description, &r.Source, &r.Severity, &sel, &cond, - &r.ForSeconds, &r.DependsOnTopology); err != nil { + &r.ForSeconds, &r.DependsOnTopology, + &r.AutoCloseSeconds, &r.MinIntervalSeconds); err != nil { return nil, err } if err := json.Unmarshal([]byte(sel), &r.Selector); err != nil { @@ -181,9 +227,13 @@ func (s *Store) EvaluateRule(ctx context.Context, r Rule) ([]Candidate, error) { case "metric": return s.evalSeries(ctx, r) default: - // Джерела syslog/trap/ncm/compliance обробляються не опитуванням, - // а подіями — цей движок їх свідомо не чіпає. - return nil, nil + // Джерела syslog/ncm/compliance обробляються не опитуванням, а + // в момент надходження події (alerts_events.go). Сюди вони + // доходити не мають: движок відсіює їх раніше, бо інакше + // ResolveMissing із порожнім списком кандидатів закривав би + // щойно піднятий подієвий алерт на наступному ж тіку. + return nil, fmt.Errorf("правило %s: джерело %s не обчислюється опитуванням", + r.Name, r.Source) } } diff --git a/server/internal/store/alerts_channels.go b/server/internal/store/alerts_channels.go index 3e99ebb..6f516b0 100644 --- a/server/internal/store/alerts_channels.go +++ b/server/internal/store/alerts_channels.go @@ -362,6 +362,11 @@ type RuleAction struct { ChannelIDs []string Schedule *RouteSchedule NotifyOnResolve bool + // Драбина ескалації правила. Порожньо — без ескалації. + EscalationPolicyID string + // Джерело правила. Потрібне рівно для одного рішення: подієвий + // алерт (0058) проходить драбину без повторів — див. PlanEscalation. + Source string } // LoadRuleActions читає маршрутизацію всіх увімкнених правил тенанта. @@ -373,7 +378,8 @@ func (s *Store) LoadRuleActions(ctx context.Context, tenantID string) (map[strin err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { rows, err := tx.Query(ctx, ` SELECT id::text, channel_ids::text[], - COALESCE(notify_schedule::text,''), notify_on_resolve + COALESCE(notify_schedule::text,''), notify_on_resolve, + COALESCE(escalation_policy_id::text,''), source::text FROM alr.rules WHERE tenant_id = $1 `, tenantID) if err != nil { @@ -383,7 +389,8 @@ func (s *Store) LoadRuleActions(ctx context.Context, tenantID string) (map[strin for rows.Next() { var id, sched string var a RuleAction - if err := rows.Scan(&id, &a.ChannelIDs, &sched, &a.NotifyOnResolve); err != nil { + if err := rows.Scan(&id, &a.ChannelIDs, &sched, &a.NotifyOnResolve, + &a.EscalationPolicyID, &a.Source); err != nil { return err } if sched != "" { diff --git a/server/internal/store/alerts_escalation.go b/server/internal/store/alerts_escalation.go new file mode 100644 index 0000000..0bb227b --- /dev/null +++ b/server/internal/store/alerts_escalation.go @@ -0,0 +1,653 @@ +package store + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +// --------------------------------------------------------------------- +// Політики +// --------------------------------------------------------------------- + +// EscalationStep — одна сходинка драбини. +// +// AfterMin рахується від ПОЧАТКУ алерту, а не від попередньої сходинки. +// Людина проектує чергування абсолютними числами («через 15 хвилин — +// другий інженер, через 45 — керівник»), і відносні проміжки змушували б +// перераховувати всю драбину щоразу, коли посередині додається сходинка. +type EscalationStep struct { + AfterMin int `json:"after_min"` + ChannelIDs []string `json:"channel_ids"` +} + +// EscalationPolicy — драбина цілком. +type EscalationPolicy struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Steps []EscalationStep `json:"steps"` + // Через скільки хвилин після вичерпання драбини почати її спочатку. + // 0 — не повторювати. + RepeatAfterMin int `json:"repeat_after_min"` + MaxRepeats int `json:"max_repeats"` + // Скільки правил уже посилаються на цю політику — щоб видалення не + // було мовчазним вимкненням ескалації на десятку тригерів. + RuleCount int `json:"rule_count"` +} + +// Стелі драбини. Живуть тут, а не в HTTP-шарі, бо ту саму перевірку +// робить і збереження політики, і CHECK у 0066: три різні числа в трьох +// місцях розходяться на першій же правці. +const ( + MaxEscalationSteps = 10 + MaxEscalationRepeats = 10 + // Доба — стеля проміжку сходинки. Більше означає «розбудити + // завтра», а це вже не ескалація, а нагадування. + MaxEscalationAfterMin = 24 * 60 +) + +// ValidateEscalationSteps відмовляє в драбині, яка не робитиме того, що +// про неї думає людина. +// +// Головна перевірка тут — перша сходинка НЕ на нульовій хвилині. +// Сходинка «через 0 хвилин» пішла б одночасно зі звичайним сповіщенням +// про той самий алерт: людина отримала б два однакові повідомлення й +// вирішила, що система заїкається. Ескалація починається там, де +// закінчилось мовчання, тобто строго пізніше. +func ValidateEscalationSteps(steps []EscalationStep) error { + if len(steps) == 0 { + return fmt.Errorf("%w: драбина без жодної сходинки нікого не розбудить — "+ + "додайте принаймні одну", ErrInvalid) + } + if len(steps) > MaxEscalationSteps { + return fmt.Errorf("%w: сходинок не більше %d: довша драбина — це вже розсилка "+ + "на всю компанію з інтервалом", ErrInvalid, MaxEscalationSteps) + } + prev := 0 + for i, s := range steps { + if s.AfterMin <= 0 { + return fmt.Errorf("%w: сходинка %d стоїть на %d хв — перша ескалація має бути "+ + "пізніше за саме сповіщення, інакше вона його просто продублює", + ErrInvalid, i+1, s.AfterMin) + } + if s.AfterMin > MaxEscalationAfterMin { + return fmt.Errorf("%w: сходинка %d аж через %d хв — це вже нагадування, "+ + "а не ескалація (стеля %d хв)", ErrInvalid, i+1, s.AfterMin, MaxEscalationAfterMin) + } + if i > 0 && s.AfterMin <= prev { + return fmt.Errorf("%w: сходинка %d (%d хв) не пізніша за попередню (%d хв) — "+ + "драбина має вести вгору", ErrInvalid, i+1, s.AfterMin, prev) + } + if len(s.ChannelIDs) == 0 { + return fmt.Errorf("%w: сходинка %d не має жодного каналу — вона мовчатиме, "+ + "а драбина рахуватиме її пройденою", ErrInvalid, i+1) + } + prev = s.AfterMin + } + return nil +} + +// ListEscalationPolicies читає політики кабінету. +func (s *Store) ListEscalationPolicies(ctx context.Context, tenantID string) ([]EscalationPolicy, error) { + var out []EscalationPolicy + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, ` + SELECT p.id::text, p.name, COALESCE(p.description,''), p.steps::text, + COALESCE(p.repeat_after_min, 0), p.max_repeats, + (SELECT count(*)::int FROM alr.rules r WHERE r.escalation_policy_id = p.id) + FROM alr.escalation_policies p + WHERE p.tenant_id = $1 + ORDER BY p.name + `, tenantID) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var p EscalationPolicy + var steps string + if err := rows.Scan(&p.ID, &p.Name, &p.Description, &steps, + &p.RepeatAfterMin, &p.MaxRepeats, &p.RuleCount); err != nil { + return err + } + if err := json.Unmarshal([]byte(steps), &p.Steps); err != nil { + return fmt.Errorf("політика %s: сходинки: %w", p.Name, err) + } + if p.Steps == nil { + p.Steps = []EscalationStep{} + } + out = append(out, p) + } + return rows.Err() + }) + return out, err +} + +// SaveEscalationPolicy створює або замінює політику цілком. +// +// Цілком, а не полями: форма показує повну драбину, і часткове +// оновлення дало б комбінацію сходинок, якої людина не бачила. +func (s *Store) SaveEscalationPolicy(ctx context.Context, tenantID, id string, p EscalationPolicy) (string, error) { + steps, err := json.Marshal(p.Steps) + if err != nil { + return "", err + } + var repeat any + if p.RepeatAfterMin > 0 { + repeat = p.RepeatAfterMin + } + + err = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + if id == "" { + return tx.QueryRow(ctx, ` + INSERT INTO alr.escalation_policies + (tenant_id, name, description, steps, repeat_after_min, max_repeats) + VALUES ($1, $2, $3, $4::jsonb, $5, $6) + RETURNING id::text + `, tenantID, p.Name, nullString(p.Description), string(steps), + repeat, p.MaxRepeats).Scan(&id) + } + ct, err := tx.Exec(ctx, ` + UPDATE alr.escalation_policies + SET name = $3, description = $4, steps = $5::jsonb, + repeat_after_min = $6, max_repeats = $7, updated_at = now() + WHERE tenant_id = $1 AND id = $2 + `, tenantID, id, p.Name, nullString(p.Description), string(steps), + repeat, p.MaxRepeats) + if err != nil { + return err + } + if ct.RowsAffected() == 0 { + return ErrNotFound + } + return nil + }) + return id, err +} + +// DeleteEscalationPolicy прибирає політику. +// +// Правила, що на неї посилались, лишаються без ескалації (ON DELETE SET +// NULL у 0066), а живі драбини зупиняє движок із причиною «політику +// видалено». Мовчазного продовження за старою копією немає навмисно: +// драбина, якої вже немає у формі, але яка ще будить людей, — найгірший +// із можливих станів. +func (s *Store) DeleteEscalationPolicy(ctx context.Context, tenantID, id string) error { + return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + ct, err := tx.Exec(ctx, + `DELETE FROM alr.escalation_policies WHERE tenant_id = $1 AND id = $2`, tenantID, id) + if err != nil { + return err + } + if ct.RowsAffected() == 0 { + return ErrNotFound + } + return nil + }) +} + +// --------------------------------------------------------------------- +// Взведення драбини +// --------------------------------------------------------------------- + +// EscalationGrace — запас до жорсткої стелі життя драбини. +// +// Стеля потрібна через заглушення: заглушена сходинка не витрачається, +// а відкладається (див. PlanEscalation), і без стелі відкладання ходило +// б по колу місяцями на алерті, який ніхто не закриє. Доба запасу +// означає «драбину, яку цілу добу не давали пройти, вже нема сенсу +// проходити»: за добу або аварію розібрали, або вона перестала бути +// новиною. +const EscalationGrace = 24 * time.Hour + +// ArmEscalation ставить драбину на бойовий звід. +// +// ON CONFLICT DO NOTHING — і це не оптимізація, а вимога. Повторна +// доставка того самого алерту трапляється (ретрай, другий інстанс, +// перезапуск між надсиланням і записом), і кожна з них інакше +// перезапускала б драбину з нуля: алерт висів би годинами, а «наступного» +// будили б щоп'ятнадцять хвилин заново. +func (s *Store) ArmEscalation(ctx context.Context, tenantID, alertID, policyID string, + isEvent bool, p EscalationPolicy, alertStarted time.Time) error { + + if len(p.Steps) == 0 { + return nil + } + passStart := alertStarted + next := passStart.Add(time.Duration(p.Steps[0].AfterMin) * time.Minute) + deadline := passStart.Add(escalationSpan(p, isEvent)).Add(EscalationGrace) + + return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + INSERT INTO alr.alert_escalations + (alert_id, tenant_id, policy_id, is_event, step_idx, repeat_idx, + pass_start, next_at, deadline) + VALUES ($1, $2, $3, $4, 0, 0, $5, $6, $7) + ON CONFLICT (alert_id) DO NOTHING + `, alertID, tenantID, policyID, isEvent, passStart, next, deadline) + return err + }) +} + +// escalationSpan — скільки триває драбина, якщо ніхто не втручається. +func escalationSpan(p EscalationPolicy, isEvent bool) time.Duration { + if len(p.Steps) == 0 { + return 0 + } + span := time.Duration(p.Steps[len(p.Steps)-1].AfterMin) * time.Minute + if !isEvent && p.RepeatAfterMin > 0 && p.MaxRepeats > 0 { + pass := time.Duration(p.RepeatAfterMin) * time.Minute + span += time.Duration(p.MaxRepeats) * (pass + span) + } + return span +} + +// --------------------------------------------------------------------- +// Рішення про одну сходинку +// --------------------------------------------------------------------- + +// EscalationSnapshot — усе, що потрібно, щоб вирішити долю однієї +// сходинки. Читається з бази одним запитом і в один момент часу: стан +// алерту, прочитаний окремо від стану драбини, встиг би застаріти рівно +// між двома запитами — тобто саме тоді, коли алерт підтвердили. +type EscalationSnapshot struct { + AlertID string + TenantID string + PolicyID string + PolicyName string + IsEvent bool + + StepIdx int + RepeatIdx int + PassStart time.Time + Deadline time.Time + + Steps []EscalationStep + RepeatAfterMin int + MaxRepeats int + + // Стан алерту на момент читання. Порожньо — алерту вже немає. + AlertState string + // Алерт у вигляді, придатному для тексту повідомлення. + Alert Alert +} + +// EscalationAction — що робити з цією сходинкою. +type EscalationAction int + +const ( + // EscFire — доставити сходинку. + EscFire EscalationAction = iota + // EscDefer — не доставляти й не витрачати: перевірити пізніше. + EscDefer + // EscStop — драбина закінчилась, доставляти нічого. + EscStop +) + +// EscalationDecision — рішення разом із новим станом драбини. +type EscalationDecision struct { + Action EscalationAction + // Код для журналу: sent | done | acked | closed | suppressed | + // deadline | no_policy. + Outcome string + Detail string + + // Яку саме сходинку доставляємо (для EscFire). + StepIdx int + RepeatIdx int + ChannelIDs []string + + // Новий стан. NextAt == nil означає «драбину зупинено». + NextStepIdx int + NextRepeatIdx int + NextPassStart time.Time + NextAt *time.Time +} + +// EscalationRecheck — через скільки перевірити відкладену драбину. +// +// Порівняно з тіком движка це довго й навмисно: заглушений алерт не +// потребує уваги щопівхвилини, а кожна перевірка — це рядок у журналі +// сходинок. +const EscalationRecheck = 5 * time.Minute + +// PlanEscalation вирішує долю однієї сходинки. +// +// Функція чиста, і це головне архітектурне рішення в усій ескалації. +// Причина проста: половина роботи ескалації — НЕ будити. «Сходинка +// спрацювала» перевіряється легко й доводить мало; «сходинка не +// спрацювала, бо алерт підтвердили / закрили / хост заглушено / вікно +// обслуговування / драбина протухла» — це п'ять різних гілок, кожна з +// яких коштує чийогось сну, і перевіряти їх треба без бази. +// +// Перевірка стану робиться ПЕРЕД КОЖНОЮ сходинкою, а не один раз на +// початку. Інакше драбина, взведена о 02:40, о 03:10 будила б людину +// через алерт, закритий о 02:45, — тобто ескалація воскрешала б мертве. +func PlanEscalation(s EscalationSnapshot, now time.Time) EscalationDecision { + stop := func(outcome, detail string) EscalationDecision { + return EscalationDecision{ + Action: EscStop, Outcome: outcome, Detail: detail, + StepIdx: s.StepIdx, RepeatIdx: s.RepeatIdx, + NextStepIdx: s.StepIdx, NextRepeatIdx: s.RepeatIdx, + NextPassStart: s.PassStart, + } + } + + switch s.AlertState { + case "", "resolved", "expired": + // Закритий алерт ескалації не потребує за визначенням. Окремо + // від 'acknowledged', бо це різні історії: тут проблеми більше + // немає, там нею зайнялись. + return stop("closed", "алерт закрито — далі будити нікого") + case "acknowledged": + // Рівно те, заради чого існує кнопка «Прийняти»: ack не гасить + // проблему, він зупиняє драбину. Без цього людина, яка вже + // дивиться на аварію, за 15 хвилин отримала б дзвінок від + // керівника про те, що вона й так чинить. + return stop("acked", "алерт підтверджено — драбину зупинено") + } + + if len(s.Steps) == 0 || s.StepIdx < 0 || s.StepIdx >= len(s.Steps) { + // Політику видалили або переписали коротшою, поки драбина йшла. + // Мовчки добивати за старою копією не можна: драбини, якої вже + // немає у формі, ніхто не знайде, коли питатиме «звідки дзвінок». + return stop("no_policy", "політику ескалації видалено або скорочено") + } + + if !now.Before(s.Deadline) { + return stop("deadline", "драбина протухла — стелю життя вичерпано") + } + + if s.AlertState == "suppressed" { + // Вікно обслуговування й ручне заглушення зупиняють ескалацію + // так само, як звичайне сповіщення: обидва означають «не + // турбувати», і драбина не має бути винятком. + // + // Але сходинка при цьому НЕ витрачається. Різниця принципова: + // заглушення — це «не зараз», а не «проблеми немає». Списана + // сходинка означала б, що півгодинне вікно обслуговування тихо + // роззброює драбину до кінця життя алерту — тобто рівно та + // мовчазна відмова, від якої ескалація й рятує. Тому чекаємо, + // а стелю життя (Deadline) поставлено саме для того, щоб це + // чекання колись закінчилось. + next := now.Add(EscalationRecheck) + if !next.Before(s.Deadline) { + return stop("deadline", "заглушення пережило стелю життя драбини") + } + return EscalationDecision{ + Action: EscDefer, Outcome: "suppressed", + Detail: "придушено (" + s.Alert.SuppressedBy + ") — сходинку відкладено", + StepIdx: s.StepIdx, + RepeatIdx: s.RepeatIdx, + NextStepIdx: s.StepIdx, + NextRepeatIdx: s.RepeatIdx, + NextPassStart: s.PassStart, + NextAt: &next, + } + } + + if s.AlertState != "firing" { + // Невідомий стан. Мовчати безпечніше, ніж будити за здогадкою. + return stop("closed", "невідомий стан алерту "+s.AlertState) + } + + d := EscalationDecision{ + Action: EscFire, + Outcome: "sent", + StepIdx: s.StepIdx, + RepeatIdx: s.RepeatIdx, + ChannelIDs: s.Steps[s.StepIdx].ChannelIDs, + NextPassStart: s.PassStart, + } + + // Є наступна сходинка в цьому проході. + if s.StepIdx+1 < len(s.Steps) { + cur := s.Steps[s.StepIdx].AfterMin + nxt := s.Steps[s.StepIdx+1].AfterMin + at := s.PassStart.Add(time.Duration(nxt) * time.Minute) + // Якщо сходинка спізнилась (процес стояв, драбина чекала кінця + // вікна обслуговування), наступна не має спрацювати негайно + // слідом: інакше після паузи вся драбина висиплеться в одну + // хвилину й розбудить одразу всіх. + gap := nxt - cur + if gap < 1 { + gap = 1 + } + if floor := now.Add(time.Duration(gap) * time.Minute); at.Before(floor) { + at = floor + } + d.NextStepIdx = s.StepIdx + 1 + d.NextRepeatIdx = s.RepeatIdx + d.NextAt = &at + return d + } + + // Драбина вичерпана. Повтор — це ставка на те, що проблема ще + // триває, і зробити її можна лише там, де існування алерту саме по + // собі є доказом: метричний алерт живий рівно доти, доки виконується + // умова, і зникає сам, щойно вона перестала. + // + // Подієвий алерт (0058) такого доказу не дає. Рядок журналу стався + // один раз і «перестати ставатись» не може: алерт висить, поки його + // не закриє людина або строк auto_close. Повторювати за ним драбину + // означало б будити всю зміну по колу через один нічний блимок + // порту, який давно припинився. Тому подієвий алерт проходить + // драбину рівно раз — і на цьому ескалація закінчується. + if !s.IsEvent && s.RepeatAfterMin > 0 && s.RepeatIdx+1 <= s.MaxRepeats { + at := now.Add(time.Duration(s.RepeatAfterMin) * time.Minute) + if !at.Before(s.Deadline) { + d.Outcome = "done" + d.Detail = "драбину пройдено; повтор не вміщається у стелю життя" + d.NextStepIdx, d.NextRepeatIdx = s.StepIdx, s.RepeatIdx + return d + } + d.NextStepIdx = 0 + d.NextRepeatIdx = s.RepeatIdx + 1 + // Відлік нового проходу зсуваємо так, щоб перша сходинка + // припала рівно на «через repeat_after_min», а не на + // «repeat_after_min + after_min першої сходинки». + d.NextPassStart = at.Add(-time.Duration(s.Steps[0].AfterMin) * time.Minute) + d.NextAt = &at + return d + } + + d.Outcome = "done" + if s.IsEvent { + d.Detail = "драбину пройдено; подієвий алерт не повторюється" + } else { + d.Detail = "драбину пройдено повністю" + } + d.NextStepIdx, d.NextRepeatIdx = s.StepIdx, s.RepeatIdx + return d +} + +// --------------------------------------------------------------------- +// Читання й запис стану +// --------------------------------------------------------------------- + +// EscalationLease — на скільки сходинка вважається взятою в роботу. +// +// Взяття, рішення й запис — три кроки, і між ними процес може впасти. +// Оренда закриває найгіршу з двох дір: доки вона не спливла, другий +// інстанс тієї самої сходинки не візьме, тож дубля не буде. Друга діра +// (падіння між записом і надсиланням) коштує однієї недоставленої +// сходинки — це той самий свідомий вибір, що вже зроблено для черги +// подієвих алертів у 0058: «спробували» не дорівнює «доставили», і +// краще не надіслати, ніж надіслати вдруге о третій ночі. +const EscalationLease = 2 * time.Minute + +// TakeDueEscalations забирає сходинки, час яких настав. +// +// Наскрізно по всіх кабінетах і робочим пулом — так само, як черга +// подієвих алертів: движок один на інсталяцію й крутиться під +// advisory-блокуванням. +func (s *Store) TakeDueEscalations(ctx context.Context, limit int) ([]EscalationSnapshot, error) { + if limit <= 0 || limit > 500 { + limit = 100 + } + rows, err := s.bg.Query(ctx, ` + WITH due AS ( + SELECT alert_id FROM alr.alert_escalations + WHERE stopped_at IS NULL + AND next_at IS NOT NULL AND next_at <= now() + AND (leased_until IS NULL OR leased_until <= now()) + ORDER BY next_at + LIMIT $1 + FOR UPDATE SKIP LOCKED + ), taken AS ( + UPDATE alr.alert_escalations e + SET leased_until = now() + $2::interval + FROM due d WHERE e.alert_id = d.alert_id + RETURNING e.alert_id, e.tenant_id, e.policy_id, e.is_event, + e.step_idx, e.repeat_idx, e.pass_start, e.deadline + ) + SELECT t.alert_id::text, t.tenant_id::text, COALESCE(t.policy_id::text,''), + t.is_event, t.step_idx, t.repeat_idx, t.pass_start, t.deadline, + COALESCE(p.name,''), COALESCE(p.steps::text,'[]'), + COALESCE(p.repeat_after_min,0), COALESCE(p.max_repeats,0), + COALESCE(a.state::text,''), COALESCE(a.severity::text,'info'), + COALESCE(a.title,''), COALESCE(a.message,''), + COALESCE(a.device_id::text,''), COALESCE(d.name,''), + COALESCE(a.rule_id::text,''), COALESCE(a.suppressed_by,''), + COALESCE(a.started_at, t.pass_start) + FROM taken t + LEFT JOIN alr.alerts a ON a.id = t.alert_id + LEFT JOIN alr.escalation_policies p ON p.id = t.policy_id + LEFT JOIN inv.devices d ON d.id = a.device_id + ORDER BY t.pass_start + `, limit, EscalationLease.String()) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []EscalationSnapshot + for rows.Next() { + var s EscalationSnapshot + var steps string + if err := rows.Scan(&s.AlertID, &s.TenantID, &s.PolicyID, &s.IsEvent, + &s.StepIdx, &s.RepeatIdx, &s.PassStart, &s.Deadline, + &s.PolicyName, &steps, &s.RepeatAfterMin, &s.MaxRepeats, + &s.AlertState, &s.Alert.Severity, &s.Alert.Title, &s.Alert.Message, + &s.Alert.DeviceID, &s.Alert.DeviceName, &s.Alert.RuleID, + &s.Alert.SuppressedBy, &s.Alert.StartedAt); err != nil { + return nil, err + } + if err := json.Unmarshal([]byte(steps), &s.Steps); err != nil { + // Політика з нечитабельними сходинками не має валити чергу: + // решта драбин у кабінеті ні в чому не винна. + s.Steps = nil + } + s.Alert.ID = s.AlertID + s.Alert.TenantID = s.TenantID + s.Alert.State = s.AlertState + out = append(out, s) + } + return out, rows.Err() +} + +// ApplyEscalation записує рішення й веде журнал. +// +// Записується ДО надсилання. Порядок той самий, що в RecordNotification, +// і з тієї ж причини: падіння між записом і надсиланням лишає слід +// «сходинку пройдено» на недоставленому повідомленні, а зворотний +// порядок лишав би драбину на місці — і після підйому вона надіслала б +// те саме вдруге. +func (s *Store) ApplyEscalation(ctx context.Context, snap EscalationSnapshot, d EscalationDecision) error { + var next any + if d.NextAt != nil { + next = *d.NextAt + } + fired := d.Action == EscFire + + return s.InTenantTx(ctx, snap.TenantID, func(tx pgx.Tx) error { + if _, err := tx.Exec(ctx, ` + UPDATE alr.alert_escalations + SET step_idx = $2, + repeat_idx = $3, + pass_start = $4, + next_at = $5, + leased_until = NULL, + last_step_at = CASE WHEN $6 THEN now() ELSE last_step_at END, + stopped_at = CASE WHEN $5::timestamptz IS NULL THEN now() ELSE NULL END, + stop_reason = CASE WHEN $5::timestamptz IS NULL THEN $7 ELSE NULL END + WHERE alert_id = $1 + `, snap.AlertID, d.NextStepIdx, d.NextRepeatIdx, d.NextPassStart, + next, fired, d.Outcome); err != nil { + return err + } + _, err := tx.Exec(ctx, ` + INSERT INTO alr.escalation_steps + (tenant_id, alert_id, policy_id, step_idx, repeat_idx, outcome, detail) + VALUES ($1, $2, $3, $4, $5, $6, $7) + `, snap.TenantID, snap.AlertID, nullUUID(snap.PolicyID), + d.StepIdx, d.RepeatIdx, d.Outcome, nullString(d.Detail)) + return err + }) +} + +// AlertEscalation — стан драбини для картки алерту. +// +// Без цього ескалація перетворюється на невидиму магію: людину підняли +// о 03:10, а на екрані нічого не пояснює, звідки взявся дзвінок. Тут +// видно і те, скільки сходинок пройдено, і коли буде наступна, і — якщо +// драбину зупинено — чому саме. +type AlertEscalation struct { + PolicyName string `json:"policy_name"` + // Скільки сходинок цього проходу вже доставлено. + Step int `json:"step"` + // Скільки їх усього в драбині. + Total int `json:"total"` + Repeat int `json:"repeat"` + // Коли наступна. Порожньо — драбина зупинена або вичерпана. + NextAt *time.Time `json:"next_at,omitempty"` + StoppedAt *time.Time `json:"stopped_at,omitempty"` + StopReason string `json:"stop_reason,omitempty"` +} + +// PurgeEscalationLog прибирає журнал сходинок за строком. +// +// Той самий строк, що й у alr.notifications (0007). Таблиця не +// гіпертаблиця й політики ретеншену TimescaleDB не має, тож прибирає +// її прибиральник алертів. +func (s *Store) PurgeEscalationLog(ctx context.Context, olderThan time.Duration) (int64, error) { + tag, err := s.bg.Exec(ctx, + `DELETE FROM alr.escalation_steps WHERE ts < now() - $1::interval`, + olderThan.String()) + if err != nil { + return 0, err + } + return tag.RowsAffected(), nil +} + +// stopEscalationTx зупиняє драбину в межах уже відкритої транзакції. +// +// Викликається з підтвердження й ручного закриття алерту. Це НЕ той +// механізм, який гарантує зупинку: гарантує її перевірка стану перед +// кожною сходинкою в PlanEscalation, і саме там зупиняються драбини +// алертів, закритих шляхами, до яких цей код не дотягується (гасіння +// прострочених подієвих, закриття різницею множин, вимкнення правила). +// Тут — лише щоб картка алерту сказала «ескалацію зупинено» одразу, а +// не за півхвилини, коли настане час наступної сходинки. +func stopEscalationTx(ctx context.Context, tx pgx.Tx, tenantID, alertID, reason, detail string) error { + // Одним оператором, щоб журнал не міг розійтися зі станом: окремий + // SELECT після UPDATE записав би рядок і тоді, коли драбини вже не + // було, — тобто вигадав би подію. + _, err := tx.Exec(ctx, ` + WITH stopped AS ( + UPDATE alr.alert_escalations + SET next_at = NULL, leased_until = NULL, + stopped_at = now(), stop_reason = $3 + WHERE tenant_id = $1 AND alert_id = $2 AND stopped_at IS NULL + RETURNING policy_id, step_idx, repeat_idx + ) + INSERT INTO alr.escalation_steps + (tenant_id, alert_id, policy_id, step_idx, repeat_idx, outcome, detail) + SELECT $1, $2, policy_id, step_idx, repeat_idx, $3, $4 FROM stopped + `, tenantID, alertID, reason, nullString(detail)) + return err +} diff --git a/server/internal/store/alerts_escalation_db_test.go b/server/internal/store/alerts_escalation_db_test.go new file mode 100644 index 0000000..11a6a0c --- /dev/null +++ b/server/internal/store/alerts_escalation_db_test.go @@ -0,0 +1,238 @@ +package store + +import ( + "context" + "os" + "strings" + "testing" + "time" +) + +// Перевірка ескалації ПРОТИ БАЗИ. +// +// Чиста PlanEscalation покриває рішення, і це головна половина. Але є +// друга, яку рішенням не перевіриш узагалі: атомарність. «Сходинка не +// подвоїлась при перезапуску» — це властивість трьох запитів +// (взведення з ON CONFLICT, взяття з орендою, запис рішення), а не +// властивість функції. Тест, який їх не виконує, про них нічого не +// каже — скільки б зелених рядків він не давав. +// +// Мовчки пропускається без NETPULSE_TEST_DSN: `go test ./...` не має +// вимагати бази. Запускати треба на ОДНОРАЗОВІЙ базі — тест створює +// кабінет і видаляє його разом з усім вмістом: +// +// docker run --rm -d --name np-test -e POSTGRES_PASSWORD=x \ +// -e POSTGRES_DB=np timescale/timescaledb:2.17.2-pg16 +// NETPULSE_DSN=postgres://postgres:x@localhost/np go run ./cmd/netpulse-migrate +// NETPULSE_TEST_DSN=postgres://postgres:x@localhost/np \ +// go test ./internal/store/ -run EscalationAgainstDB -v +func TestEscalationAgainstDB(t *testing.T) { + dsn := os.Getenv("NETPULSE_TEST_DSN") + if dsn == "" { + t.Skip("NETPULSE_TEST_DSN не задано — перевірка проти бази пропускається") + } + ctx := context.Background() + + st, err := New(ctx, dsn) + if err != nil { + t.Fatalf("підключення: %v", err) + } + t.Cleanup(st.Close) + + slug := "esc-" + strings.ReplaceAll(time.Now().Format("150405.000"), ".", "") + var tenantID string + if err := st.pool.QueryRow(ctx, ` + INSERT INTO core.tenants (slug, name) VALUES ($1, $2) RETURNING id::text + `, slug, "Перевірка ескалацій").Scan(&tenantID); err != nil { + t.Fatalf("кабінет: %v", err) + } + t.Cleanup(func() { + _, _ = st.pool.Exec(context.Background(), + `DELETE FROM core.tenants WHERE id = $1`, tenantID) + }) + + var deviceID string + if err := st.pool.QueryRow(ctx, ` + INSERT INTO inv.devices (tenant_id, name, address, kind) + VALUES ($1, $2, '10.77.0.1', 'switch') RETURNING id::text + `, tenantID, slug+"-sw").Scan(&deviceID); err != nil { + t.Fatalf("хост: %v", err) + } + + policy := EscalationPolicy{ + Name: "Нічне чергування", + Steps: []EscalationStep{{AfterMin: 15, ChannelIDs: []string{"ch"}}, {AfterMin: 45, ChannelIDs: []string{"ch"}}}, + } + policyID, err := st.SaveEscalationPolicy(ctx, tenantID, "", policy) + if err != nil { + t.Fatalf("політика: %v", err) + } + + var ruleID string + if err := st.pool.QueryRow(ctx, ` + INSERT INTO alr.rules (tenant_id, name, source, severity, condition, escalation_policy_id) + VALUES ($1, 'Пінг', 'icmp', 'high', '{"metric":"loss_pct","op":">","value":20}'::jsonb, $2) + RETURNING id::text + `, tenantID, policyID).Scan(&ruleID); err != nil { + t.Fatalf("правило: %v", err) + } + + newAlert := func(key string) string { + t.Helper() + var id string + if err := st.pool.QueryRow(ctx, ` + INSERT INTO alr.alerts (tenant_id, rule_id, device_id, severity, state, + title, dedup_key, started_at, last_seen_at) + VALUES ($1, $2, $3, 'high', 'firing', 'ядро не відповідає', $4, + now() - interval '20 minutes', now()) + RETURNING id::text + `, tenantID, ruleID, deviceID, key).Scan(&id); err != nil { + t.Fatalf("алерт: %v", err) + } + return id + } + + started := time.Now().Add(-20 * time.Minute) + + // --- Взведення не подвоюється ------------------------------------- + // + // Найтонше місце всієї ескалації: повторна доставка того самого + // алерту (ретрай, другий інстанс, перезапуск між надсиланням і + // записом) не має перезапускати драбину з нуля. + a1 := newAlert(ruleID + ":dev:" + deviceID) + for i := 0; i < 3; i++ { + if err := st.ArmEscalation(ctx, tenantID, a1, policyID, false, policy, started); err != nil { + t.Fatalf("взведення %d: %v", i, err) + } + } + var rows int + if err := st.pool.QueryRow(ctx, + `SELECT count(*)::int FROM alr.alert_escalations WHERE alert_id = $1`, a1).Scan(&rows); err != nil { + t.Fatal(err) + } + if rows != 1 { + t.Fatalf("три взведення дали %d рядків драбини — драбина подвоїлась", rows) + } + + // --- Оренда не дає взяти сходинку двічі --------------------------- + due, err := st.TakeDueEscalations(ctx, 10) + if err != nil { + t.Fatalf("взяття сходинки: %v", err) + } + if len(due) != 1 { + t.Fatalf("очікували одну сходинку, отримали %d", len(due)) + } + if due[0].AlertState != "firing" || len(due[0].Steps) != 2 { + t.Fatalf("знімок прочитано неправильно: %+v", due[0]) + } + + again, err := st.TakeDueEscalations(ctx, 10) + if err != nil { + t.Fatal(err) + } + if len(again) != 0 { + t.Fatalf("та сама сходинка взята вдруге під орендою: %d", len(again)) + } + + // --- Запис рішення просуває драбину й веде журнал ------------------ + d := PlanEscalation(due[0], time.Now()) + if d.Action != EscFire { + t.Fatalf("сходинка мала спрацювати: %v/%s", d.Action, d.Outcome) + } + if err := st.ApplyEscalation(ctx, due[0], d); err != nil { + t.Fatalf("запис рішення: %v", err) + } + + var stepIdx int + var leased *time.Time + if err := st.pool.QueryRow(ctx, ` + SELECT step_idx, leased_until FROM alr.alert_escalations WHERE alert_id = $1 + `, a1).Scan(&stepIdx, &leased); err != nil { + t.Fatal(err) + } + if stepIdx != 1 { + t.Errorf("драбина не просунулась: step_idx = %d", stepIdx) + } + if leased != nil { + t.Error("оренда не знята після запису рішення") + } + + var logged int + if err := st.pool.QueryRow(ctx, + `SELECT count(*)::int FROM alr.escalation_steps WHERE alert_id = $1 AND outcome = 'sent'`, + a1).Scan(&logged); err != nil { + t.Fatal(err) + } + if logged != 1 { + t.Errorf("журнал сходинок порожній або задвоєний: %d", logged) + } + + // --- Підтвердження зупиняє драбину негайно ------------------------- + if _, err := st.AckAlert(ctx, tenantID, a1, "", "беру"); err != nil { + t.Fatalf("підтвердження: %v", err) + } + var stopReason string + var nextAt *time.Time + if err := st.pool.QueryRow(ctx, ` + SELECT COALESCE(stop_reason,''), next_at + FROM alr.alert_escalations WHERE alert_id = $1 + `, a1).Scan(&stopReason, &nextAt); err != nil { + t.Fatal(err) + } + if stopReason != "acked" { + t.Errorf("драбину не зупинено підтвердженням: причина %q", stopReason) + } + if nextAt != nil { + t.Error("у зупиненої драбини лишився час наступної сходинки") + } + + // І головне: зупинена драбина більше не потрапляє в чергу. + if _, err := st.pool.Exec(ctx, + `UPDATE alr.alert_escalations SET leased_until = NULL WHERE alert_id = $1`, a1); err != nil { + t.Fatal(err) + } + rest, err := st.TakeDueEscalations(ctx, 10) + if err != nil { + t.Fatal(err) + } + for _, r := range rest { + if r.AlertID == a1 { + t.Fatal("підтверджений алерт повернувся в чергу ескалації") + } + } + + // --- Закритий алерт: сходинка не спрацьовує навіть якщо настала ---- + a2 := newAlert(ruleID + ":dev:" + deviceID + ":2") + if err := st.ArmEscalation(ctx, tenantID, a2, policyID, false, policy, started); err != nil { + t.Fatal(err) + } + if err := st.CloseAlert(ctx, tenantID, a2); err != nil { + t.Fatalf("закриття: %v", err) + } + if _, err := st.pool.Exec(ctx, ` + UPDATE alr.alert_escalations + SET stopped_at = NULL, stop_reason = NULL, next_at = now() - interval '1 minute' + WHERE alert_id = $1 + `, a2); err != nil { + t.Fatal(err) + } + // Драбину штучно «воскресили» повз зупинку — рішення все одно має + // зупинити її, бо стан алерту читається перед кожною сходинкою. + dueClosed, err := st.TakeDueEscalations(ctx, 10) + if err != nil { + t.Fatal(err) + } + var found bool + for _, s := range dueClosed { + if s.AlertID != a2 { + continue + } + found = true + if got := PlanEscalation(s, time.Now()); got.Action != EscStop || got.Outcome != "closed" { + t.Fatalf("закритий алерт: %v/%s", got.Action, got.Outcome) + } + } + if !found { + t.Fatal("тест не дістав сходинку закритого алерту — перевірка нічого не довела") + } +} diff --git a/server/internal/store/alerts_escalation_test.go b/server/internal/store/alerts_escalation_test.go new file mode 100644 index 0000000..8daa653 --- /dev/null +++ b/server/internal/store/alerts_escalation_test.go @@ -0,0 +1,484 @@ +package store + +import ( + "strings" + "testing" + "time" +) + +// Тести написані навколо однієї думки: зелений тест доводить рівно те, +// що він перевіряє. «Сходинка спрацювала» — найлегший і найменш цінний +// із можливих тестів ескалації, бо він нічого не каже про другу +// половину роботи. А друга половина — НЕ будити: підтверджений алерт, +// закритий алерт, заглушений хост, вікно обслуговування, протухла +// драбина. Кожен із цих випадків коштує чийогось сну (або чиєїсь +// аварії, яку ніхто не побачив), і кожен перевіряється нижче окремо. + +// ladder — драбина «через 15 хв — черговий, через 45 — керівник». +func ladder() []EscalationStep { + return []EscalationStep{ + {AfterMin: 15, ChannelIDs: []string{"ch-duty"}}, + {AfterMin: 45, ChannelIDs: []string{"ch-lead"}}, + } +} + +// snap збирає знімок так, як його віддала б база. +func snap(state string, stepIdx int, mods ...func(*EscalationSnapshot)) EscalationSnapshot { + start := time.Date(2026, 8, 15, 2, 40, 0, 0, time.UTC) + s := EscalationSnapshot{ + AlertID: "a1", + TenantID: "t1", + PolicyID: "p1", + PolicyName: "Нічне чергування", + StepIdx: stepIdx, + PassStart: start, + Deadline: start.Add(48 * time.Hour), + Steps: ladder(), + AlertState: state, + Alert: Alert{ID: "a1", State: state, Severity: "high", StartedAt: start}, + } + for _, m := range mods { + m(&s) + } + return s +} + +func at(base time.Time, min int) time.Time { + return base.Add(time.Duration(min) * time.Minute) +} + +// --------------------------------------------------------------------- +// Контроль: без цього тесту решта нічого не доводить +// --------------------------------------------------------------------- + +// Ніхто не підтвердив за 15 хвилин — будимо чергового. +func TestEscalationFiresWhenNobodyAcked(t *testing.T) { + s := snap("firing", 0) + d := PlanEscalation(s, at(s.PassStart, 15)) + + if d.Action != EscFire { + t.Fatalf("сходинка мала спрацювати, отримали %v (%s)", d.Action, d.Outcome) + } + if len(d.ChannelIDs) != 1 || d.ChannelIDs[0] != "ch-duty" { + t.Fatalf("не ті канали: %v", d.ChannelIDs) + } + if d.NextAt == nil { + t.Fatal("після першої сходинки має бути запланована друга") + } + if d.NextStepIdx != 1 { + t.Errorf("наступна сходинка мала стати 1, отримали %d", d.NextStepIdx) + } + if want := at(s.PassStart, 45); !d.NextAt.Equal(want) { + t.Errorf("друга сходинка о %s, очікували %s", d.NextAt, want) + } +} + +// --------------------------------------------------------------------- +// Друга половина: коли сходинка НЕ має спрацювати +// --------------------------------------------------------------------- + +// Підтвердження зупиняє драбину. Це вся суть кнопки «Прийняти»: людина +// вже дивиться на аварію, і дзвінок керівникові за 15 хвилин повідомить +// його рівно про те, що вона й так робить. +func TestEscalationStopsOnAcknowledged(t *testing.T) { + d := PlanEscalation(snap("acknowledged", 0), time.Now()) + + if d.Action != EscStop { + t.Fatalf("підтверджений алерт не має ескалювати, отримали %v", d.Action) + } + if d.Outcome != "acked" { + t.Errorf("причина %q, очікували acked", d.Outcome) + } + if len(d.ChannelIDs) != 0 { + t.Errorf("зупинена драбина не має каналів, отримали %v", d.ChannelIDs) + } + if d.NextAt != nil { + t.Error("після зупинки наступної сходинки бути не може") + } +} + +// Закритий алерт ескалація воскресити не може. +// +// Найдорожча помилка з можливих: аварію полагодили о 02:45, а о 03:10 +// драбина піднімає керівника через алерт, якого вже немає. Саме тому +// стан читається перед КОЖНОЮ сходинкою, а не один раз при взведенні. +func TestEscalationNeverResurrectsClosedAlert(t *testing.T) { + for _, st := range []string{"resolved", "expired", ""} { + d := PlanEscalation(snap(st, 0), time.Now()) + if d.Action != EscStop || d.Outcome != "closed" { + t.Errorf("стан %q: отримали %v/%s, очікували зупинку closed", st, d.Action, d.Outcome) + } + } +} + +// Те саме, але на СЕРЕДИНІ драбини — окремим тестом навмисно. +// +// Перевірка «на початку» проходить і в реалізації, яка дивиться на стан +// лише при взведенні. Ця — ні. +func TestEscalationChecksStateBeforeEveryStep(t *testing.T) { + // Перша сходинка вже пішла (step_idx = 1), алерт тим часом закрили. + d := PlanEscalation(snap("resolved", 1), time.Now()) + if d.Action != EscStop || d.Outcome != "closed" { + t.Fatalf("друга сходинка закритого алерту: %v/%s", d.Action, d.Outcome) + } + + // І те саме для підтвердження на середині драбини. + d = PlanEscalation(snap("acknowledged", 1), time.Now()) + if d.Action != EscStop || d.Outcome != "acked" { + t.Fatalf("друга сходинка підтвердженого алерту: %v/%s", d.Action, d.Outcome) + } +} + +// Заглушений хост і вікно обслуговування зупиняють сходинку так само, +// як звичайне сповіщення. +func TestEscalationDoesNotFireWhileSuppressed(t *testing.T) { + for _, why := range []string{"mute", "maintenance", "topology"} { + s := snap("suppressed", 0, func(s *EscalationSnapshot) { + s.Alert.SuppressedBy = why + }) + d := PlanEscalation(s, at(s.PassStart, 15)) + + if d.Action == EscFire { + t.Fatalf("%s: придушений алерт розбудив людину", why) + } + if d.Action != EscDefer || d.Outcome != "suppressed" { + t.Fatalf("%s: отримали %v/%s", why, d.Action, d.Outcome) + } + if len(d.ChannelIDs) != 0 { + t.Errorf("%s: у придушеної сходинки з'явились канали %v", why, d.ChannelIDs) + } + if !strings.Contains(d.Detail, why) { + t.Errorf("%s: причина придушення не потрапила в журнал: %q", why, d.Detail) + } + } +} + +// Придушення не ВИТРАЧАЄ сходинку. +// +// Різниця принципова й непомітна на тесті «не надіслали»: якщо сходинку +// списати, півгодинне вікно обслуговування тихо роззброїть драбину до +// кінця життя алерту — тобто дасть рівно ту мовчазну відмову, від якої +// ескалація й рятує. +func TestSuppressionDefersStepInsteadOfSpendingIt(t *testing.T) { + s := snap("suppressed", 0) + now := at(s.PassStart, 15) + d := PlanEscalation(s, now) + + if d.NextStepIdx != 0 { + t.Fatalf("сходинку списано попри придушення: наступна %d", d.NextStepIdx) + } + if d.NextAt == nil { + t.Fatal("відкладена драбина має отримати час наступної перевірки") + } + if !d.NextAt.After(now) { + t.Errorf("перевірку призначено не в майбутнє: %s", d.NextAt) + } + + // Вікно скінчилось — сходинка, що чекала, спрацьовує тією самою. + back := snap("firing", d.NextStepIdx) + fired := PlanEscalation(back, *d.NextAt) + if fired.Action != EscFire || fired.StepIdx != 0 { + t.Fatalf("після вікна мала піти та сама перша сходинка, отримали %v/%d", + fired.Action, fired.StepIdx) + } +} + +// Вічно заглушений алерт не має ходити по колу вічно. +func TestSuppressionStopsAtDeadline(t *testing.T) { + s := snap("suppressed", 0, func(s *EscalationSnapshot) { + s.Deadline = s.PassStart.Add(20 * time.Minute) + }) + d := PlanEscalation(s, at(s.PassStart, 19)) + + if d.Action != EscStop || d.Outcome != "deadline" { + t.Fatalf("отримали %v/%s, очікували зупинку за стелею", d.Action, d.Outcome) + } +} + +// Протухла драбина не будить нікого, навіть якщо алерт ще горить. +func TestEscalationStopsAtDeadline(t *testing.T) { + s := snap("firing", 0, func(s *EscalationSnapshot) { + s.Deadline = s.PassStart.Add(10 * time.Minute) + }) + d := PlanEscalation(s, at(s.PassStart, 15)) + + if d.Action != EscStop || d.Outcome != "deadline" { + t.Fatalf("отримали %v/%s", d.Action, d.Outcome) + } +} + +// Політику видалили, поки драбина йшла. +// +// Мовчки добивати за старою копією не можна: драбини, якої вже немає у +// формі, ніхто не знайде, коли питатиме «звідки дзвінок». +func TestEscalationStopsWhenPolicyDisappeared(t *testing.T) { + gone := snap("firing", 0, func(s *EscalationSnapshot) { s.Steps = nil }) + if d := PlanEscalation(gone, time.Now()); d.Action != EscStop || d.Outcome != "no_policy" { + t.Fatalf("зникла політика: %v/%s", d.Action, d.Outcome) + } + + // Політику скоротили — сходинки з таким номером більше немає. + short := snap("firing", 5) + if d := PlanEscalation(short, time.Now()); d.Action != EscStop || d.Outcome != "no_policy" { + t.Fatalf("скорочена політика: %v/%s", d.Action, d.Outcome) + } +} + +// --------------------------------------------------------------------- +// Перезапуск процесу +// --------------------------------------------------------------------- + +// Рішення залежить ВИКЛЮЧНО від рядка в базі, а не від чогось у пам'яті. +// +// Це і є перевірка на перезапуск: «процес підняли заново» означає, що +// той самий знімок читається вдруге, і сходинка не має ані загубитись, +// ані піти двічі. Загубитись — бо рішення однакове; піти двічі — бо +// після запису стану знімок уже інший, і та сама сходинка з нього не +// відтворюється. +func TestEscalationSurvivesRestartMidLadder(t *testing.T) { + before := snap("firing", 0) + now := at(before.PassStart, 15) + + first := PlanEscalation(before, now) + if first.Action != EscFire { + t.Fatal("перша сходинка мала спрацювати") + } + + // Процес упав і піднявся, стан прочитано з бази заново — рішення те + // саме, бо в пам'яті нічого не було. + again := PlanEscalation(before, now) + if again.Action != first.Action || again.StepIdx != first.StepIdx || + !equalTimes(again.NextAt, first.NextAt) { + t.Fatalf("рішення залежить від пам'яті процесу: %+v проти %+v", again, first) + } + + // А після того, як стан записано (ApplyEscalation), знімок уже + // інший — і перша сходинка з нього не повториться. + after := snap("firing", first.NextStepIdx, func(s *EscalationSnapshot) { + s.RepeatIdx = first.NextRepeatIdx + s.PassStart = first.NextPassStart + }) + second := PlanEscalation(after, *first.NextAt) + if second.Action != EscFire { + t.Fatalf("друга сходинка не спрацювала: %v/%s", second.Action, second.Outcome) + } + if second.StepIdx == first.StepIdx { + t.Fatal("після перезапуску повторилась та сама сходинка") + } + if second.ChannelIDs[0] != "ch-lead" { + t.Errorf("друга сходинка пішла не тому: %v", second.ChannelIDs) + } +} + +func equalTimes(a, b *time.Time) bool { + if a == nil || b == nil { + return a == b + } + return a.Equal(*b) +} + +// Драбина, яка чекала кінця вікна обслуговування, не має висипатись +// уся за одну хвилину: інакше після паузи будяться одразу всі. +func TestDelayedLadderKeepsSpacing(t *testing.T) { + s := snap("firing", 0) + // Перша сходинка спрацювала із запізненням на дві години. + late := at(s.PassStart, 135) + d := PlanEscalation(s, late) + + if d.Action != EscFire || d.NextAt == nil { + t.Fatalf("очікували спрацювання з наступною сходинкою, отримали %v", d.Action) + } + // Проміжок між сходинками — 30 хв (15 → 45), він і має зберегтися. + if got := d.NextAt.Sub(late); got < 30*time.Minute { + t.Fatalf("наступна сходинка через %s після спізнілої — проміжок з'їдено", got) + } +} + +// --------------------------------------------------------------------- +// Повтори й подієві алерти +// --------------------------------------------------------------------- + +// Метричний алерт: драбину можна пройти заново, бо саме існування +// алерту доводить, що умова досі виконується. +func TestMetricAlertRepeatsLadder(t *testing.T) { + s := snap("firing", 1, func(s *EscalationSnapshot) { + s.RepeatAfterMin = 60 + s.MaxRepeats = 2 + }) + d := PlanEscalation(s, at(s.PassStart, 45)) + + if d.Action != EscFire { + t.Fatalf("остання сходинка мала спрацювати: %v", d.Action) + } + if d.NextAt == nil { + t.Fatal("після вичерпання драбини мав бути запланований повтор") + } + if d.NextRepeatIdx != 1 || d.NextStepIdx != 0 { + t.Fatalf("повтор має починати драбину спочатку: крок %d, повтор %d", + d.NextStepIdx, d.NextRepeatIdx) + } + // Повтор «через 60 хв» означає рівно 60, а не 60 + 15 хвилин першої + // сходинки: зсув відліку проходу має це врахувати. + want := at(s.PassStart, 45).Add(60 * time.Minute) + if !d.NextAt.Equal(want) { + t.Errorf("повтор о %s, очікували %s", d.NextAt, want) + } + if got := d.NextPassStart.Add(15 * time.Minute); !got.Equal(*d.NextAt) { + t.Errorf("відлік нового проходу зсунуто неправильно: %s", got) + } +} + +// Стеля повторів дотримується. +func TestRepeatsStopAtMaxRepeats(t *testing.T) { + s := snap("firing", 1, func(s *EscalationSnapshot) { + s.RepeatAfterMin = 60 + s.MaxRepeats = 2 + s.RepeatIdx = 2 // два повтори вже зроблено + }) + d := PlanEscalation(s, at(s.PassStart, 45)) + + if d.Action != EscFire { + t.Fatalf("остання сходинка мала піти: %v", d.Action) + } + if d.NextAt != nil { + t.Fatal("після вичерпання повторів драбина має зупинитись") + } + if d.Outcome != "done" { + t.Errorf("причина %q, очікували done", d.Outcome) + } +} + +// Подієвий алерт (0058) проходить драбину рівно раз. +// +// Причина не в економії повідомлень. Метричний алерт живий рівно доти, +// доки виконується умова, і зникає сам — тому повтор є ставкою на те, +// що проблема триває. Подієвий алерт такого доказу не дає: рядок журналу +// стався один раз і «перестати ставатись» не може. Повторювати за ним +// драбину означало б будити всю зміну по колу через нічний блимок порту, +// який давно припинився. +func TestEventAlertLadderRunsOnce(t *testing.T) { + s := snap("firing", 1, func(s *EscalationSnapshot) { + s.IsEvent = true + s.RepeatAfterMin = 60 + s.MaxRepeats = 3 + }) + d := PlanEscalation(s, at(s.PassStart, 45)) + + if d.Action != EscFire { + t.Fatalf("остання сходинка подієвого алерту мала піти: %v", d.Action) + } + if d.NextAt != nil { + t.Fatal("подієвий алерт не повторює драбину — повтор усе одно заплановано") + } + if !strings.Contains(d.Detail, "подієв") { + t.Errorf("журнал не пояснює, чому повтору немає: %q", d.Detail) + } +} + +// Але сходинки в межах одного проходу подієвий алерт проходить як усі: +// «конфіг змінився на ядрі й ніхто не глянув» має піднімати наступного. +func TestEventAlertStillWalksItsLadder(t *testing.T) { + s := snap("firing", 0, func(s *EscalationSnapshot) { s.IsEvent = true }) + d := PlanEscalation(s, at(s.PassStart, 15)) + + if d.Action != EscFire || d.NextStepIdx != 1 || d.NextAt == nil { + t.Fatalf("подієвий алерт має ескалюватись у межах проходу: %+v", d) + } +} + +// Повтор, який не вміщається у стелю життя, не планується. +func TestRepeatNotScheduledPastDeadline(t *testing.T) { + s := snap("firing", 1, func(s *EscalationSnapshot) { + s.RepeatAfterMin = 60 + s.MaxRepeats = 3 + s.Deadline = s.PassStart.Add(50 * time.Minute) + }) + d := PlanEscalation(s, at(s.PassStart, 45)) + + if d.Action != EscFire { + t.Fatalf("сходинка мала піти: %v", d.Action) + } + if d.NextAt != nil { + t.Fatal("повтор за межами стелі життя не має плануватись") + } +} + +// --------------------------------------------------------------------- +// Перевірка драбини при збереженні +// --------------------------------------------------------------------- + +func TestValidateEscalationStepsRejectsBrokenLadders(t *testing.T) { + cases := []struct { + name string + steps []EscalationStep + want string + }{ + {"порожня", nil, "жодної сходинки"}, + { + "нульова хвилина", + []EscalationStep{{AfterMin: 0, ChannelIDs: []string{"c"}}}, + "продублює", + }, + { + "не зростає", + []EscalationStep{ + {AfterMin: 30, ChannelIDs: []string{"c"}}, + {AfterMin: 30, ChannelIDs: []string{"c"}}, + }, + "вгору", + }, + { + "сходинка без каналів", + []EscalationStep{{AfterMin: 15}}, + "жодного каналу", + }, + { + "надто пізно", + []EscalationStep{{AfterMin: 5000, ChannelIDs: []string{"c"}}}, + "нагадування", + }, + } + for _, c := range cases { + err := ValidateEscalationSteps(c.steps) + if err == nil { + t.Errorf("%s: драбину прийнято, а мало бути відхилено", c.name) + continue + } + if !strings.Contains(err.Error(), c.want) { + t.Errorf("%s: пояснення %q не містить %q", c.name, err, c.want) + } + } +} + +func TestValidateEscalationStepsRejectsTooLongLadder(t *testing.T) { + var steps []EscalationStep + for i := 1; i <= MaxEscalationSteps+1; i++ { + steps = append(steps, EscalationStep{AfterMin: i * 5, ChannelIDs: []string{"c"}}) + } + if err := ValidateEscalationSteps(steps); err == nil { + t.Fatal("драбина понад стелю має відхилятись") + } +} + +func TestValidateEscalationStepsAcceptsSaneLadder(t *testing.T) { + if err := ValidateEscalationSteps(ladder()); err != nil { + t.Fatalf("нормальну драбину відхилено: %v", err) + } +} + +// Стеля життя драбини має накривати саму драбину з усіма повторами — +// інакше вона обривала б ескалацію раніше, ніж та встигла спрацювати. +func TestEscalationSpanCoversRepeats(t *testing.T) { + p := EscalationPolicy{Steps: ladder(), RepeatAfterMin: 60, MaxRepeats: 2} + + withRepeats := escalationSpan(p, false) + if withRepeats <= 45*time.Minute { + t.Fatalf("стеля %s не накриває навіть один прохід із повторами", withRepeats) + } + + // Подієвий алерт повторів не має — і стеля в нього коротша. + if ev := escalationSpan(p, true); ev >= withRepeats { + t.Fatalf("подієвий алерт не повторюється, а стеля така сама: %s проти %s", + ev, withRepeats) + } +} diff --git a/server/internal/store/alerts_events.go b/server/internal/store/alerts_events.go new file mode 100644 index 0000000..6a80ead --- /dev/null +++ b/server/internal/store/alerts_events.go @@ -0,0 +1,579 @@ +package store + +import ( + "context" + "encoding/json" + "fmt" + "net" + "regexp" + "strings" + + "github.com/jackc/pgx/v5" +) + +// --------------------------------------------------------------------- +// Класифікація джерел +// --------------------------------------------------------------------- + +// Джерела поділені не за темою, а за тим, звідки береться факт. +// +// Опитуване джерело має ряд вимірів: движок щотіку перепитує його й +// може відповісти і «так», і «ні». Подієве джерело ряду не має — є +// момент, коли щось сталося, і більше нічого. Уся різниця в поведінці +// алерту (як він гасне, як дедуплікується, чи можна його «не знайти») +// випливає саме звідси. +var ( + polledSources = map[string]bool{ + "metric": true, "icmp": true, "interface": true, + } + eventSources = map[string]bool{ + "syslog": true, "ncm": true, "compliance": true, "trap": true, + } +) + +// IsEventSource — чи обробляється джерело в момент надходження події. +func IsEventSource(source string) bool { return eventSources[source] } + +// UnsupportedSourceReason пояснює людині, чому джерело не працює. +// +// Порожній рядок означає «працює». Текст тут, а не в HTTP-шарі, бо ту +// саму відповідь має дати і збереження тригера шаблону: два різні +// пояснення тієї самої відмови розходяться на першій же правці. +func UnsupportedSourceReason(source string) string { + if polledSources[source] || eventSources[source] { + return "" + } + switch source { + case "link": + return "лінк на мапі не має власних вимірів — він живий рівно настільки, " + + "наскільки живі його кінці. Заведіть правило на пристрої або на інтерфейс" + case "agent": + return "«зонд не на звʼязку» — це стан, а не подія; він рахується опитуванням. " + + "Скористайтесь правилом «Пінг» з метрикою «Даних немає взагалі»" + default: + return "невідоме джерело правила" + } +} + +// --------------------------------------------------------------------- +// Перевірка умови при збереженні +// --------------------------------------------------------------------- + +// ValidateRuleCondition відмовляє у правилі, яке ніколи не спрацює. +// +// Перевірка стоїть на записі, а не на обчисленні, з тієї ж причини, що +// й у правил відповідності: про друкарську помилку в регулярному виразі +// людина має дізнатися з форми, а не з тригера, який рік мовчав. +func ValidateRuleCondition(source string, raw []byte) error { + if reason := UnsupportedSourceReason(source); reason != "" { + return fmt.Errorf("%w: %s", ErrInvalid, reason) + } + if !eventSources[source] { + return nil + } + + var c Condition + if len(raw) > 0 { + if err := json.Unmarshal(raw, &c); err != nil { + return fmt.Errorf("%w: умова не читається як JSON: %v", ErrInvalid, err) + } + } + + switch source { + case "syslog": + if strings.TrimSpace(c.Regex) == "" { + return fmt.Errorf("%w: правило на журнал без зразка підпало б під кожен рядок "+ + "— задайте, що саме шукати", ErrInvalid) + } + if _, err := regexp.Compile(c.Regex); err != nil { + return fmt.Errorf("%w: зразок не компілюється: %v", ErrInvalid, err) + } + if c.SeverityLTE != nil && (*c.SeverityLTE < 0 || *c.SeverityLTE > 7) { + return fmt.Errorf("%w: рівень syslog буває від 0 (emerg) до 7 (debug)", ErrInvalid) + } + case "ncm": + switch c.Event { + case "changed", "backup_failed": + case "": + return fmt.Errorf("%w: не вказано подію конфігу: changed або backup_failed", ErrInvalid) + default: + return fmt.Errorf("%w: невідома подія конфігу %q: буває changed або backup_failed", + ErrInvalid, c.Event) + } + case "compliance": + if c.Event != "" && c.Event != "violation" { + return fmt.Errorf("%w: для відповідності є лише подія violation", ErrInvalid) + } + if c.MinSeverity != "" && !validSeverity[c.MinSeverity] { + return fmt.Errorf("%w: невідома серйозність %q", ErrInvalid, c.MinSeverity) + } + case "trap": + return validateTrapCondition(c) + } + return nil +} + +// validateTrapCondition перевіряє умову правила на трапи. +// +// Головна відповідальність цієї функції — не пропустити правило, яке +// виглядатиме працюючим. 0058 з'явився саме через таке: джерело `trap` +// зберігалось мовчки, показувалось увімкненим і не спрацьовувало +// ніколи. Повернути джерело й лишити хоч одну мовчазну гілку означало б +// повторити ту саму помилку в дрібнішому масштабі, а це гірше — дрібну +// довше не помічають. +// +// Тому кожна відмова тут не просто відмовляє, а каже, що робити далі. +func validateTrapCondition(c Condition) error { + // Поля чужого джерела в умові — не дрібниця. Людина, яка + // переключила джерело правила з «Syslog» на «Трапи» й лишила в + // формі зразок, має дізнатись, що зразок більше не діє. Мовчазне + // ігнорування дало б правило, яке ловить УСІ трапи замість тих, що + // підпадають під зразок. + if strings.TrimSpace(c.Regex) != "" { + return fmt.Errorf("%w: зразок (regex) до трапів не застосовується — трап це не рядок "+ + "тексту, а набір типізованих полів. Задайте OID трапа, а за потреби "+ + "звузьте його конкретним varbind-ом", ErrInvalid) + } + if c.SeverityLTE != nil { + return fmt.Errorf("%w: у трапа немає рівня severity — його має syslog. Серйозність "+ + "алерту задається самим правилом", ErrInvalid) + } + if strings.TrimSpace(c.Tag) != "" { + return fmt.Errorf("%w: тега у трапа немає; те, «що саме сталося», задається OID трапа", + ErrInvalid) + } + + oid := NormalizeOID(c.TrapOID) + src := strings.TrimSpace(c.SourceIP) + if oid == "" && src == "" { + // Правило без жодного звуження підпадає під КОЖЕН трап у + // кабінеті. Формально воно робоче, практично — це спосіб + // отримати алерт на кожен linkUp кожного порту й вимкнути + // сповіщення назавжди через тиждень. + return fmt.Errorf("%w: правило без OID трапа й без адреси джерела підпало б під "+ + "кожен трап у мережі — вкажіть, що саме ловимо", ErrInvalid) + } + if oid != "" && !ValidOID(oid) { + return fmt.Errorf("%w: %q не схоже на OID. Очікуються числа через крапку "+ + "(наприклад 1.3.6.1.6.3.1.1.5.3 — linkDown); назву трапа зі свого словника "+ + "теж треба вказувати її OID-ом", ErrInvalid, c.TrapOID) + } + if src != "" && !validIPOrCIDR(src) { + return fmt.Errorf("%w: %q не схоже на адресу або підмережу (10.20.0.5 чи "+ + "10.20.0.0/24)", ErrInvalid, c.SourceIP) + } + + vbOID := NormalizeOID(c.VarbindOID) + if vbOID != "" && !ValidOID(vbOID) { + return fmt.Errorf("%w: %q не схоже на OID varbind-а", ErrInvalid, c.VarbindOID) + } + if strings.TrimSpace(c.VarbindValue) != "" && vbOID == "" { + // Порівнювати значення, не сказавши якого поля, ніде: у трапі + // їх десяток. Умова «будь-який varbind дорівнює 2» зривалась би + // на кожному другому трапі й виглядала б при цьому осмисленою. + return fmt.Errorf("%w: вказано значення varbind-а, але не вказано, якого саме. "+ + "Додайте OID varbind-а — наприклад 1.3.6.1.2.1.2.2.1.1 (ifIndex)", ErrInvalid) + } + return nil +} + +// validIPOrCIDR — поверхнева перевірка адреси джерела. +// +// Стоїть на записі, а не на спрацюванні, з тієї ж причини, що й +// перевірка регулярного виразу: про описку в адресі людина має +// дізнатись із форми, а не з правила, яке рік мовчало. +func validIPOrCIDR(s string) bool { + if _, _, err := net.ParseCIDR(s); err == nil { + return true + } + return net.ParseIP(s) != nil +} + +var validSeverity = map[string]bool{ + "info": true, "warning": true, "average": true, "high": true, "disaster": true, +} + +// SeverityRank — порядок серйозності, той самий, що в alr.severity. +func SeverityRank(s string) int { + switch s { + case "warning": + return 1 + case "average": + return 2 + case "high": + return 3 + case "disaster": + return 4 + default: + return 0 + } +} + +// --------------------------------------------------------------------- +// Читання подієвих правил +// --------------------------------------------------------------------- + +// EventRules читає увімкнені подієві правила одного тенанта. +// +// Окремо від ActiveRules навмисно: та вибірка наскрізна по всіх +// тенантах і робиться раз на тік одним процесом, а ця — гаряча. Її +// смикає приймач журналу, тобто найчастіший шлях у системі, і вона має +// віддавати рівно правила одного кабінету, щоб їх можна було закешувати +// поруч із його ж списком хостів. +func (s *Store) EventRules(ctx context.Context, tenantID string) ([]Rule, error) { + // Тенантна транзакція, хоча предикат r.tenant_id = $1 у запиті вже + // стоїть: alr.rules під tenant_isolation, і без app.tenant_id + // вибірка порожня. Наслідок був би тихий і найгірший з можливих — + // подієві алерти просто перестали б заводитись, а сторінка алертів + // виглядала б як «усе спокійно». + var out []Rule + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, ` + SELECT r.id::text, r.name, r.source::text, r.severity::text, + r.selector::text, r.condition::text, + r.auto_close_seconds, r.min_interval_seconds + FROM alr.rules r + JOIN core.tenants t ON t.id = r.tenant_id + WHERE r.tenant_id = $1 + AND r.enabled + AND r.source IN ('syslog','ncm','compliance','trap') + AND t.status NOT IN ('suspended','cancelled') + ORDER BY r.name + `, tenantID) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + r := Rule{TenantID: tenantID} + var sel, cond string + if err := rows.Scan(&r.ID, &r.Name, &r.Source, &r.Severity, &sel, &cond, + &r.AutoCloseSeconds, &r.MinIntervalSeconds); err != nil { + return err + } + if err := json.Unmarshal([]byte(sel), &r.Selector); err != nil { + return fmt.Errorf("правило %s: selector: %w", r.Name, err) + } + if err := json.Unmarshal([]byte(cond), &r.Condition); err != nil { + return fmt.Errorf("правило %s: condition: %w", r.Name, err) + } + out = append(out, r) + } + return rows.Err() + }) + if err != nil { + return nil, err + } + return out, nil +} + +// SelectorDevices розгортає селектор правила у перелік хостів. +// +// Подієвий шлях не може перевіряти належність хоста до селектора +// запитом на кожну подію: рядків журналу за секунду більше, ніж хостів +// у кабінеті. Тому селектор розгортається один раз і живе в кеші поруч +// із правилами — ціною того, що щойно доданий хост підпадає під правило +// не миттєво, а з наступним оновленням кешу. +func (s *Store) SelectorDevices(ctx context.Context, tenantID string, sel Selector) ([]string, error) { + var out []string + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + ids, err := s.resolveSelectorDevices(ctx, tx, tenantID, sel) + out = ids + return err + }) + return out, err +} + +// --------------------------------------------------------------------- +// Підняття подієвого алерту +// --------------------------------------------------------------------- + +// EventAlert — подія, яка вже визнана такою, що підпадає під правило. +type EventAlert struct { + RuleID string + DeviceID string + DeviceName string + Severity string + Title string + Message string + DedupKey string + Context json.RawMessage + // Скільки однакових подій злилося в це звернення. Одиниця — звичайна + // подія; більше — пачка, зведена ще до звернення до бази. + Count int + SuppressedBy string +} + +// RaiseEventAlert створює подієвий алерт або доливає подію в наявний. +// +// Дедуплікація — той самий унікальний індекс, що й у метричних алертів, +// і це не економія коду. Ключ навмисно не містить нічого від самої +// події: один алерт на пару «правило + хост» незалежно від того, чи +// прийшов один рядок журналу, чи чотири тисячі. Інакше перший же +// мигаючий порт зробив би дошку алертів нечитабельною за хвилину, а +// саме дошка — те, заради чого все це існує. +// +// Ціна такого рішення чесна й видима: у алерті лишається останній текст +// і лічильник подій, а не весь їхній перелік. Перелік є в журналі, і +// шукати його треба там. +func (s *Store) RaiseEventAlert(ctx context.Context, tenantID string, e EventAlert) (FiredAlert, error) { + var out FiredAlert + ctxJSON := "{}" + if len(e.Context) > 0 { + ctxJSON = string(e.Context) + } + count := e.Count + if count < 1 { + count = 1 + } + + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + return tx.QueryRow(ctx, ` + WITH prev AS ( + SELECT id, state::text AS old_state + FROM alr.alerts + WHERE tenant_id = $1 AND dedup_key = $6 + AND state IN ('firing','acknowledged','suppressed') + ), ups AS ( + INSERT INTO alr.alerts + (tenant_id, rule_id, device_id, severity, state, title, message, + dedup_key, context, suppressed_by, event_count, notify_pending, + started_at, last_seen_at) + VALUES ($1, $2, $3, $4::alr.severity, + CASE WHEN $8::text IS NULL THEN 'firing' ELSE 'suppressed' END::alr.alert_state, + $5, $9, $6, $7::jsonb, $8, $10, + -- Придушений алерт не ставиться в чергу на + -- розсилку: заглушення означає «не турбувати», + -- а не «покажи пізніше». + $8::text IS NULL, + now(), now()) + ON CONFLICT (tenant_id, dedup_key) + WHERE state IN ('firing','acknowledged','suppressed') + DO UPDATE SET + last_seen_at = now(), + event_count = alr.alerts.event_count + EXCLUDED.event_count, + message = EXCLUDED.message, + context = EXCLUDED.context, + suppressed_by = $8, + state = CASE + WHEN alr.alerts.state = 'acknowledged' THEN 'acknowledged' + WHEN $8::text IS NULL THEN 'firing' + ELSE 'suppressed' END::alr.alert_state + -- notify_pending навмисно не чіпаємо: продовження + -- вже відомої події не є новиною, і сотий рядок + -- журналу не має слати сотого повідомлення. + RETURNING id, state::text AS new_state, started_at, last_seen_at, + event_count, notify_count, (xmax = 0) AS inserted + ) + SELECT ups.id::text, ups.new_state, ups.started_at, ups.last_seen_at, + ups.event_count, ups.notify_count, ups.inserted, + COALESCE(prev.old_state, '') + FROM ups LEFT JOIN prev ON prev.id = ups.id + `, tenantID, nullUUID(e.RuleID), nullUUID(e.DeviceID), e.Severity, + e.Title, e.DedupKey, ctxJSON, nullString(e.SuppressedBy), + nullString(e.Message), count, + ).Scan(&out.ID, &out.State, &out.StartedAt, &out.LastSeenAt, + &out.EventCount, &out.NotifyCount, &out.IsNew, &out.PrevState) + }) + if err != nil { + return out, fmt.Errorf("подієвий алерт %s: %w", e.DedupKey, err) + } + + out.RuleID, out.DeviceID = e.RuleID, e.DeviceID + out.DeviceName, out.Severity, out.Title = e.DeviceName, e.Severity, e.Title + out.Message, out.DedupKey, out.SuppressedBy = e.Message, e.DedupKey, e.SuppressedBy + return out, nil +} + +// ResolveEventAlerts закриває подієві алерти за їхніми ключами. +// +// Потрібно рівно там, де в події ВСЕ Ж таки є зворотний бік: +// відповідність перевіряється прогоном, і той самий прогін, у якому +// хост правило пройшов, — єдиний чесний сигнал «більше не порушено». +// Для журналу й конфігів такого сигналу не існує: рядок «конфіг +// змінився» ніщо не скасовує. +func (s *Store) ResolveEventAlerts(ctx context.Context, tenantID string, dedupKeys []string, reason string) (int, error) { + if len(dedupKeys) == 0 { + return 0, nil + } + var n int + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + return tx.QueryRow(ctx, ` + WITH closed AS ( + UPDATE alr.alerts + SET state = 'resolved', resolved_at = now(), notify_pending = false + WHERE tenant_id = $1 + AND dedup_key = ANY($2::text[]) + AND state IN ('firing','acknowledged','suppressed') + RETURNING id, device_id, severity, title + ), published AS ( + INSERT INTO core.event_outbox (tenant_id, topic, payload) + SELECT $1, 'alert.resolved', + jsonb_build_object('alert_id', id::text, + 'device_id', COALESCE(device_id::text,''), + 'severity', severity::text, + 'title', title, + 'state', 'resolved', + 'reason', $3::text) + FROM closed + RETURNING 1 + ) + SELECT count(*)::int FROM published + `, tenantID, dedupKeys, reason).Scan(&n) + }) + return n, err +} + +// ExpireEventAlerts гасить подієві алерти, до яких давно не було подій. +// +// Це не прибирання й не косметика — це відповідь на питання «як +// закривається алерт, що не має умови». +// +// Метричний алерт закриває сама дійсність: умова перестала виконуватись +// — рядок зник із кандидатів. Подієвий такого шансу не має: рядок +// журналу стався, і «перестати ставатись» не може. Лишити його висіти +// назавжди означає за тиждень отримати дошку з двома сотнями старих +// подій, на яку ніхто не дивиться, — а тоді на ній не помітять і +// справжню аварію. +// +// Тому такий алерт має строк. Стан навмисно `expired`, а не `resolved`: +// ніхто не казав, що проблему полагодили, вона просто відстоялась. У +// журналі й на екрані це має виглядати по-різному, інакше «саме +// минулося» неможливо відрізнити від «розібрались». +func (s *Store) ExpireEventAlerts(ctx context.Context) (int64, error) { + tag, err := s.bg.Exec(ctx, ` + WITH aged AS ( + UPDATE alr.alerts a + SET state = 'expired', resolved_at = now(), notify_pending = false + FROM alr.rules r + WHERE r.id = a.rule_id + AND r.auto_close_seconds > 0 + AND a.state IN ('firing','acknowledged','suppressed') + AND a.last_seen_at < now() - make_interval(secs => r.auto_close_seconds) + RETURNING a.id, a.tenant_id, a.device_id, a.severity, a.title + ) + INSERT INTO core.event_outbox (tenant_id, topic, payload) + SELECT tenant_id, 'alert.resolved', + jsonb_build_object('alert_id', id::text, + 'device_id', COALESCE(device_id::text,''), + 'severity', severity::text, + 'title', title, + 'state', 'expired', + 'reason', 'подій більше не було') + FROM aged + `) + if err != nil { + return 0, err + } + return tag.RowsAffected(), nil +} + +// TakeNotifyPending забирає подієві алерти, які ще нікуди не пішли. +// +// Забирає, а не читає: позначка знімається тією ж командою, що й +// повертає рядки. Інакше два процеси, які випадково опинились у тіку +// одночасно, розіслали б одне й те саме двічі — а телефон о третій ночі +// не розрізняє «дублікат» і «друга аварія». +// +// Знята позначка означає «спробували», а не «доставили». Це свідомо: +// повторні спроби доставки — робота каналу, і робити їх звідси означало +// б слати вдруге те, що вже дійшло, щоразу як мовчить один із трьох +// каналів. +func (s *Store) TakeNotifyPending(ctx context.Context, limit int) ([]Alert, error) { + if limit <= 0 || limit > 500 { + limit = 200 + } + rows, err := s.bg.Query(ctx, ` + WITH taken AS ( + UPDATE alr.alerts a + SET notify_pending = false + WHERE a.id IN ( + SELECT id FROM alr.alerts + WHERE notify_pending + ORDER BY started_at + LIMIT $1 + FOR UPDATE SKIP LOCKED + ) + RETURNING a.* + ) + SELECT t.tenant_id::text, t.id::text, COALESCE(t.rule_id::text,''), + COALESCE(t.device_id::text,''), COALESCE(d.name,''), + t.severity::text, t.state::text, t.title, COALESCE(t.message,''), + t.dedup_key, COALESCE(t.suppressed_by,''), t.started_at, t.last_seen_at, + t.event_count + FROM taken t + LEFT JOIN inv.devices d ON d.id = t.device_id + `, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []Alert + for rows.Next() { + var a Alert + if err := rows.Scan(&a.TenantID, &a.ID, &a.RuleID, &a.DeviceID, &a.DeviceName, + &a.Severity, &a.State, &a.Title, &a.Message, &a.DedupKey, + &a.SuppressedBy, &a.StartedAt, &a.LastSeenAt, &a.EventCount); err != nil { + return nil, err + } + out = append(out, a) + } + return out, rows.Err() +} + +// EventDedupKey — ключ подієвого алерту. +// +// Свідомо той самий вигляд, що й у Candidate.DedupKey: подія й вимір +// про той самий хост і те саме правило — це один алерт, і два різні +// формати ключа рано чи пізно дали б два. +func EventDedupKey(ruleID, deviceID string) string { + return ruleID + ":dev:" + deviceID +} + +// TrapDedupKey — ключ алерту за трапом. +// +// Для трапа від відомого хоста це той самий ключ, що й для решти +// подієвих джерел: подія про хост — один алерт на пару «правило+хост». +// +// Для трапа від адреси, якої немає в інвентарі, хоста немає взагалі, і +// ключ будується від адреси. Без цього всі невпізнані відправники +// злилися б у ОДИН алерт на правило — тобто «щось у мережі шле трапи», +// з чим неможливо нічого зробити. З адресою в ключі кожен незнайомець +// має власний рядок на дошці, і його видно як окреме питання. +func TrapDedupKey(ruleID, deviceID, sourceIP string) string { + if deviceID != "" { + return EventDedupKey(ruleID, deviceID) + } + return ruleID + ":ip:" + sourceIP +} + +// DeviceNames — імена всіх живих хостів кабінету. +// +// Одним запитом на весь кабінет, а не по хосту на подію: ім'я потрібне +// лише в заголовку алерту, а заголовок пишеться раз при створенні. +// Платити за нього окремим запитом на кожен рядок журналу означало б +// зробити найдорожчою частиною шляху найдешевшу його потребу. +func (s *Store) DeviceNames(ctx context.Context, tenantID string) (map[string]string, error) { + out := map[string]string{} + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, ` + SELECT id::text, name FROM inv.devices + WHERE tenant_id = $1 AND deleted_at IS NULL + `, tenantID) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var id, name string + if err := rows.Scan(&id, &name); err != nil { + return err + } + out[id] = name + } + return rows.Err() + }) + return out, err +} diff --git a/server/internal/store/alerts_events_test.go b/server/internal/store/alerts_events_test.go new file mode 100644 index 0000000..1038bc8 --- /dev/null +++ b/server/internal/store/alerts_events_test.go @@ -0,0 +1,221 @@ +package store + +import ( + "errors" + "strings" + "testing" +) + +// Помилка, яку лікує ця гілка: джерело, якого движок не вміє, +// зберігалось мовчки й виглядало ввімкненим. +func TestUnsupportedSourcesAreRefusedWithReason(t *testing.T) { + for _, src := range []string{"link", "agent", "вигадане"} { + reason := UnsupportedSourceReason(src) + if reason == "" { + t.Errorf("%s: джерело не працює, а відмови немає", src) + } + if len(reason) < 40 { + // Відмова без пояснення — та сама мовчанка, тільки з кодом + // помилки. Людина має дізнатись, що робити натомість. + t.Errorf("%s: пояснення надто коротке: %q", src, reason) + } + } + // `trap` переїхав сюди з переліку вище: 0065 дала йому приймач, + // словник і перевірку умови. Тест лишається двобічним навмисно — + // джерело, яке працює, не має відмовляти, а джерело, якого немає, + // не має мовчати. + for _, src := range []string{"metric", "icmp", "interface", "syslog", "ncm", "compliance", "trap"} { + if r := UnsupportedSourceReason(src); r != "" { + t.Errorf("%s: працююче джерело відхилено: %s", src, r) + } + } +} + +func TestEventSourcesAreExactlyTheEventDriven(t *testing.T) { + for _, src := range []string{"syslog", "ncm", "compliance", "trap"} { + if !IsEventSource(src) { + t.Errorf("%s мало бути подієвим", src) + } + } + for _, src := range []string{"metric", "icmp", "interface"} { + if IsEventSource(src) { + t.Errorf("%s опитується, а не приходить подією", src) + } + } +} + +// Правило на журнал без зразка підпало б під кожен рядок — тобто дало б +// алерт на все підряд у першу ж хвилину. +func TestSyslogRuleNeedsPattern(t *testing.T) { + if err := ValidateRuleCondition("syslog", []byte(`{}`)); err == nil { + t.Fatal("правило без зразка мало бути відхилене") + } else if !errors.Is(err, ErrInvalid) { + t.Fatalf("очікували ErrInvalid, отримали %v", err) + } +} + +// Про друкарську помилку в регулярному виразі людина має дізнатись із +// форми, а не з тригера, який рік мовчав. +func TestSyslogRuleRejectsBrokenPattern(t *testing.T) { + err := ValidateRuleCondition("syslog", []byte(`{"regex":"%LINK-3-(UPDOWN"}`)) + if err == nil { + t.Fatal("незакрита дужка мала бути помічена") + } + if !strings.Contains(err.Error(), "компілю") { + t.Errorf("пояснення має вказувати на зразок: %v", err) + } +} + +func TestSyslogSeverityBounds(t *testing.T) { + if err := ValidateRuleCondition("syslog", []byte(`{"regex":"x","severity_lte":4}`)); err != nil { + t.Fatalf("рівень 4 припустимий: %v", err) + } + if err := ValidateRuleCondition("syslog", []byte(`{"regex":"x","severity_lte":9}`)); err == nil { + t.Fatal("рівнів syslog більше семи не буває") + } +} + +// Тригер на конфіг мусить сказати, ЯКУ саме подію ловить: «змінився» і +// «не зібрався» — новини для різних людей. +func TestNcmRuleNeedsEvent(t *testing.T) { + if err := ValidateRuleCondition("ncm", []byte(`{}`)); err == nil { + t.Fatal("умова без події мала бути відхилена") + } + if err := ValidateRuleCondition("ncm", []byte(`{"event":"вигадана"}`)); err == nil { + t.Fatal("невідома подія мала бути відхилена") + } + for _, ev := range []string{"changed", "backup_failed"} { + if err := ValidateRuleCondition("ncm", []byte(`{"event":"`+ev+`"}`)); err != nil { + t.Errorf("%s: %v", ev, err) + } + } +} + +// Відповідність — єдине подієве джерело, де порожня умова осмислена: +// «скажи мені про будь-яке порушення». +func TestComplianceRuleAllowsEmptyCondition(t *testing.T) { + if err := ValidateRuleCondition("compliance", []byte(`{}`)); err != nil { + t.Fatalf("порожня умова мала пройти: %v", err) + } + if err := ValidateRuleCondition("compliance", []byte(`{"min_severity":"страшна"}`)); err == nil { + t.Fatal("невідома серйозність мала бути відхилена") + } +} + +// Опитувані джерела через цю перевірку проходять без змін: їхню умову +// розбирає движок, і дублювати тут його whitelist означало б розійтися +// з ним на першій же правці. +func TestPolledSourcesPassValidationUntouched(t *testing.T) { + if err := ValidateRuleCondition("icmp", []byte(`{"metric":"loss_pct","op":">","value":20}`)); err != nil { + t.Fatalf("метричну умову тут не перевіряють: %v", err) + } +} + +// Ключ подієвого алерту має збігатися з ключем метричного для тієї ж +// пари: два формати рано чи пізно дали б два алерти про одне. +func TestEventDedupKeyMatchesCandidateKey(t *testing.T) { + c := Candidate{DeviceID: "d1"} + if EventDedupKey("r1", "d1") != c.DedupKey("r1") { + t.Fatalf("ключі розійшлись: %q проти %q", EventDedupKey("r1", "d1"), c.DedupKey("r1")) + } +} + +// --------------------------------------------------------------------- +// Трапи +// --------------------------------------------------------------------- + +// Правило без жодного звуження підпало б під КОЖЕН трап у мережі — і +// перший же linkUp кожного порту навчив би людину не читати сповіщення. +func TestTrapRuleNeedsSomethingToMatch(t *testing.T) { + err := ValidateRuleCondition("trap", []byte(`{}`)) + if err == nil { + t.Fatal("умова без OID і без адреси мала бути відхилена") + } + if !errors.Is(err, ErrInvalid) { + t.Fatalf("очікували ErrInvalid, отримали %v", err) + } +} + +// Дві форми звуження, і кожної окремо достатньо: «цей трап звідусіль» і +// «будь-який трап від цієї адреси» — обидва питання осмислені. +func TestTrapRuleAcceptsOIDOrSource(t *testing.T) { + for _, cond := range []string{ + `{"trap_oid":"1.3.6.1.6.3.1.1.5.3"}`, + `{"trap_oid":".1.3.6.1.6.3.1.1.5.3"}`, + `{"source_ip":"10.20.0.0/24"}`, + `{"source_ip":"10.20.0.7"}`, + `{"trap_oid":"1.3.6.1.6.3.1.1.5.3","varbind_oid":"1.3.6.1.2.1.2.2.1.8","varbind_value":"2"}`, + // Varbind без значення — «щоб такий взагалі був». Теж питання. + `{"trap_oid":"1.3.6.1.6.3.1.1.5.3","varbind_oid":"1.3.6.1.2.1.2.2.1.1"}`, + } { + if err := ValidateRuleCondition("trap", []byte(cond)); err != nil { + t.Errorf("%s: %v", cond, err) + } + } +} + +// Описка в OID має бути помічена у формі, а не через півроку тиші. +func TestTrapRuleRejectsBrokenOID(t *testing.T) { + for _, cond := range []string{ + `{"trap_oid":"linkDown"}`, + `{"trap_oid":"1.3.6.1.6.3.1.1.5,3"}`, + `{"trap_oid":"1..3.6"}`, + `{"source_ip":"10.20.0.300"}`, + `{"trap_oid":"1.3.6.1.6.3.1.1.5.3","varbind_oid":"ifIndex"}`, + } { + if err := ValidateRuleCondition("trap", []byte(cond)); err == nil { + t.Errorf("%s: описку не помічено", cond) + } + } +} + +// Значення varbind-а без OID varbind-а порівнювати ніде: у трапі їх +// десяток. Мовчазне ігнорування дало б умову, яка виглядає точною й не +// є такою. +func TestTrapRuleRejectsValueWithoutOID(t *testing.T) { + err := ValidateRuleCondition("trap", []byte(`{"trap_oid":"1.3.6.1.6.3.1.1.5.3","varbind_value":"2"}`)) + if err == nil { + t.Fatal("значення без OID varbind-а мало бути відхилене") + } + if !strings.Contains(err.Error(), "1.3.6.1.2.1.2.2.1.1") { + // Відмова має казати, що робити далі, а не лише «ні». + t.Errorf("у поясненні немає прикладу: %v", err) + } +} + +// Поля чужого джерела в умові — не дрібниця: людина, яка переключила +// джерело з «Syslog» на «Трапи», має дізнатися, що зразок більше не +// діє. Мовчазне ігнорування дало б правило, яке ловить УСЕ. +func TestTrapRuleRefusesSyslogLeftovers(t *testing.T) { + for _, cond := range []string{ + `{"trap_oid":"1.3.6.1.6.3.1.1.5.3","regex":"%LINK-3-UPDOWN"}`, + `{"trap_oid":"1.3.6.1.6.3.1.1.5.3","severity_lte":4}`, + `{"trap_oid":"1.3.6.1.6.3.1.1.5.3","tag":"LINK"}`, + } { + err := ValidateRuleCondition("trap", []byte(cond)) + if err == nil { + t.Errorf("%s: залишок від syslog проковтнуто мовчки", cond) + continue + } + if len(err.Error()) < 60 { + t.Errorf("%s: відмова без пояснення: %v", cond, err) + } + } +} + +// Алерт про трап від адреси, яку не впізнали, не має зливатися з +// алертами про інших незнайомців: інакше на дошці буде один рядок +// «щось у мережі шле трапи», з яким нічого не зробиш. +func TestTrapDedupKeySeparatesUnknownSenders(t *testing.T) { + if got := TrapDedupKey("r1", "d1", "10.0.0.1"); got != EventDedupKey("r1", "d1") { + t.Fatalf("для відомого хоста ключ має бути звичайним: %q", got) + } + a := TrapDedupKey("r1", "", "10.0.0.1") + b := TrapDedupKey("r1", "", "10.0.0.2") + if a == b { + t.Fatalf("двоє незнайомців злилися в один алерт: %q", a) + } + if a == EventDedupKey("r1", "") { + t.Fatalf("ключ без хоста збігся з ключем «хост порожній»: %q", a) + } +} diff --git a/server/internal/store/alerts_query.go b/server/internal/store/alerts_query.go index 7699370..0cac4c1 100644 --- a/server/internal/store/alerts_query.go +++ b/server/internal/store/alerts_query.go @@ -53,11 +53,21 @@ func (s *Store) ListAlerts(ctx context.Context, tenantID string, f AlertFilter) a.dedup_key, a.value, a.threshold, a.context::text, COALESCE(a.root_alert_id::text,''), COALESCE(a.suppressed_by,''), a.started_at, a.acked_at, COALESCE(u.email::text,''), - COALESCE(a.ack_comment,''), a.resolved_at, a.last_seen_at, a.notify_count + COALESCE(a.ack_comment,''), a.resolved_at, a.last_seen_at, + a.notify_count, a.event_count, + COALESCE(ep.name,''), COALESCE(e.step_idx,0), COALESCE(e.repeat_idx,0), + COALESCE(jsonb_array_length(ep.steps),0), + e.next_at, e.stopped_at, COALESCE(e.stop_reason,''), + (e.alert_id IS NOT NULL) FROM alr.alerts a LEFT JOIN alr.rules r ON r.id = a.rule_id LEFT JOIN inv.devices d ON d.id = a.device_id LEFT JOIN core.users u ON u.id = a.acked_by + -- Стан драбини ескалації. LEFT JOIN, бо ескалація типово + -- вимкнена: більшість алертів рядка тут не має, і це + -- нормальний стан, а не відсутність даних. + LEFT JOIN alr.alert_escalations e ON e.alert_id = a.id + LEFT JOIN alr.escalation_policies ep ON ep.id = e.policy_id WHERE a.tenant_id = $1 AND a.state::text = ANY($2::text[]) AND ($3::uuid IS NULL OR a.device_id = $3::uuid) @@ -85,17 +95,25 @@ func (s *Store) ListAlerts(ctx context.Context, tenantID string, f AlertFilter) for rows.Next() { var a Alert var ctxJSON string + var esc AlertEscalation + var hasEsc bool if err := rows.Scan(&a.ID, &a.RuleID, &a.RuleName, &a.DeviceID, &a.DeviceName, &a.InterfaceID, &a.Severity, &a.State, &a.Title, &a.Message, &a.DedupKey, &a.Value, &a.Threshold, &ctxJSON, &a.RootAlertID, &a.SuppressedBy, &a.StartedAt, &a.AckedAt, &a.AckedByEmail, &a.AckComment, &a.ResolvedAt, &a.LastSeenAt, - &a.NotifyCount); err != nil { + &a.NotifyCount, &a.EventCount, + &esc.PolicyName, &esc.Step, &esc.Repeat, &esc.Total, + &esc.NextAt, &esc.StoppedAt, &esc.StopReason, &hasEsc); err != nil { return err } if minRank >= 0 && severityRank[a.Severity] < minRank { continue } + if hasEsc { + e := esc + a.Escalation = &e + } a.Context = []byte(ctxJSON) out = append(out, a) } @@ -156,6 +174,16 @@ func (s *Store) AckAlert(ctx context.Context, tenantID, alertID, userID, comment return err } + // Драбина зупиняється тут же, у тій самій транзакції: людина + // натиснула «Прийняти» рівно для цього, і побачити «ескалацію + // зупинено» вона має негайно, а не за півхвилини. Безпеку при + // цьому тримає не цей рядок, а перевірка стану перед кожною + // сходинкою — див. PlanEscalation. + if err := stopEscalationTx(ctx, tx, tenantID, a.ID, "acked", + "алерт підтверджено людиною"); err != nil { + return err + } + _, err = tx.Exec(ctx, ` INSERT INTO core.event_outbox (tenant_id, topic, payload) VALUES ($1, 'alert.acked', jsonb_build_object('alert_id', $2::text)) @@ -184,6 +212,11 @@ func (s *Store) CloseAlert(ctx context.Context, tenantID, alertID string) error if tag.RowsAffected() == 0 { return ErrAlertNotFound } + // Закритий алерт не має воскресати сходинкою ескалації. + if err := stopEscalationTx(ctx, tx, tenantID, alertID, "closed", + "алерт закрито людиною"); err != nil { + return err + } _, err = tx.Exec(ctx, ` INSERT INTO core.event_outbox (tenant_id, topic, payload) VALUES ($1, 'alert.resolved', jsonb_build_object('alert_id', $2::text)) @@ -192,6 +225,19 @@ func (s *Store) CloseAlert(ctx context.Context, tenantID, alertID string) error }) } +// MaxMute — стеля ручного заглушення. +// +// Безстрокове «не турбувати» — найпоширеніший спосіб тихо вимкнути +// моніторинг назавжди: людина глушить на час аварії й забуває. Тиждень +// достатньо для будь-яких робіт і замало, щоб забути назовсім. +// +// Живе в шарі store, а не в обробнику HTTP, бо кнопку «Заглушити» +// натискають уже з двох місць — з інтерфейсу й з Telegram. Стеля, +// продубльована в другому місці, розійшлася б із першою на наступній +// же правці, і найгірше в цьому те, що розбіжність було б видно лише +// тому, хто відкриє обидва файли поруч. +const MaxMute = 7 * 24 * time.Hour + // MuteDevice глушить пристрій на визначений час. func (s *Store) MuteDevice(ctx context.Context, tenantID, deviceID, userID, reason string, until time.Time) error { return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { @@ -203,6 +249,94 @@ func (s *Store) MuteDevice(ctx context.Context, tenantID, deviceID, userID, reas }) } +// ActiveMute каже, чи пристрій уже заглушений і до котрої. +// +// Потрібне не для показу, а для ідемпотентності: кнопка в Telegram +// приходить від пальця, а палець натискає двічі — і мережа теж уміє +// доставити те саме натискання повторно. Без цієї перевірки друге +// натискання клало б другий ряд у alr.mutes, і «заглушено на годину» +// тихо перетворювалось би на дві години. +func (s *Store) ActiveMute(ctx context.Context, tenantID, deviceID string) (time.Time, bool, error) { + var until time.Time + var found bool + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + err := tx.QueryRow(ctx, ` + SELECT until FROM alr.mutes + WHERE tenant_id = $1 AND device_id = $2 AND until > now() + ORDER BY until DESC LIMIT 1 + `, tenantID, deviceID).Scan(&until) + if errors.Is(err, pgx.ErrNoRows) { + return nil + } + if err != nil { + return err + } + found = true + return nil + }) + return until, found, err +} + +// AlertAck — стан підтвердження алерту. +// +// Окреме читання перед AckAlert, а не після: до підтвердження треба +// знати пристрій (щоб звірити його з доступом людини) і поточний стан +// (щоб повторне натискання відповіло «вже підтверджено Іваном о 03:12», +// а не помилкою «алерт не знайдено» — з телефона друге читається як +// поломка). +type AlertAck struct { + ID string + DeviceID string + DeviceName string + Title string + State string + AckedAt *time.Time + AckedByEmail string +} + +// AlertAckState читає алерт разом із тим, хто його вже підтвердив. +func (s *Store) AlertAckState(ctx context.Context, tenantID, alertID string) (AlertAck, error) { + var a AlertAck + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + err := tx.QueryRow(ctx, ` + SELECT a.id::text, COALESCE(a.device_id::text,''), COALESCE(d.name,''), + a.title, a.state::text, a.acked_at, COALESCE(u.email::text,'') + FROM alr.alerts a + LEFT JOIN inv.devices d ON d.id = a.device_id + LEFT JOIN core.users u ON u.id = a.acked_by + WHERE a.tenant_id = $1 AND a.id = $2 + `, tenantID, alertID).Scan(&a.ID, &a.DeviceID, &a.DeviceName, + &a.Title, &a.State, &a.AckedAt, &a.AckedByEmail) + if errors.Is(err, pgx.ErrNoRows) { + return ErrAlertNotFound + } + return err + }) + return a, err +} + +// DeviceNameInTenant повертає ім'я хоста, якщо він належить кабінету. +// +// Зовнішній ключ alr.mutes.device_id перевіряє лише те, що хост існує +// десь у базі, — не те, що він у цьому кабінеті. Для запиту з UI цього +// досить, бо там ідентифікатор уже пройшов через scope; для натискання +// з Telegram — ні: у callback_data лежить рівно те, що ми туди +// поклали, але прислати його може будь-хто, хто бачив формат. +func (s *Store) DeviceNameInTenant(ctx context.Context, tenantID, deviceID string) (string, error) { + var name string + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + err := tx.QueryRow(ctx, ` + SELECT name FROM inv.devices + WHERE tenant_id = $1 AND id = $2 AND deleted_at IS NULL + `, tenantID, deviceID).Scan(&name) + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + return err + }) + return name, err +} + // --------------------------------------------------------------------- // Правила: CRUD для UI // --------------------------------------------------------------------- @@ -221,6 +355,13 @@ type RuleInput struct { ChannelIDs []string NotifySchedule string NotifyOnResolve bool + // Тільки для подієвих джерел: через скільки алерт гасне сам (0 — + // тільки руками) і як рідко можна доливати в нього події. + AutoCloseSeconds int + MinIntervalSeconds int + // Драбина ескалації. Порожньо — без ескалації, і це типове + // значення: оновлення не має раптом почати будити людей. + EscalationPolicyID string } func (s *Store) ListRules(ctx context.Context, tenantID string) ([]map[string]any, error) { @@ -232,11 +373,14 @@ func (s *Store) ListRules(ctx context.Context, tenantID string) ([]map[string]an r.for_seconds, r.depends_on_topology, r.enabled, r.channel_ids::text[], COALESCE(r.notify_schedule::text,''), r.notify_on_resolve, + r.auto_close_seconds, r.min_interval_seconds, + COALESCE(r.escalation_policy_id::text,''), COALESCE(ep.name,''), (SELECT count(*) FROM alr.alerts a WHERE a.rule_id = r.id AND a.state IN ('firing','acknowledged')), COALESCE(r.template_id::text,''), COALESCE(t.name,'') FROM alr.rules r LEFT JOIN tpl.templates t ON t.id = r.template_id + LEFT JOIN alr.escalation_policies ep ON ep.id = r.escalation_policy_id WHERE r.tenant_id = $1 ORDER BY r.name `, tenantID) @@ -246,12 +390,13 @@ func (s *Store) ListRules(ctx context.Context, tenantID string) ([]map[string]an defer rows.Close() for rows.Next() { var id, name, desc, source, sev, sel, cond, sched string - var tplID, tplName string - var forSec, active int + var tplID, tplName, escID, escName string + var forSec, active, autoClose, minInterval int var topo, enabled, onResolve bool var channelIDs []string if err := rows.Scan(&id, &name, &desc, &source, &sev, &sel, &cond, &forSec, &topo, &enabled, &channelIDs, &sched, &onResolve, + &autoClose, &minInterval, &escID, &escName, &active, &tplID, &tplName); err != nil { return err } @@ -264,7 +409,12 @@ func (s *Store) ListRules(ctx context.Context, tenantID string) ([]map[string]an "for_seconds": forSec, "depends_on_topology": topo, "enabled": enabled, "active_alerts": active, "channel_ids": channelIDs, "notify_on_resolve": onResolve, - "notify_schedule": rawJSON(sched), + "notify_schedule": rawJSON(sched), + "auto_close_seconds": autoClose, "min_interval_seconds": minInterval, + // Порожньо — без ескалації. Ім'я віддаємо поруч із + // ідентифікатором, щоб перелік правил не мусив другим + // запитом з'ясовувати, як називається драбина. + "escalation_policy_id": escID, "escalation_policy_name": escName, // Непорожній шаблон означає, що правило породжене його // тригером: у списку його видно, але правлять його там, // де воно описане один раз на клас пристроїв. @@ -283,14 +433,16 @@ func (s *Store) CreateRule(ctx context.Context, tenantID, userID string, in Rule INSERT INTO alr.rules (tenant_id, name, description, source, severity, selector, condition, for_seconds, depends_on_topology, enabled, created_by, - channel_ids, notify_schedule, notify_on_resolve) + channel_ids, notify_schedule, notify_on_resolve, + auto_close_seconds, min_interval_seconds, escalation_policy_id) VALUES ($1,$2,$3,$4::alr.rule_source,$5::alr.severity,$6::jsonb,$7::jsonb, - $8,$9,$10,$11,$12::uuid[],NULLIF($13,'')::jsonb,$14) + $8,$9,$10,$11,$12::uuid[],NULLIF($13,'')::jsonb,$14,$15,$16,$17) RETURNING id::text `, tenantID, in.Name, nullString(in.Description), in.Source, in.Severity, in.Selector, in.Condition, in.ForSeconds, in.DependsOnTopology, in.Enabled, nullUUID(userID), in.ChannelIDs, in.NotifySchedule, - in.NotifyOnResolve).Scan(&id) + in.NotifyOnResolve, in.AutoCloseSeconds, in.MinIntervalSeconds, + nullUUID(in.EscalationPolicyID)).Scan(&id) }) return id, err } @@ -383,12 +535,16 @@ func (s *Store) UpdateRule(ctx context.Context, tenantID, ruleID string, in Rule channel_ids = $12::uuid[], notify_schedule = NULLIF($13,'')::jsonb, notify_on_resolve = $14, + auto_close_seconds = $15, + min_interval_seconds = $16, + escalation_policy_id = $17, updated_at = now() WHERE id = $1 AND tenant_id = $2 `, ruleID, tenantID, in.Name, nullString(in.Description), in.Source, in.Severity, in.Selector, in.Condition, in.ForSeconds, in.DependsOnTopology, in.Enabled, in.ChannelIDs, in.NotifySchedule, - in.NotifyOnResolve) + in.NotifyOnResolve, in.AutoCloseSeconds, in.MinIntervalSeconds, + nullUUID(in.EscalationPolicyID)) if err != nil { return err } diff --git a/server/internal/store/alerts_state.go b/server/internal/store/alerts_state.go index a66953a..6c1e4e0 100644 --- a/server/internal/store/alerts_state.go +++ b/server/internal/store/alerts_state.go @@ -34,6 +34,17 @@ type Alert struct { ResolvedAt *time.Time `json:"resolved_at,omitempty"` LastSeenAt time.Time `json:"last_seen_at"` NotifyCount int `json:"notify_count"` + // Скільки подій злилося в цей алерт. Для метричних завжди 1: + // вимір не «стається», він просто триває. + EventCount int `json:"event_count,omitempty"` + // Стан драбини ескалації; порожньо — ескалації в цього алерту немає. + // Без цього поля людина, підняту о третій ночі, не має де побачити, + // звідки взявся дзвінок і коли буде наступний. + Escalation *AlertEscalation `json:"escalation,omitempty"` + // Тенант потрібен лише там, де алерти читаються наскрізь по всіх + // кабінетах (черга розсилки подієвих) — у решті місць він відомий + // із контексту запиту. + TenantID string `json:"-"` } // FiredAlert — результат підняття. @@ -167,7 +178,7 @@ func (s *Store) ResolveMissing(ctx context.Context, tenantID, ruleID string, kee // у тій самій таблиці зробила б кожен із цих запитів дорожчим без // жодної користі. func (s *Store) ArchiveResolved(ctx context.Context, olderThan time.Duration) (int64, error) { - tag, err := s.pool.Exec(ctx, ` + tag, err := s.bg.Exec(ctx, ` WITH moved AS ( DELETE FROM alr.alerts WHERE state IN ('resolved','expired') diff --git a/server/internal/store/apitokens.go b/server/internal/store/apitokens.go index fc72056..d809a04 100644 --- a/server/internal/store/apitokens.go +++ b/server/internal/store/apitokens.go @@ -52,7 +52,7 @@ func (s *Store) AuthenticateAPIToken(ctx context.Context, token string) (*APITok revokedAt *time.Time ) - err := s.pool.QueryRow(ctx, ` + err := s.bg.QueryRow(ctx, ` SELECT id::text, tenant_id::text, name, scopes, token_hash, expires_at, revoked_at FROM core.api_tokens WHERE token_hash = $1 @@ -79,7 +79,7 @@ func (s *Store) AuthenticateAPIToken(ctx context.Context, token string) (*APITok go func() { bg, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) defer cancel() - _, _ = s.pool.Exec(bg, + _, _ = s.bg.Exec(bg, `UPDATE core.api_tokens SET last_used_at = now() WHERE id = $1`, t.ID) }() diff --git a/server/internal/store/audit.go b/server/internal/store/audit.go new file mode 100644 index 0000000..032a87f --- /dev/null +++ b/server/internal/store/audit.go @@ -0,0 +1,70 @@ +package store + +import ( + "context" + "encoding/json" + "net" +) + +// Журнал аудиту. +// +// Таблиця core.audit_log існує з першої міграції, але доти в неї ніхто +// не писав: усі дії, які встигли з'явитись, або оборотні (правка мапи, +// перейменування хоста), або й так видимі в самому об'єкті. Масове +// виконання команд — перша дія, яка нічого по собі не лишає: команда +// пішла, пристрій відповів, слід є тільки тут. +// +// Через це запис аудиту тут не «на всяк випадок», а частина функції: +// без нього питання «хто о третій ночі прогнав це по дільниці» не має +// відповіді взагалі. + +// AuditEntry — один рядок журналу. +// +// Actor окремими полями, а не одним «хто»: за токеном інтеграції немає +// людини, і зводити їх до рядка означало б втратити різницю між +// «інженер натиснув кнопку» і «скрипт ходить нашим API». +type AuditEntry struct { + ActorUserID string + ActorTokenID string + ActorIP net.IP + + // Дія у вигляді «об'єкт.дія»: ncm.command_run.create. + Action string + ObjectType string + ObjectID string + + // Meta несе те, що робить рядок зрозумілим без доступу до решти + // таблиць: самі команди, кількість хостів, їхні імена. Через рік + // хостів може вже не бути, а питання лишиться. + Meta map[string]any +} + +// WriteAudit кладе подію в журнал. +// +// Пише в пул напряму, а не в тенантній транзакції: core.audit_log — +// гіпертаблиця, RLS на ній вимкнено (див. 0011), тому ізоляцію тримає +// сам предикат tenant_id у запитах читання. +func (s *Store) WriteAudit(ctx context.Context, tenantID string, e AuditEntry) error { + meta := []byte("{}") + if len(e.Meta) > 0 { + b, err := json.Marshal(e.Meta) + if err != nil { + return err + } + meta = b + } + + var ip any + if e.ActorIP != nil { + ip = e.ActorIP.String() + } + + _, err := s.pool.Exec(ctx, ` + INSERT INTO core.audit_log + (tenant_id, actor_user_id, actor_token_id, actor_ip, + action, object_type, object_id, meta) + VALUES ($1, $2, $3, $4::inet, $5, $6, $7, $8::jsonb) + `, tenantID, nullUUID(e.ActorUserID), nullUUID(e.ActorTokenID), ip, + e.Action, nullString(e.ObjectType), nullUUID(e.ObjectID), string(meta)) + return err +} diff --git a/server/internal/store/audit_actions.go b/server/internal/store/audit_actions.go new file mode 100644 index 0000000..acdeb48 --- /dev/null +++ b/server/internal/store/audit_actions.go @@ -0,0 +1,349 @@ +package store + +import "strings" + +// Словник дій журналу аудиту. +// +// Навіщо він узагалі. Ключ `ncm.command_run.delete` читається лише тим, +// хто його писав; людина, яка шукає, хто стер результати прогону, +// шукає «видалення». Але сам ключ прибирати не можна: за ним фільтрують, +// його шлють у підтримку, за ним шукають у логах сервера. Тому в +// інтерфейсі є обидва — фраза великим, ключ поруч дрібним. +// +// Словник живе тут, а не в браузері, з однієї причини: перелік дій — це +// властивість того, що продукт ЗАПИСУЄ, а не того, як це показано. +// Наступний, хто додасть виклик WriteAudit, дописує рядок сюди, поруч +// із рештою, — і сторінка, вивантаження й будь-що майбутнє отримують +// однакову назву. Копія словника в TypeScript розійшлася б із цією на +// першому ж новому рядку, і розбіжність було б видно лише тому, хто +// відкриє обидва файли поруч. +// +// Ключа, якого тут немає, це не ламає: подія показується самим лише +// ключем. Журнал старший за словник — у ньому лежать дії збірок, яких +// уже немає, і мовчки ховати їх було б гірше, ніж показати як є. +// +// ЧОМУ КЛЮЧІ — КОНСТАНТИ, А НЕ РЯДКИ ПО МІСЦЯХ ВИКЛИКУ +// +// Прохання «не забудь дописати рядок сюди» цей файл уже програв: за +// півроку в журнал почали писати дзеркало Git, ролі й прив'язку хоста +// до зонда — жодна з цих дій до словника не потрапила, і адміністратор +// бачив у переліку сирі ключі. Помітити це неможливо ніяк, крім як +// відкрити журнал і впізнати відсутню назву. +// +// Тому ключ тепер має рівно одне місце оголошення — константу нижче, — +// а поруч стоїть тест (audit_actions_source_test.go), який читає ВЕСЬ +// server/internal, знаходить кожне присвоєння AuditEntry.Action і падає +// на двох речах: ключ не оголошено константою тут або в константи немає +// назви у словнику. Тобто наступна нова дія не має способу потрапити в +// журнал безіменною: збірка не пройде. + +// Ключі дій. Значення — те, що лягає в core.audit_log.action; змінювати +// їх заднім числом не можна, бо в журналі вже лежать рядки зі старим +// значенням, а журнал не переписують. +const ( + AuditActionDeviceBulkUpdate = "inv.device.bulk_update" + AuditActionDeviceBulkDelete = "inv.device.bulk_delete" + AuditActionDeviceBulkPurge = "inv.device.bulk_purge" + AuditActionDeviceBulkRestore = "inv.device.bulk_restore" + AuditActionDeviceSelfAgent = "inv.device.self_agent" + AuditActionDeviceSelfAgentUn = "inv.device.self_agent_clear" + + AuditActionCommandRunCreate = "ncm.command_run.create" + AuditActionCommandRunCancel = "ncm.command_run.cancel" + AuditActionCommandRunDelete = "ncm.command_run.delete" + AuditActionCommandRunReport = "ncm.command_run.report" + + AuditActionRollbackCreate = "ncm.rollback.create" + AuditActionRollbackApprove = "ncm.rollback.approve" + AuditActionRollbackReject = "ncm.rollback.reject" + AuditActionRollbackPolicy = "ncm.rollback_policy.update" + + AuditActionConfigDelete = "ncm.config.delete" + AuditActionRetentionUpdate = "ncm.retention.update" + AuditActionMirrorUpdate = "ncm.mirror.update" + AuditActionMirrorPush = "ncm.mirror.push" + + AuditActionRoleCreate = "core.role.create" + AuditActionRoleUpdate = "core.role.update" + AuditActionRoleDelete = "core.role.delete" + + AuditActionTelegramLink = "core.telegram.link" + AuditActionTelegramUnlink = "core.telegram.unlink" + + AuditActionRetentionSettings = "core.retention.update" +) + +// Ключі типів об'єктів — те, НАД ЧИМ зроблено дію. +const ( + AuditObjectDevice = "inv.device" + AuditObjectCommandRun = "ncm.command_run" + AuditObjectRollback = "ncm.rollback" + AuditObjectRollbackPolicy = "ncm.rollback_policy" + AuditObjectConfig = "ncm.config" + AuditObjectBackupDefaults = "ncm.backup_defaults" + AuditObjectMirror = "ncm.mirror" + AuditObjectRole = "core.role" + AuditObjectTelegram = "core.telegram_account" + AuditObjectRetention = "core.retention_settings" +) + +// AuditActionInfo — те, що словник знає про дію. +type AuditActionInfo struct { + Key string `json:"key"` + Label string `json:"label"` + Group string `json:"group"` + // Destructive — після цієї дії об'єкта більше немає. Не «важлива» + // й не «небезпечна»: важливість суб'єктивна, а «щось зникло» — + // факт, і саме за ним журнал переглядають найчастіше. + Destructive bool `json:"destructive,omitempty"` +} + +// auditActions — усе, що продукт уміє записувати СЬОГОДНІ. +// +// Перелік і є вся правда про покриття: аудит пишуть масові дії над +// хостами, виконання команд, руйнівні дії над архівом конфігів, +// налаштування дзеркала Git і зміни складу ролей. Решта продукту в +// журнал не пише нічого — див. AuditBlindSpots. +var auditActions = []AuditActionInfo{ + { + Key: AuditActionDeviceBulkUpdate, Group: "Інвентар", + Label: "Масова правка хостів", + }, + { + Key: AuditActionDeviceBulkDelete, Group: "Інвентар", + // Назва навмисно широка. Досі під цим ключем писались ОБИДВА + // режими видалення — і архівний, і повний (режим лежав у + // meta.mode), тож у журналі за ним стоять і ті, й ті рядки. + // Звузити назву до «архівування» означало б перейменувати + // заднім числом чужі події, яких ніхто вже не перевірить. + Label: "Масове видалення хостів", Destructive: true, + }, + { + Key: AuditActionDeviceBulkPurge, Group: "Інвентар", + Label: "Повне видалення хостів разом з історією", Destructive: true, + }, + { + Key: AuditActionDeviceBulkRestore, Group: "Інвентар", + Label: "Відновлення хостів з архіву", + }, + { + Key: AuditActionDeviceSelfAgent, Group: "Інвентар", + Label: "Прив'язка хоста до машини зонда", + }, + { + Key: AuditActionDeviceSelfAgentUn, Group: "Інвентар", + Label: "Зняття прив'язки хоста до машини зонда", + }, + { + Key: AuditActionCommandRunCreate, Group: "Команди", + Label: "Запуск команд на обладнанні", + }, + { + Key: AuditActionCommandRunCancel, Group: "Команди", + Label: "Зупинка прогону команд", + }, + { + Key: AuditActionCommandRunDelete, Group: "Команди", + Label: "Видалення прогону разом із виводом", Destructive: true, + }, + { + Key: AuditActionCommandRunReport, Group: "Команди", + Label: "Вивантаження звіту про прогін", + }, + { + Key: AuditActionRollbackCreate, Group: "Конфігурації", + // Створення наміру, а не сама заливка. Різниця важлива: намір + // може так і не поїхати на пристрій — його відхилять або він + // застаріє. Те, що на залізо справді писали, видно в самому + // відкаті (стани applying → verifying → applied), і дублювати + // це рядком у журналі означало б мати два джерела правди про + // подію, яку найгірше знати неточно. + Label: "Намір відкотити конфіг на пристрої", + }, + { + Key: AuditActionRollbackApprove, Group: "Конфігурації", + // Найважливіший рядок розділу. Відкат за політикою погоджує + // ДРУГА людина, і питання «хто дозволив залити старий конфіг на + // магістральний вузол» має мати відповідь з іменем і часом — + // незалежно від того, чи вцілів сам намір у базі. + Label: "Погодження відкату конфігу", + }, + { + Key: AuditActionRollbackReject, Group: "Конфігурації", + Label: "Відмова у відкаті конфігу", + }, + { + Key: AuditActionRollbackPolicy, Group: "Конфігурації", + // Зміна політики нічого не ламає в мить збереження — вона + // змінює правила для всього, що станеться далі. Саме тому + // рядок тут: «вимогу другої людини вимкнули за годину до + // аварії» інакше не з'ясувати ніяк. + Label: "Зміна правил погодження відкату", + }, + { + Key: AuditActionConfigDelete, Group: "Конфігурації", + Label: "Видалення збережених версій конфігів", Destructive: true, + }, + { + Key: AuditActionRetentionUpdate, Group: "Конфігурації", + Label: "Зміна політики очистки конфігів", + }, + { + Key: AuditActionMirrorUpdate, Group: "Конфігурації", + // Одна назва на три дії, і це не спрощення: налаштування + // дзеркала, видача ключа розгортання й вимкнення дзеркала + // міняють той самий об'єкт і той самий рядок налаштувань. + // Що саме змінилось, видно в meta (url, auth, deploy_key), а + // три окремі рядки у фільтрі означали б три способи спитати + // одне питання. + Label: "Налаштування дзеркала Git", + }, + { + Key: AuditActionMirrorPush, Group: "Конфігурації", + Label: "Примусовий пуш архіву на дзеркало", + }, + { + Key: AuditActionRoleCreate, Group: "Адміністрування", + Label: "Створення ролі", + }, + { + Key: AuditActionRoleUpdate, Group: "Адміністрування", + Label: "Зміна прав ролі", + }, + { + Key: AuditActionRoleDelete, Group: "Адміністрування", + Label: "Видалення ролі", Destructive: true, + }, + { + Key: AuditActionTelegramLink, Group: "Адміністрування", + // Прив'язка не видає нових прав, але дає новий СПОСІБ ними + // скористатися — з телефона, без входу в систему. Питання «чому + // алерт підтверджено о третій ночі акаунтом, який тоді нікуди + // не заходив» без цього рядка відповіді не має. + Label: "Прив'язка Telegram до облікового запису", + }, + { + Key: AuditActionTelegramUnlink, Group: "Адміністрування", + Label: "Зняття прив'язки Telegram", + }, + { + Key: AuditActionRetentionSettings, Group: "Адміністрування", + // Destructive не ставимо, і це не недогляд. Сама зміна нічого + // не стирає — стирає її наслідок, політика, яка вночі знесе + // чанки. Позначка «об'єкта більше немає» тут була б неправдою + // про мить події. Правду про наслідок несе meta: для кожного + // виду даних там стоїть shortened, тобто «строк скоротили», і + // саме за цим полем шукатимуть того, після кого зникла історія. + Label: "Зміна строків зберігання даних", + }, +} + +var auditActionByKey = func() map[string]AuditActionInfo { + m := make(map[string]AuditActionInfo, len(auditActions)) + for _, a := range auditActions { + m[a.Key] = a + } + return m +}() + +// AuditActions — словник для фільтра на сторінці. +func AuditActions() []AuditActionInfo { return auditActions } + +// auditObjectTypes — назви типів об'єктів. +// +// Окремо від дій, бо це різні питання: дія — що зробили, тип — над чим. +// Один тип зачіпають кілька дій, і зводити їх в одну таблицю означало б +// повторювати назву об'єкта в кожному рядку. +// +// Перелік, а не мапа: порядок тут значущий — типи йдуть у тому ж +// порядку, у якому людина зустрічає їх у фільтрі дій, а мапа порядку не +// має. Раніше поруч лежав окремий список `order`, і будь-який новий тип +// мовчки не потрапляв у фільтр доти, доки його не допишуть удруге. +var auditObjectTypes = []AuditActionInfo{ + {Key: AuditObjectDevice, Label: "Хост"}, + {Key: AuditObjectCommandRun, Label: "Прогін команд"}, + {Key: AuditObjectRollback, Label: "Відкат конфігу"}, + {Key: AuditObjectRollbackPolicy, Label: "Правила погодження відкату"}, + {Key: AuditObjectConfig, Label: "Версія конфігу"}, + {Key: AuditObjectBackupDefaults, Label: "Налаштування бекапів"}, + {Key: AuditObjectMirror, Label: "Дзеркало Git"}, + {Key: AuditObjectRole, Label: "Роль"}, + {Key: AuditObjectTelegram, Label: "Прив'язка Telegram"}, + {Key: AuditObjectRetention, Label: "Строки зберігання даних"}, +} + +var auditObjectTypeByKey = func() map[string]string { + m := make(map[string]string, len(auditObjectTypes)) + for _, o := range auditObjectTypes { + m[o.Key] = o.Label + } + return m +}() + +// AuditObjectTypes — словник типів для фільтра. +func AuditObjectTypes() []AuditActionInfo { return auditObjectTypes } + +// decorateAuditEvent дописує до події те, що знає словник. +func decorateAuditEvent(e *AuditEvent) { + if a, ok := auditActionByKey[e.Action]; ok { + e.ActionLabel = a.Label + e.ActionGroup = a.Group + e.Destructive = a.Destructive + } else { + // Ключа немає в словнику. Не вигадуємо назву з ключа — назва, + // зібрана з крапок, читається як фраза, але означає лише те, що + // хтось назвав змінну. Порожня мітка чесніша: сторінка покаже + // ключ і позначить, що назви для нього немає. + e.ActionGroup = auditActionGroupFromKey(e.Action) + } + if l, ok := auditObjectTypeByKey[e.ObjectType]; ok { + e.ObjectTypeLabel = l + } +} + +// auditActionGroupFromKey — до якого розділу віднести незнайому дію. +// +// Це єдине, що з ключа справді виводиться: перший сегмент — схема бази, +// і вона не змінюється разом із формулюванням. +func auditActionGroupFromKey(key string) string { + head, _, _ := strings.Cut(key, ".") + switch head { + case "inv": + return "Інвентар" + case "ncm": + return "Конфігурації" + case "topo": + return "Топологія" + case "alr": + return "Сповіщення" + case "core": + return "Адміністрування" + default: + return "" + } +} + +// AuditBlindSpots — чесний перелік того, чого журнал НЕ бачить. +// +// Сторінка без цього блоку створює хибне відчуття повноти: «тут нічого +// немає» читається як «нічого не робили», хоча означає лише «це місце +// продукту в журнал не пише». Найдорожча помилка журналу — не +// неправильний запис, а відсутній: неправильний помітно, відсутнього +// немає з чим порівняти. +// +// Перелік складено від протилежного до auditActions: усе, що не +// перелічено там, не записується. Рядки тримаються поруч зі словником +// саме тому, що правити їх треба разом — новий виклик WriteAudit має +// одночасно з'явитись у словнику й зникнути звідси. +func AuditBlindSpots() []string { + return []string{ + "Вхід у систему, вихід і невдалі спроби входу. Спроби входу — і вдалі, і ні — лягають в окрему таблицю core.login_attempts, а не сюди: журнал аудиту вимагає tenant_id, а на момент перевірки пароля кабінет ще невідомий. Тобто цієї сторінки для питання «хто заходив» замало.", + "Поодинокі зміни хостів: створення, правка й видалення одного хоста в його картці. У журнал пишуть лише масові дії — а «видалив один хост» і «видалив сорок» відрізняються масштабом, не суттю. Виняток — повернення хоста з архіву й повне видалення: обидві йдуть масовим шляхом навіть для одного хоста, тож записуються завжди.", + "Склад команди: запрошення, зміна ролі людини, вилучення з кабінету. САМІ ролі — створення, зміна набору прав, видалення — з 0053 у журналі є (core.role.*), а от «кому цю роль видали» — ні. Тобто на питання «звідки в цієї людини такий доступ» журнал відповідає лише наполовину: що дозволяє роль, видно, хто в ній опинився — ні.", + "Доступи до обладнання: створення, правка й видалення облікових даних, а також те, кому їх призначили.", + "Мапи, групи, шаблони, правила алертів і канали сповіщень. Правка мапи оборотна й видима в самій мапі, але «хто вимкнув правило, за яким приходив алерт» звідси не видно.", + "Ручний запуск збору конфігу. Відкат конфігурації журнал бачить з 0060 — намір, погодження, відмову й зміну правил погодження, — а от «зібрати зараз» лишається поза ним: збір нічого не змінює на пристрої.", + "Читання. Журнал записує зміни, а не перегляди: те, що хтось відкрив чужий конфіг або вивантажив архів, тут не з'явиться — окрім вивантаження звіту про прогін команд.", + "Дії зондів і колектора: усе, що система робить сама за розкладом, журналом не покривається — це не дії людини, і їхнє місце в логах служб.", + } +} diff --git a/server/internal/store/audit_actions_source_test.go b/server/internal/store/audit_actions_source_test.go new file mode 100644 index 0000000..ee28861 --- /dev/null +++ b/server/internal/store/audit_actions_source_test.go @@ -0,0 +1,409 @@ +package store + +import ( + "go/ast" + "go/parser" + "go/token" + "io/fs" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// Тест, який не дає новій дії потрапити в журнал безіменною. +// +// ЧОМУ ЦЕ НЕ ПЕРЕВІРИТИ ІНАКШЕ +// +// Словник auditActions — єдине місце, де ключ `ncm.mirror.push` +// перетворюється на «Примусовий пуш архіву на дзеркало». Дописати туди +// рядок нічого не коштує; проблема в тому, що ЗАБУТИ його теж нічого не +// коштує й нічим не проявляється: запис у журнал іде, сторінка +// відкривається, фільтр працює. Єдиний, хто побачить пропуск, — людина, +// яка через місяць читає журнал і бачить у ньому сирий ключ замість +// назви. Саме так у продукті й опинились без назв дзеркало Git, ролі та +// прив'язка хоста до зонда — усі три додали правильно, просто в іншому +// файлі. +// +// Звідси вимога до перевірки: вона має брати перелік ключів НЕ з +// окремого списку (той розійшовся б із кодом так само), а з самого +// коду — з тих місць, де ключ реально лягає в AuditEntry.Action. Тому +// тест розбирає дерево server/ як текст програми й дивиться на кожне +// таке присвоєння. +// +// ЩО САМЕ ВИМАГАЄТЬСЯ ВІД АВТОРА НОВОЇ ДІЇ +// +// 1. Ключ оголошено константою в audit_actions.go (AuditAction… / +// AuditObject…). Літерал у місці виклику тест вважає помилкою — не +// з любові до констант, а тому, що інакше ключ має два місця +// оголошення й розходяться вони мовчки. +// 2. У константи є рядок у словнику. +// +// Обидві вимоги — це одна дія автора: дописати константу поруч із +// назвою. Тест лише робить її обов'язковою. +// +// ЧОГО ТЕСТ НЕ ВИМАГАЄ +// +// Зворотного — щоб кожен рядок словника десь писався. Журнал старший за +// код: у ньому лежать дії збірок, яких уже немає, і назви до них мають +// лишатись, поки лежать самі рядки. + +// auditScanRoot — корінь розбору. Не лише internal/: дію в журнал може +// писати й службова команда з cmd/, і пропустити її було б рівно тією +// помилкою, від якої цей тест і стоїть. +const auditScanRoot = "../.." + +// auditKeyUse — одне місце в коді, де ключ лягає в AuditEntry. +type auditKeyUse struct { + pos string // файл:рядок + field string // Action або ObjectType + key string // саме значення ключа + literal bool // ключ написано рядком просто в місці виклику +} + +func TestAuditDictionaryCoversEveryWrittenKey(t *testing.T) { + fset := token.NewFileSet() + files := parseAuditScanTree(t, fset) + consts := auditConstValues(files) + + uses := collectAuditKeyUses(t, fset, files, consts) + + // Захист від «тест зелений, бо нічого не знайшов». Перейменують + // AuditEntry — і мовчазний нуль знахідок виглядав би як успіх. + var actions int + for _, u := range uses { + if u.field == "Action" { + actions++ + } + } + if actions < 10 { + t.Fatalf("розбір знайшов лише %d присвоєнь AuditEntry.Action — "+ + "так мало бути не може; найімовірніше, змінилась назва типу "+ + "або структура виклику, і перевірка перестала щось перевіряти", actions) + } + + for _, u := range uses { + if u.literal { + t.Errorf("%s: ключ %q написано рядком просто в місці виклику. "+ + "Заведіть константу в store/audit_actions.go — інакше ключ має "+ + "два місця оголошення, і словник розійдеться з кодом мовчки", + u.pos, u.key) + // Далі все одно перевіряємо: відсутня назва — окрема біда. + } + switch u.field { + case "Action": + if _, ok := auditActionByKey[u.key]; !ok { + t.Errorf("%s: дія %q пишеться в журнал, але назви для неї немає. "+ + "Допишіть рядок у auditActions (store/audit_actions.go) — "+ + "інакше адміністратор побачить у журналі сирий ключ", + u.pos, u.key) + } + case "ObjectType": + if _, ok := auditObjectTypeByKey[u.key]; !ok { + t.Errorf("%s: тип об'єкта %q пишеться в журнал, але назви для нього "+ + "немає. Допишіть рядок у auditObjectTypes (store/audit_actions.go)", + u.pos, u.key) + } + } + } +} + +// Константи без назви — та сама діра з іншого боку. +// +// Ключ, оголошений константою й ще нікуди не підставлений, потрапить у +// журнал наступним комітом, і на той момент про словник ніхто вже не +// згадає. Дешевше вимагати назву одразу. +func TestAuditConstantsHaveLabels(t *testing.T) { + fset := token.NewFileSet() + files := parseAuditScanTree(t, fset) + + for name, val := range auditConstValues(files) { + switch { + case strings.HasPrefix(name, "AuditObject"): + if _, ok := auditObjectTypeByKey[val]; !ok { + t.Errorf("константа %s = %q оголошена, але назви в auditObjectTypes немає", + name, val) + } + case strings.HasPrefix(name, "AuditAction"): + if _, ok := auditActionByKey[val]; !ok { + t.Errorf("константа %s = %q оголошена, але назви в auditActions немає", name, val) + } + } + } +} + +// Сам словник теж має бути справним: порожня назва або два рядки з +// однаковим ключем ламають фільтр на сторінці мовчки. +func TestAuditDictionaryWellFormed(t *testing.T) { + seen := map[string]bool{} + for _, a := range auditActions { + if a.Key == "" || a.Label == "" || a.Group == "" { + t.Errorf("дія %+v: ключ, назва й розділ обов'язкові", a) + } + if seen[a.Key] { + t.Errorf("ключ %q описано двічі", a.Key) + } + seen[a.Key] = true + // Звіряти розділ із тим, що виводиться з ключа + // (auditActionGroupFromKey), тут НЕ можна: «Команди» — + // повноцінний розділ фільтра, а ключ у прогонів команд + // починається з ncm, тобто з ключа виводиться «Конфігурації». + // Розбіжність тут навмисна: з ключа розділ виводять лише для + // дії, назви якої немає взагалі, і це запасний варіант, а не + // друге джерело правди. + } + + seen = map[string]bool{} + for _, o := range auditObjectTypes { + if o.Key == "" || o.Label == "" { + t.Errorf("тип об'єкта %+v: ключ і назва обов'язкові", o) + } + if seen[o.Key] { + t.Errorf("тип об'єкта %q описано двічі", o.Key) + } + seen[o.Key] = true + } +} + +// --------------------------------------------------------------------- +// Розбір дерева +// --------------------------------------------------------------------- + +func parseAuditScanTree(t *testing.T, fset *token.FileSet) []*ast.File { + t.Helper() + + var out []*ast.File + err := filepath.WalkDir(auditScanRoot, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + switch d.Name() { + case ".git", "node_modules", "webui", "migrations": + return fs.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + src, err := os.ReadFile(path) + if err != nil { + return err + } + f, err := parser.ParseFile(fset, path, src, 0) + if err != nil { + return err + } + out = append(out, f) + return nil + }) + if err != nil { + t.Fatalf("розбір дерева %s: %v", auditScanRoot, err) + } + if len(out) == 0 { + t.Fatalf("у %s не знайдено жодного файлу .go", auditScanRoot) + } + return out +} + +// auditConstValues — усі рядкові константи AuditAction… / AuditObject… +// разом з їхніми значеннями. +func auditConstValues(files []*ast.File) map[string]string { + out := map[string]string{} + for _, f := range files { + for _, decl := range f.Decls { + gd, ok := decl.(*ast.GenDecl) + if !ok || gd.Tok != token.CONST { + continue + } + for _, spec := range gd.Specs { + vs, ok := spec.(*ast.ValueSpec) + if !ok { + continue + } + for i, name := range vs.Names { + if i >= len(vs.Values) { + continue + } + if !strings.HasPrefix(name.Name, "AuditAction") && + !strings.HasPrefix(name.Name, "AuditObject") { + continue + } + bl, ok := vs.Values[i].(*ast.BasicLit) + if !ok || bl.Kind != token.STRING { + continue + } + v, err := strconv.Unquote(bl.Value) + if err != nil { + continue + } + out[name.Name] = v + } + } + } + } + return out +} + +// collectAuditKeyUses — усі місця, де в AuditEntry кладуть Action або +// ObjectType. +// +// Окремий випадок, заради якого тут узагалі є другий прохід: половина +// дій пишеться не напряму, а через невеликий помічник — +// auditRole(r, p, "core.role.create", …), auditSelfAgent(…). У самому +// виклику WriteAudit там стоїть параметр, а не ключ, і взяти значення +// з того рядка неможливо. Тому такі місця відкладаються, а потім +// шукаються виклики самого помічника — саме там ключ і написано. +func collectAuditKeyUses(t *testing.T, fset *token.FileSet, files []*ast.File, + consts map[string]string) []auditKeyUse { + + t.Helper() + + type pending struct { + fn string // ім'я функції-помічника + param string // ім'я параметра, у якому їде ключ + field string + pos string + } + + var uses []auditKeyUse + var pend []pending + // Кожна функція дерева — на випадок, якщо ключ їде в неї параметром. + funcs := map[string]*ast.FuncDecl{} + + resolve := func(expr ast.Expr, field, pos string) (auditKeyUse, bool, string) { + switch v := expr.(type) { + case *ast.BasicLit: + if v.Kind != token.STRING { + return auditKeyUse{}, false, "не рядок" + } + s, err := strconv.Unquote(v.Value) + if err != nil { + return auditKeyUse{}, false, "не розібрати рядок" + } + return auditKeyUse{pos: pos, field: field, key: s, literal: true}, true, "" + case *ast.Ident: + if s, ok := consts[v.Name]; ok { + return auditKeyUse{pos: pos, field: field, key: s}, true, "" + } + return auditKeyUse{}, false, v.Name + case *ast.SelectorExpr: + if s, ok := consts[v.Sel.Name]; ok { + return auditKeyUse{pos: pos, field: field, key: s}, true, "" + } + return auditKeyUse{}, false, v.Sel.Name + } + return auditKeyUse{}, false, "вираз" + } + + for _, f := range files { + for _, decl := range f.Decls { + fd, ok := decl.(*ast.FuncDecl) + if !ok || fd.Body == nil { + continue + } + funcs[fd.Name.Name] = fd + + ast.Inspect(fd.Body, func(n ast.Node) bool { + cl, ok := n.(*ast.CompositeLit) + if !ok || !isAuditEntryType(cl.Type) { + return true + } + for _, el := range cl.Elts { + kv, ok := el.(*ast.KeyValueExpr) + if !ok { + continue + } + key, ok := kv.Key.(*ast.Ident) + if !ok || (key.Name != "Action" && key.Name != "ObjectType") { + continue + } + pos := fset.Position(kv.Pos()).String() + use, ok, what := resolve(kv.Value, key.Name, pos) + if ok { + uses = append(uses, use) + continue + } + // Не вивелось: або це параметр помічника, або щось + // таке, з чого ключ не дістати взагалі. + pend = append(pend, pending{ + fn: fd.Name.Name, param: what, field: key.Name, pos: pos, + }) + } + return true + }) + } + } + + for _, p := range pend { + fd := funcs[p.fn] + idx := -1 + if fd != nil && fd.Type.Params != nil { + i := 0 + for _, field := range fd.Type.Params.List { + for _, name := range field.Names { + if name.Name == p.param { + idx = i + } + i++ + } + } + } + if idx < 0 { + t.Errorf("%s: у %s.%s підставляють %s — значення ключа не виводиться з коду. "+ + "Передавайте константу store.AuditAction… (хай навіть через параметр "+ + "функції-помічника), інакше цю дію нічим не перевірити", + p.pos, p.fn, p.field, p.param) + continue + } + + found := 0 + for _, f := range files { + ast.Inspect(f, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok || calleeName(call.Fun) != p.fn || idx >= len(call.Args) { + return true + } + pos := fset.Position(call.Pos()).String() + use, ok, what := resolve(call.Args[idx], p.field, pos) + if !ok { + t.Errorf("%s: у виклик %s ключ %s їде як %s — значення не виводиться "+ + "з коду; підставте константу store.AuditAction…", + pos, p.fn, p.field, what) + return true + } + found++ + uses = append(uses, use) + return true + }) + } + if found == 0 { + t.Errorf("%s: %s приймає ключ %s параметром, але жодного виклику не знайдено — "+ + "перевірити цю дію нічим", p.pos, p.fn, p.field) + } + } + + return uses +} + +func isAuditEntryType(expr ast.Expr) bool { + switch v := expr.(type) { + case *ast.Ident: + return v.Name == "AuditEntry" + case *ast.SelectorExpr: + return v.Sel.Name == "AuditEntry" + } + return false +} + +func calleeName(expr ast.Expr) string { + switch v := expr.(type) { + case *ast.Ident: + return v.Name + case *ast.SelectorExpr: + return v.Sel.Name + } + return "" +} diff --git a/server/internal/store/audit_read.go b/server/internal/store/audit_read.go new file mode 100644 index 0000000..f78551f --- /dev/null +++ b/server/internal/store/audit_read.go @@ -0,0 +1,815 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sort" + "strconv" + "strings" + "time" + + "github.com/jackc/pgx/v5" +) + +// Читання журналу аудиту. +// +// Окремо від audit.go навмисно: там одна функція, яка кладе подію, і +// вона не має знати нічого про те, як журнал потім розглядають. Запис +// іде з десятка місць продукту й мусить лишатись дешевим і незмінним; +// читання — це фільтри, курсор і словник, який житиме своїм життям. +// +// Головне обмеження шару: RLS на core.audit_log НЕ ДІЄ й діяти не +// може — це гіпертаблиця, а TimescaleDB не поєднує row level security +// зі стисненням (0011). Тому предикат tenant_id стоїть у КОЖНОМУ запиті +// нижче явно, як єдиний механізм ізоляції, а не як дублювання. +// +// Виняток — два LEFT JOIN на core.users і core.api_tokens, якими +// журнал добирає імена акторів. Ці таблиці під політиками, тож обидва +// читання йдуть через InTenantTx. Без контексту вони не впали б, а +// повернули NULL: перелік подій лишився б, а колонка «хто» стала б +// порожньою. Це єдине місце в шарі, де забутий контекст дає не +// порожній екран, а правдоподібну неправду. + +// AuditFilter — питання, з яким приходять на сторінку журналу. +type AuditFilter struct { + // Період обов'язковий і завжди заданий клієнтом. Журнал росте + // вічно, і запит без нижньої межі — це запит по всій історії + // організації; те, що він зазвичай швидкий завдяки курсору, не + // робить його осмисленим. + From time.Time + To time.Time + + Actions []string + ObjectTypes []string + // Хто саме — двома окремими переліками, бо це два різні стовпці й + // два різні види діяча. Обрані разом вони складаються через АБО: + // питання «що робили Петро й наша інтеграція» — одне питання. + ActorUserIDs []string + ActorTokenIDs []string + // AnyToken — «будь-який машинний токен», тобто рядок без людини. + // Потрібне окремо від переліку токенів: відкликаний токен зі списку + // зникає, а його сліди в журналі лишаються назавжди — і саме до них + // найчастіше й буває питання. + AnyToken bool + // Адреса або підмережа: '10.20.0.5' і '10.20.0.0/24' обидві + // працюють через один оператор <<=. + ActorIP string + + // Пошук підрядком по before/after/meta. Саме там лежать імена + // хостів і тексти команд — тобто те, за чим журнал і шукають. + Query string + + Limit int + Cursor string +} + +// AuditActor — хто зробив дію. +// +// Користувач і токен окремими полями, а не одним «хто»: за токеном +// інтеграції немає людини, і зводити їх до рядка означало б втратити +// різницю між «інженер натиснув кнопку» і «скрипт ходить нашим API». +// Ім'я користувача підтягується на момент ЧИТАННЯ — якщо людину +// перейменували, у журналі має бути видно поточне ім'я, бо саме за ним +// її впізнають сьогодні. +type AuditActor struct { + UserID string `json:"user_id,omitempty"` + Username string `json:"username,omitempty"` + Email string `json:"email,omitempty"` + TokenID string `json:"token_id,omitempty"` + TokenName string `json:"token_name,omitempty"` + IP string `json:"ip,omitempty"` +} + +// AuditEvent — рядок журналу в переліку. +// +// Без before/after/meta: у прогоні команд на п'ятсот хостів meta несе +// п'ятсот ідентифікаторів та імен, і п'ятдесят таких рядків — це +// мегабайти на кожне гортання. Замість вмісту тут його стислий переказ +// (Summary) і чесна вага (Bytes); повний вміст приїздить окремо, коли +// на запис натиснули. +type AuditEvent struct { + ID string `json:"id"` + TS time.Time `json:"ts"` + + Action string `json:"action"` + // Людська назва дії. Порожня для ключа, якого немає в словнику, — + // і це не помилка: журнал старший за словник, і рядок, назви якому + // ще не придумали, має бути видно як є. + ActionLabel string `json:"action_label,omitempty"` + ActionGroup string `json:"action_group,omitempty"` + // Руйнівна дія: те, після чого об'єкта більше немає. Саме за цим + // журнал переглядають найчастіше. + Destructive bool `json:"destructive,omitempty"` + + ObjectType string `json:"object_type,omitempty"` + ObjectTypeLabel string `json:"object_type_label,omitempty"` + ObjectID string `json:"object_id,omitempty"` + + Actor AuditActor `json:"actor"` + + Summary string `json:"summary,omitempty"` + // Скільки важать before/after/meta разом. Показується поруч із + // кнопкою розгортання: людина має бачити, що зараз завантажить. + Bytes int `json:"bytes"` + HasBefore bool `json:"has_before"` + HasAfter bool `json:"has_after"` + HasDetails bool `json:"has_details"` +} + +// AuditEventDetail — те, що показують по натисканню. +type AuditEventDetail struct { + AuditEvent + Before json.RawMessage `json:"before,omitempty"` + After json.RawMessage `json:"after,omitempty"` + Meta json.RawMessage `json:"meta,omitempty"` +} + +// AuditPage — сторінка журналу. +type AuditPage struct { + // Період, у якому відповідь насправді зібрана. Повертається назад + // тому, що межі мають типові значення («останні 30 днів»), а + // «останні» — це момент першого запиту. Клієнт закріплює отриману + // пару й шле її з кожною наступною сторінкою: інакше подія, що + // сталася під час гортання, зсунула б усю вибірку на рядок. + From time.Time `json:"from"` + To time.Time `json:"to"` + + Events []AuditEvent `json:"events"` + // Курсор наступної сторінки. Порожній означає «більше немає» — + // саме означає, а не «здається»: сторінка береться з limit+1 + // рядка, тож питання «чи є ще» відповідає сама вибірка, а не + // окремий COUNT по всьому журналу. + NextCursor string `json:"next_cursor,omitempty"` +} + +const ( + auditDefaultLimit = 50 + auditMaxLimit = 200 +) + +// --------------------------------------------------------------------- +// Курсор +// --------------------------------------------------------------------- + +// Курсор — це пара (ts, id) останнього показаного рядка, а не номер +// сторінки. +// +// OFFSET на журналі означає «прочитати сто тисяч рядків, щоб показати +// двадцять», і ціна росте разом із глибиною. Виміряно на 500 тис. +// рядків: курсор на 100 000-му рядку — 1.3 мс і 147 буферів, той самий +// рядок через OFFSET 100000 — 46 мс і 100 416 буферів. +// +// Пара, а не сам лише ts: дві події однієї дії отримують один ts із +// точністю до мікросекунди, і межа лише за часом або губила б другий +// рядок, або показувала б перший двічі. +// +// Мікросекунди, а не RFC3339 з наносекундами: timestamptz у Postgres +// має саме мікросекундну точність, тож так пара повертається в базу +// точно такою, якою звідти приїхала. +func encodeAuditCursor(ts time.Time, id string) string { + return strconv.FormatInt(ts.UnixMicro(), 10) + "." + id +} + +func decodeAuditCursor(s string) (time.Time, string, error) { + micros, id, ok := strings.Cut(s, ".") + if !ok { + return time.Time{}, "", fmt.Errorf("%w: зіпсований курсор", ErrInvalid) + } + n, err := strconv.ParseInt(micros, 10, 64) + if err != nil { + return time.Time{}, "", fmt.Errorf("%w: зіпсований курсор", ErrInvalid) + } + if !isUUID(id) { + return time.Time{}, "", fmt.Errorf("%w: зіпсований курсор", ErrInvalid) + } + return time.UnixMicro(n).UTC(), id, nil +} + +func isUUID(s string) bool { + if len(s) != 36 { + return false + } + for i, c := range s { + if i == 8 || i == 13 || i == 18 || i == 23 { + if c != '-' { + return false + } + continue + } + if !(c >= '0' && c <= '9') && !(c >= 'a' && c <= 'f') && !(c >= 'A' && c <= 'F') { + return false + } + } + return true +} + +// --------------------------------------------------------------------- +// Вибірка +// --------------------------------------------------------------------- + +// ListAudit повертає сторінку журналу. +func (s *Store) ListAudit(ctx context.Context, tenantID string, f AuditFilter) (AuditPage, error) { + limit := f.Limit + if limit <= 0 { + limit = auditDefaultLimit + } + if limit > auditMaxLimit { + limit = auditMaxLimit + } + + where, args, err := auditWhere(tenantID, f) + if err != nil { + return AuditPage{}, err + } + + // Беремо на один рядок більше, ніж показуємо: саме він і є + // відповіддю на «чи є наступна сторінка». Окремий COUNT(*) по + // журналу коштував би повного проходу за фільтром — тобто рівно + // того, чого курсор і уникає. + args = append(args, limit+1) + limitArg := len(args) + + inner := ` + SELECT ts, id, action, object_type, object_id, + actor_user_id, actor_token_id, host(actor_ip) AS actor_ip, + (before IS NOT NULL) AS has_before, + (after IS NOT NULL) AS has_after, + coalesce(octet_length(before::text), 0) + + coalesce(octet_length(after::text), 0) + + coalesce(octet_length(meta::text), 0) AS bytes, + meta + FROM core.audit_log + WHERE ` + where + + if f.Query != "" { + // Пошук по вмісту ставиться в підзапит із OFFSET 0. + // + // Це бар'єр планувальника, і без нього план виходить + // катастрофічним саме в найчастішому випадку. З ORDER BY ts + // DESC + LIMIT планувальник обирає впорядкований індекс і + // розраховує зупинитись, щойно набере двадцять рядків, — а + // коли шуканого підрядка в журналі немає (а це і є типовий + // результат пошуку за іменем хоста), він доходить до кінця + // історії, жодного разу не заглянувши в триграмний індекс. + // + // Виміряно на 500 тис. рядків, пошук без збігів за два роки: + // 634 мс і 167 368 буферів проти 64 мс і 6 016 із бар'єром. + // Ціною є те, що широкий запит («Бровари») збирає всі збіги й + // сортує їх — 42 мс на тих самих даних; це вища й передбачувана + // стеля замість нижчої й випадкової. + inner += "\n\t\tOFFSET 0" + inner = "SELECT * FROM (" + inner + "\n\t\t) q" + } + + q := ` + WITH page AS (` + inner + ` + ORDER BY ts DESC, id DESC + LIMIT $` + strconv.Itoa(limitArg) + ` + ) + SELECT p.ts, p.id, p.action, p.object_type, p.object_id, + p.actor_user_id, p.actor_token_id, p.actor_ip, + p.has_before, p.has_after, p.bytes, p.meta, + u.username, u.email, t.name + FROM page p + LEFT JOIN core.users u ON u.id = p.actor_user_id + LEFT JOIN core.api_tokens t ON t.id = p.actor_token_id AND t.tenant_id = $1 + ORDER BY p.ts DESC, p.id DESC` + + // Тенантна транзакція потрібна не журналу, а двом LEFT JOIN у + // хвості запиту. core.audit_log — гіпертаблиця, RLS на ній немає, і + // сам перелік прочитався б і без контексту. А от core.users і + // core.api_tokens під політиками: без app.tenant_id обидва JOIN + // дали б NULL, і журнал показував би ті самі рядки з порожньою + // колонкою «хто». Це єдиний тут випадок, коли забутий контекст дає + // не порожній екран, а правдоподібну неправду — журнал, у якому всі + // дії анонімні. + out := AuditPage{From: f.From, To: f.To, Events: []AuditEvent{}} + err = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, q, args...) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + e, err := scanAuditEvent(rows) + if err != nil { + return err + } + out.Events = append(out.Events, e) + } + return rows.Err() + }) + if err != nil { + return AuditPage{}, err + } + + if len(out.Events) > limit { + last := out.Events[limit-1] + out.Events = out.Events[:limit] + out.NextCursor = encodeAuditCursor(last.TS, last.ID) + } + return out, nil +} + +// auditWhere складає умову й аргументи. $1 завжди tenant_id. +func auditWhere(tenantID string, f AuditFilter) (string, []any, error) { + args := []any{tenantID} + // Предикат tenant_id стоїть тут, а не покладається на RLS, і це не + // перестраховка: політик на core.audit_log немає взагалі й не буде + // (гіпертаблиця). Прибрати цей рядок означає показати один кабінет + // іншому — і після 0063 це лишається правдою рівно для журналу й + // решти гіпертаблиць, тоді як усюди ще й спрацює політика. + cond := []string{"tenant_id = $1"} + + add := func(sql string, v any) { + args = append(args, v) + cond = append(cond, fmt.Sprintf(sql, len(args))) + } + + if !f.From.IsZero() { + add("ts >= $%d", f.From) + } + if !f.To.IsZero() { + add("ts < $%d", f.To) + } + if len(f.Actions) > 0 { + add("action = ANY($%d)", f.Actions) + } + if len(f.ObjectTypes) > 0 { + add("object_type = ANY($%d)", f.ObjectTypes) + } + + // Актори: обране з різних стовпців зводиться в одну умову через АБО. + // Окремими умовами воно означало б перетин («що робив Петро ЧЕРЕЗ + // цей токен»), а такого рядка не буває взагалі — актор у події + // рівно один. + var actorOr []string + if len(f.ActorUserIDs) > 0 { + args = append(args, f.ActorUserIDs) + actorOr = append(actorOr, fmt.Sprintf("actor_user_id = ANY($%d)", len(args))) + } + if len(f.ActorTokenIDs) > 0 { + args = append(args, f.ActorTokenIDs) + actorOr = append(actorOr, fmt.Sprintf("actor_token_id = ANY($%d)", len(args))) + } + if f.AnyToken { + actorOr = append(actorOr, "actor_user_id IS NULL") + } + if len(actorOr) > 0 { + cond = append(cond, "("+strings.Join(actorOr, " OR ")+")") + } + + if ip := strings.TrimSpace(f.ActorIP); ip != "" { + // <<= бере і точну адресу, і підмережу: одна умова замість + // двох режимів у інтерфейсі. + add("actor_ip <<= $%d::inet", ip) + } + + if q := strings.TrimSpace(f.Query); q != "" { + // Один вираз над трьома колонками — рівно той, на якому стоїть + // триграмний індекс із 0050. Три окремі ILIKE через OR дали б + // три сканування й BitmapOr замість одного. + add(`(coalesce(meta::text, '') || ' ' || + coalesce(before::text, '') || ' ' || + coalesce(after::text, '')) ILIKE $%d`, "%"+escapeLike(q)+"%") + } + + if f.Cursor != "" { + ts, id, err := decodeAuditCursor(f.Cursor) + if err != nil { + return "", nil, err + } + args = append(args, ts, id) + cond = append(cond, fmt.Sprintf("(ts, id) < ($%d, $%d)", len(args)-1, len(args))) + } + + return strings.Join(cond, "\n\t\t AND "), args, nil +} + +// escapeLike знешкоджує символи шаблону. +// +// Без цього набране в полі пошуку «%» знаходить геть усе, а «_» — +// будь-який символ: людина шукає підрядок, а не пише шаблон, і +// підсунути їй мову шаблонів мовчки означає давати неправильні +// відповіді на правильні питання. +func escapeLike(s string) string { + r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`) + return r.Replace(s) +} + +func scanAuditEvent(rows pgx.Rows) (AuditEvent, error) { + var ( + e AuditEvent + objType *string + objID *string + userID *string + tokenID *string + ip *string + meta []byte + username *string + email *string + tokenName *string + ) + if err := rows.Scan(&e.TS, &e.ID, &e.Action, &objType, &objID, + &userID, &tokenID, &ip, + &e.HasBefore, &e.HasAfter, &e.Bytes, &meta, + &username, &email, &tokenName); err != nil { + return AuditEvent{}, err + } + + // deref живе в autoassign.go — той самий пакет, той самий сенс; + // друга копія на п'ять рядків розійшлася б із першою рівно тоді, + // коли одну з них комусь захочеться «покращити». + e.TS = e.TS.UTC() + e.ObjectType = deref(objType) + e.ObjectID = deref(objID) + e.Actor = AuditActor{ + UserID: deref(userID), + Username: deref(username), + Email: deref(email), + TokenID: deref(tokenID), + TokenName: deref(tokenName), + IP: deref(ip), + } + decorateAuditEvent(&e) + e.Summary = auditSummary(meta) + e.HasDetails = e.Bytes > 2 // «{}» — це не подробиці + return e, nil +} + +// GetAuditEvent повертає один запис разом із before/after/meta. +// +// ts у ключі не примха: первинний ключ гіпертаблиці — (ts, id), і без +// часу пошук за самим лише id означав би перебір усіх чанків. Клієнт +// його завжди має — він щойно бачив цей рядок у переліку. +func (s *Store) GetAuditEvent(ctx context.Context, tenantID, id string, ts time.Time) (AuditEventDetail, error) { + if !isUUID(id) { + return AuditEventDetail{}, fmt.Errorf("%w: некоректний ідентифікатор", ErrInvalid) + } + + var ( + d AuditEventDetail + objType *string + objID *string + userID *string + tokenID *string + ip *string + before []byte + after []byte + meta []byte + username *string + email *string + tokenName *string + ) + // Транзакція — заради тих самих двох LEFT JOIN, що й у ListAudit. + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + return tx.QueryRow(ctx, ` + SELECT a.ts, a.id, a.action, a.object_type, a.object_id, + a.actor_user_id, a.actor_token_id, host(a.actor_ip), + (a.before IS NOT NULL), (a.after IS NOT NULL), + coalesce(octet_length(a.before::text), 0) + + coalesce(octet_length(a.after::text), 0) + + coalesce(octet_length(a.meta::text), 0), + a.before, a.after, a.meta, + u.username, u.email, t.name + FROM core.audit_log a + LEFT JOIN core.users u ON u.id = a.actor_user_id + LEFT JOIN core.api_tokens t ON t.id = a.actor_token_id AND t.tenant_id = $1 + WHERE a.tenant_id = $1 AND a.id = $2 AND a.ts = $3 + `, tenantID, id, ts).Scan(&d.TS, &d.ID, &d.Action, &objType, &objID, + &userID, &tokenID, &ip, + &d.HasBefore, &d.HasAfter, &d.Bytes, + &before, &after, &meta, + &username, &email, &tokenName) + }) + if errors.Is(err, pgx.ErrNoRows) { + return AuditEventDetail{}, ErrNotFound + } + if err != nil { + return AuditEventDetail{}, err + } + + d.TS = d.TS.UTC() + d.ObjectType = deref(objType) + d.ObjectID = deref(objID) + d.Actor = AuditActor{ + UserID: deref(userID), + Username: deref(username), + Email: deref(email), + TokenID: deref(tokenID), + TokenName: deref(tokenName), + IP: deref(ip), + } + decorateAuditEvent(&d.AuditEvent) + d.Summary = auditSummary(meta) + d.HasDetails = d.Bytes > 2 + d.Before = json.RawMessage(before) + d.After = json.RawMessage(after) + d.Meta = json.RawMessage(meta) + return d, nil +} + +// --------------------------------------------------------------------- +// Межі журналу +// --------------------------------------------------------------------- + +// AuditBounds — від якої дати в журналі взагалі щось є. +// +// Потрібне не для краси. Писати в journal почали недавно, і порожня +// відповідь на «покажи березень» означає «у березні ще не писали», а не +// «у березні нічого не робили». Різницю між цими двома реченнями +// сторінка мусить показати сама, інакше вона тихо бреше. +type AuditBounds struct { + First *time.Time `json:"first,omitempty"` + Last *time.Time `json:"last,omitempty"` +} + +func (s *Store) AuditBounds(ctx context.Context, tenantID string) (AuditBounds, error) { + var b AuditBounds + // Два окремі запити з LIMIT 1, а не min()/max() одним рядком: + // впорядкований обхід гіпертаблиці зупиняється на першому ж чанку, + // у якому щось є, а агрегат на кожному краю читав би всі. + err := s.pool.QueryRow(ctx, ` + SELECT ts FROM core.audit_log WHERE tenant_id = $1 + ORDER BY ts ASC, id ASC LIMIT 1 + `, tenantID).Scan(&b.First) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return AuditBounds{}, err + } + err = s.pool.QueryRow(ctx, ` + SELECT ts FROM core.audit_log WHERE tenant_id = $1 + ORDER BY ts DESC, id DESC LIMIT 1 + `, tenantID).Scan(&b.Last) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return AuditBounds{}, err + } + if b.First != nil { + t := b.First.UTC() + b.First = &t + } + if b.Last != nil { + t := b.Last.UTC() + b.Last = &t + } + return b, nil +} + +// AuditActorOption — рядок у списку «хто саме». +type AuditActorOption struct { + ID string `json:"id"` + Label string `json:"label"` + // Kind: user | token. + Kind string `json:"kind"` +} + +// AuditActors — список для фільтра «хто». +// +// Береться зі складу кабінету й з переліку токенів, а не з DISTINCT по +// самому журналу. DISTINCT по колонці, яка стоїть другою в індексі, +// Postgres 16 без skip-scan виконує повним проходом — тобто ціна списку +// для випадаючого меню дорівнювала б ціні найважчого запиту сторінки. +// А головне, список зі складу кабінету відповідає на потрібне питання +// («хто в нас є») навіть тоді, коли людина ще нічого не зробила. +func (s *Store) AuditActors(ctx context.Context, tenantID string) ([]AuditActorOption, error) { + out := []AuditActorOption{} + + // Обидві таблиці під RLS, тому без контексту список «хто» був би + // порожній, а фільтр журналу — непрацездатний. + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, ` + SELECT u.id, u.username, coalesce(u.email::text, '') + FROM core.memberships m + JOIN core.users u ON u.id = m.user_id + WHERE m.tenant_id = $1 AND u.deleted_at IS NULL + ORDER BY u.username + `, tenantID) + if err != nil { + return err + } + for rows.Next() { + var id, username, email string + if err := rows.Scan(&id, &username, &email); err != nil { + rows.Close() + return err + } + label := username + if email != "" { + label = username + " · " + email + } + out = append(out, AuditActorOption{ID: id, Label: label, Kind: "user"}) + } + rows.Close() + if err := rows.Err(); err != nil { + return err + } + + trows, err := tx.Query(ctx, ` + SELECT id, name FROM core.api_tokens + WHERE tenant_id = $1 AND revoked_at IS NULL + ORDER BY name + `, tenantID) + if err != nil { + return err + } + defer trows.Close() + for trows.Next() { + var id, name string + if err := trows.Scan(&id, &name); err != nil { + return err + } + out = append(out, AuditActorOption{ID: id, Label: name, Kind: "token"}) + } + return trows.Err() + }) + if err != nil { + return nil, err + } + return out, nil +} + +// --------------------------------------------------------------------- +// Стислий переказ вмісту +// --------------------------------------------------------------------- + +// auditSummary робить із meta один рядок для переліку. +// +// Узагальнено, без гілки на кожну дію. Гілка на дію означала б, що +// рядок, доданий у продукт завтра, у переліку виглядатиме порожнім +// доти, доки хтось не згадає дописати сюди ще один case — а помітити це +// можна лише випадково, дивлячись саме на цю дію. +// +// Порядок ключів заданий явно: у meta вони йдуть як у Go-мапі, тобто +// випадково, і без сортування той самий рядок при кожному читанні +// виглядав би інакше. Спочатку — те, що відповідає на «скільки й чого», +// решта за абеткою. +func auditSummary(meta []byte) string { + if len(meta) < 3 { + return "" + } + var m map[string]any + if err := json.Unmarshal(meta, &m); err != nil || len(m) == 0 { + return "" + } + + // Ключі, які найкраще переказують дію. Технічні переліки + // ідентифікаторів (device_ids) свідомо не показуються: у переліку + // вони витісняють імена, а імена й є те, що людина впізнає. Самі + // ідентифікатори нікуди не діваються — вони у повному вмісті, який + // приїздить по натисканню. + preferred := []string{ + "devices", "deleted", "updated", "stopped", "names", "commands", + "counts", "status", "versions", "format", "enabled", "freed_bytes", + "created_by", "filter", + } + skip := map[string]bool{"device_ids": true} + + seen := map[string]bool{} + order := make([]string, 0, len(m)) + for _, k := range preferred { + if _, ok := m[k]; ok && !skip[k] { + order = append(order, k) + seen[k] = true + } + } + rest := make([]string, 0, len(m)) + for k := range m { + if !seen[k] && !skip[k] { + rest = append(rest, k) + } + } + sort.Strings(rest) + order = append(order, rest...) + + parts := make([]string, 0, 4) + for _, k := range order { + if len(parts) == 4 { + break + } + if v := auditValueText(m[k]); v != "" { + parts = append(parts, auditKeyLabel(k)+": "+v) + } + } + return strings.Join(parts, " · ") +} + +// auditKeyLabel — назва ключа meta українською. +// +// Словник лише на ті ключі, які продукт справді кладе в meta сьогодні; +// невідомий ключ показується як є. Це навмисно: переказ, у якому +// незнайомий ключ мовчки зник би, гірший за переказ, у якому видно +// англійське слово. Повний вміст по натисканню показує сирі ключі +// завжди — саме там правда, а тут її переказ. +var auditKeyLabels = map[string]string{ + "devices": "хостів", + "names": "хости", + "commands": "команди", + "counts": "результат", + "status": "стан", + "deleted": "видалено", + "updated": "змінено", + "stopped": "не почато", + "versions": "версії", + "kept": "лишилось", + "freed_bytes": "звільнено байтів", + "format": "формат", + "enabled": "увімкнено", + "days": "днів", + "filter": "фільтр", + "concurrency": "паралельно", + "timeout_sec": "таймаут, с", + "created_by": "створив", + "created_at": "створено", + "dropped": "відкинуто", + "checks": "перевірки", + "success": "успішно", + "failed": "невдало", + "canceled": "скасовано", + "pending": "у черзі", +} + +func auditKeyLabel(k string) string { + if l, ok := auditKeyLabels[k]; ok { + return l + } + return k +} + +// auditValueText — коротке подання одного значення. +func auditValueText(v any) string { + switch t := v.(type) { + case nil: + return "" + case bool: + if t { + return "так" + } + return "ні" + case float64: + if t == float64(int64(t)) { + return strconv.FormatInt(int64(t), 10) + } + return strconv.FormatFloat(t, 'f', -1, 64) + case string: + return clip(t, 60) + case []any: + if len(t) == 0 { + return "" + } + head := make([]string, 0, 2) + for _, e := range t { + if len(head) == 2 { + break + } + if s := auditValueText(e); s != "" { + head = append(head, s) + } + } + s := strings.Join(head, ", ") + if len(t) > len(head) { + s += fmt.Sprintf(" (+%d)", len(t)-len(head)) + } + return clip(s, 80) + case map[string]any: + // Вкладений об'єкт розкривається на один рівень, а не рахується. + // «{1}» не каже нічого; «успішно 1» — це і є те, чим прогін + // закінчився, і саме за цим у перелік дивляться. + if len(t) == 0 { + return "" + } + keys := make([]string, 0, len(t)) + for k := range t { + keys = append(keys, k) + } + sort.Strings(keys) + head := make([]string, 0, 3) + for _, k := range keys { + if len(head) == 3 { + break + } + if s := auditValueText(t[k]); s != "" { + head = append(head, auditKeyLabel(k)+" "+s) + } + } + if len(head) == 0 { + return "" + } + s := strings.Join(head, ", ") + if len(keys) > len(head) { + s += fmt.Sprintf(" (+%d)", len(keys)-len(head)) + } + return clip(s, 60) + default: + return "" + } +} + +func clip(s string, max int) string { + r := []rune(strings.TrimSpace(s)) + if len(r) <= max { + return string(r) + } + return string(r[:max]) + "…" +} diff --git a/server/internal/store/audit_read_test.go b/server/internal/store/audit_read_test.go new file mode 100644 index 0000000..827cb12 --- /dev/null +++ b/server/internal/store/audit_read_test.go @@ -0,0 +1,160 @@ +package store + +import ( + "regexp" + "strconv" + "strings" + "testing" + "time" +) + +// Нумерація плейсхолдерів — те, що ламається мовчки. +// +// auditWhere складає умову з довільного набору фільтрів і сама рахує +// $1, $2, … Помилка на одиницю тут не падає й не помітна на око: запит +// виконується, просто порівнює не ту колонку не з тим значенням — а +// журнал аудиту саме тим і цінний, що йому вірять без перевірки. +func TestAuditWherePlaceholdersMatchArgs(t *testing.T) { + now := time.Now().UTC() + cases := map[string]AuditFilter{ + "порожній": {}, + "період": {From: now.Add(-24 * time.Hour), To: now}, + "усе разом": { + From: now.Add(-24 * time.Hour), + To: now, + Actions: []string{"ncm.config.delete"}, + ObjectTypes: []string{"ncm.config"}, + ActorUserIDs: []string{"9b8e1737-5661-4b9c-b14e-ed8cd382145b"}, + ActorIP: "10.0.0.0/24", + Query: "Миронівка", + Cursor: encodeAuditCursor(now, "9b8e1737-5661-4b9c-b14e-ed8cd382145b"), + }, + "лише машинні токени": {AnyToken: true}, + "люди, токени й будь-який токен": { + ActorUserIDs: []string{"9b8e1737-5661-4b9c-b14e-ed8cd382145b"}, + ActorTokenIDs: []string{"3f1b0f5e-0000-0000-0000-000000000001"}, + AnyToken: true, + }, + "лише курсор": { + Cursor: encodeAuditCursor(now, "9b8e1737-5661-4b9c-b14e-ed8cd382145b"), + }, + } + + ph := regexp.MustCompile(`\$(\d+)`) + for name, f := range cases { + t.Run(name, func(t *testing.T) { + where, args, err := auditWhere("00000000-0000-0000-0000-000000000001", f) + if err != nil { + t.Fatalf("auditWhere: %v", err) + } + + seen := map[int]bool{} + maxN := 0 + for _, m := range ph.FindAllStringSubmatch(where, -1) { + n, _ := strconv.Atoi(m[1]) + seen[n] = true + if n > maxN { + maxN = n + } + } + if maxN != len(args) { + t.Fatalf("найбільший плейсхолдер $%d, а аргументів %d\n%s", maxN, len(args), where) + } + for i := 1; i <= len(args); i++ { + if !seen[i] { + t.Fatalf("аргумент $%d не використано\n%s", i, where) + } + } + + // Предикат кабінету має стояти завжди й саме на $1: без + // нього сторінка показує чужі записи, а RLS на цій + // гіпертаблиці не діє й підстрахувати не може. + if !strings.Contains(where, "tenant_id = $1") { + t.Fatalf("немає предикату tenant_id:\n%s", where) + } + }) + } +} + +func TestAuditCursorRoundTrip(t *testing.T) { + // Мікросекунди, а не наносекунди: саме таку точність має + // timestamptz, і курсор мусить повертатись у базу тим самим + // значенням, яким звідти приїхав. + ts := time.Date(2026, 8, 25, 20, 45, 6, 395968000, time.UTC) + id := "bf903579-806d-4c59-a2e2-7418003578fd" + + got, gotID, err := decodeAuditCursor(encodeAuditCursor(ts, id)) + if err != nil { + t.Fatalf("decode: %v", err) + } + if !got.Equal(ts) { + t.Fatalf("час не збігся: %s != %s", got, ts) + } + if gotID != id { + t.Fatalf("id не збігся: %s != %s", gotID, id) + } + + for _, bad := range []string{"", "abc", "123", "123.не-uuid", ".x"} { + if _, _, err := decodeAuditCursor(bad); err == nil { + t.Fatalf("зіпсований курсор %q прийнято", bad) + } + } +} + +// Поле пошуку — це підрядок, а не мова шаблонів. +func TestEscapeLike(t *testing.T) { + if got := escapeLike("100%_готово"); got != `100\%\_готово` { + t.Fatalf("не екрановано: %s", got) + } +} + +// Переказ має бути однаковим при кожному читанні того самого рядка: +// ключі в JSON приходять у випадковому порядку, і без явного впорядкування +// той самий запис виглядав би щоразу інакше. +func TestAuditSummaryStableAndReadable(t *testing.T) { + meta := []byte(`{"counts":{"success":2},"status":"done","devices":2, + "commands":["show vlan"],"created_by":"admin", + "device_ids":["3f1b0f5e-0000-0000-0000-000000000001"]}`) + + first := auditSummary(meta) + for i := 0; i < 20; i++ { + if got := auditSummary(meta); got != first { + t.Fatalf("переказ нестабільний:\n%s\n%s", first, got) + } + } + if !strings.HasPrefix(first, "хостів: 2") { + t.Fatalf("очікували, що переказ почнеться з кількості хостів: %s", first) + } + if strings.Contains(first, "3f1b0f5e") { + t.Fatalf("сирі ідентифікатори не мають потрапляти в переказ: %s", first) + } + if strings.Contains(first, "success") { + t.Fatalf("вкладений об'єкт не перекладено: %s", first) + } + + if auditSummary([]byte(`{}`)) != "" { + t.Fatal("порожня meta має давати порожній переказ") + } + if auditSummary([]byte(`не json`)) != "" { + t.Fatal("зіпсована meta не має ламати перелік") + } +} + +// Ключ, якого немає в словнику, має лишатись видимим. +func TestUnknownActionKeepsKey(t *testing.T) { + e := AuditEvent{Action: "ncm.майбутня_дія"} + decorateAuditEvent(&e) + if e.ActionLabel != "" { + t.Fatalf("назву для незнайомої дії вигадано: %s", e.ActionLabel) + } + if e.ActionGroup != "Конфігурації" { + t.Fatalf("розділ мав вивестись зі схеми: %q", e.ActionGroup) + } + + // А відомий ключ має нести і назву, і позначку руйнівності. + d := AuditEvent{Action: "ncm.config.delete"} + decorateAuditEvent(&d) + if d.ActionLabel == "" || !d.Destructive { + t.Fatalf("словник не спрацював: %+v", d) + } +} diff --git a/server/internal/store/autoassign.go b/server/internal/store/autoassign.go index 6a95bbc..5e9beac 100644 --- a/server/internal/store/autoassign.go +++ b/server/internal/store/autoassign.go @@ -2,6 +2,8 @@ package store import ( "context" + "errors" + "regexp" "sort" "strings" @@ -15,6 +17,13 @@ type AutoAssignStat struct { Described int // Скільки хостів отримали новий шаблон. Assigned int + // Скільки хостів не вдалося розпізнати. + Failed int + // Скільки хостів відповіли іншим sysObjectID, ніж мали. + HardwareChanged int + // Остання помилка — щоб було що написати в журнал сервера, не + // ховаючи решту хостів за нею. + LastError error } // ApplySystemInfo зберігає системну групу й довішує шаблони за @@ -40,6 +49,16 @@ func (s *Store) ApplySystemInfo(ctx context.Context, tenantID string, devices [] return stat, err } + // Помилка на одному хості не має ронити весь звіт. + // + // Досі роняла — і саме через це хост DGS-3420 місяцями стояв би + // нерозпізнаним: запит до перейменованої таблиці падав, звіт + // обривався, системна група вже була записана, а причина не + // лишалась ніде. Ззовні це виглядало як «система просто не + // розпізнала», без жодної підказки, що робити. + // + // Тепер кожен хост обробляється окремо, а причина відмови осідає + // в inv.devices.identify_error — там, де її шукатимуть. for _, d := range devices { if d.GetDeviceId() == "" { // Знахідки сканування (без device_id) тут не обробляємо: @@ -48,26 +67,75 @@ func (s *Store) ApplySystemInfo(ctx context.Context, tenantID string, devices [] continue } - if err := s.saveSystemInfo(ctx, tenantID, d); err != nil { - return stat, err - } - stat.Described++ - - tplID := matchAutoAssign(rules, d.GetSysObjectId()) - if tplID == "" { + if err := s.identifyOne(ctx, tenantID, d, rules, &stat); err != nil { + stat.Failed++ + // Причину зберігаємо саме тут, а не в журналі сервера: + // журнал читає той, хто вже знає, що щось не так, а поле + // в картці бачить той, хто питає «чому порожньо». + if e := s.noteIdentifyError(ctx, tenantID, d.GetDeviceId(), err); e != nil { + stat.LastError = e + } else { + stat.LastError = err + } continue } - added, err := s.attachTemplate(ctx, tenantID, d.GetDeviceId(), tplID) - if err != nil { - return stat, err - } - if added { - stat.Assigned++ - } } return stat, nil } +// identifyOne — весь шлях розпізнавання одного хоста. +func (s *Store) identifyOne(ctx context.Context, tenantID string, + d *npv1.DiscoveredDevice, rules []autoAssignRule, stat *AutoAssignStat) error { + + changed, err := s.noteHardwareChange(ctx, tenantID, d) + if err != nil { + return err + } + if changed { + stat.HardwareChanged++ + } + + if err := s.saveSystemInfo(ctx, tenantID, d); err != nil { + return err + } + stat.Described++ + + // Заповнюємо «виробник», «тип», модель і версію. + // + // Поля самі собою нічого не збирають, але ними користується решта + // системи: фільтри, значки на мапі, звіти й запасний підбір профілю + // для хостів без SNMP. Уже задане не чіпаємо — людина могла + // написати точніше за наш довідник. Виняток один: заміна заліза, + // коли старе значення описує коробку, якої вже немає. + if err := s.fillFromOIDForce(ctx, tenantID, d.GetDeviceId(), changed); err != nil { + return err + } + + // Профіль збору закріплюємо за хостом, а не підбираємо щоразу + // наново: інакше в картці порожньо, і людина не бачить, чим її + // хост зніматимуть. + if err := s.pinAutoProfile(ctx, tenantID, d.GetDeviceId(), changed); err != nil { + return err + } + + if err := s.clearIdentifyError(ctx, tenantID, d.GetDeviceId()); err != nil { + return err + } + + tplID := matchAutoAssign(rules, d.GetSysObjectId()) + if tplID == "" { + return nil + } + added, err := s.attachTemplate(ctx, tenantID, d.GetDeviceId(), tplID) + if err != nil { + return err + } + if added { + stat.Assigned++ + } + return nil +} + type autoAssignRule struct { prefix string templateID string @@ -171,3 +239,259 @@ func (s *Store) attachTemplate(ctx context.Context, tenantID, deviceID, template } return true, nil } + +// fillFromOID проставляє виробника, тип, модель і версію ПЗ. +// +// Правило береться те саме, що підбирає профіль збору конфігу, і тим +// самим порядком: уточнення за описом перед загальним, довший префікс +// перед коротшим. Інакше хост міг би отримати тип від одного правила, а +// профіль від іншого — і пояснити людині, чому OLT підписаний +// комутатором, було б нічим. +// +// Модель і версію дістає Go, а не Postgres: потрібні групи захоплення. +// Зразки RE2 не мають відкату, тож правило з довідника не підвісить +// збір навіть якщо його напише людина. +// +// Уже задане не чіпаємо. `other` у типі й порожній рядок в решті — це +// «не задано»: типові значення колонок, яких людина не обирала. +func (s *Store) fillFromOIDForce(ctx context.Context, tenantID, deviceID string, force bool) error { + return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + var ( + descr string + vendor, kind *string + modelRe, versionRe *string + ) + err := tx.QueryRow(ctx, ` + SELECT COALESCE(dev.sys_descr,''), r.vendor, r.kind::text, + r.model_regex, r.version_regex + FROM inv.ident_rules r, inv.devices dev + WHERE dev.id = $1 AND dev.tenant_id = $2 + AND r.enabled + AND (r.tenant_id IS NULL OR r.tenant_id = dev.tenant_id) + AND (dev.sys_object_id = r.oid_prefix + OR dev.sys_object_id LIKE r.oid_prefix || '.%') + AND (r.descr_regex IS NULL + OR COALESCE(dev.sys_descr,'') ~ r.descr_regex) + ORDER BY r.tenant_id NULLS LAST, + (r.descr_regex IS NULL), + length(r.oid_prefix) DESC + LIMIT 1 + `, deviceID, tenantID).Scan(&descr, &vendor, &kind, &modelRe, &versionRe) + if errors.Is(err, pgx.ErrNoRows) { + return nil + } + if err != nil { + return err + } + + model := firstGroup(modelRe, descr) + version := firstGroup(versionRe, descr) + + // $7 — «залізо замінили». Тоді старе значення описує коробку, + // якої вже немає, і берегти його — берегти неправду. У всіх + // інших випадках рука людини важить більше за довідник. + _, err = tx.Exec(ctx, ` + UPDATE inv.devices SET + vendor = CASE WHEN ($7 OR COALESCE(vendor,'') = '') AND $3 <> '' + THEN $3 ELSE vendor END, + kind = CASE WHEN ($7 OR kind = 'other') AND $4 <> '' + THEN $4::inv.device_kind ELSE kind END, + model = CASE WHEN ($7 OR COALESCE(model,'') = '') AND $5 <> '' + THEN $5 ELSE model END, + os_version = CASE WHEN ($7 OR COALESCE(os_version,'') = '') AND $6 <> '' + THEN $6 ELSE os_version END, + identified_at = now(), + updated_at = now() + WHERE id = $1 AND tenant_id = $2 + `, deviceID, tenantID, deref(vendor), deref(kind), model, version, force) + return err + }) +} + +func deref(p *string) string { + if p == nil { + return "" + } + return *p +} + +// firstGroup витягує першу групу захоплення. Порожньо, якщо зразка +// немає, він не збігся або взагалі не компілюється. +// +// Зіпсований зразок мовчки нічого не дає, а не валить розпізнавання: +// правила редагує людина, і помилка в одному рядку довідника не має +// зупиняти онбординг усіх інших хостів. Помітно її буде там, де й +// має бути — у порожньому полі моделі. +func firstGroup(pattern *string, text string) string { + if pattern == nil || *pattern == "" || text == "" { + return "" + } + re, err := regexp.Compile(*pattern) + if err != nil { + return "" + } + m := re.FindStringSubmatch(text) + if len(m) < 2 { + return "" + } + return strings.TrimSpace(m[1]) +} + +// noteHardwareChange помічає, що в шафі стоїть інша коробка. +// +// Повертає true лише коли sysObjectID БУВ і став іншим. Перше +// розпізнавання зміною не рахується: воно не скасовує нічого, а +// заповнює порожнє. +// +// Навіщо окремо від решти. Заміна заліза знецінює все, що система про +// хост знала: модель, версію, тип, профіль збору конфігу. Мовчки +// підмінити ці поля — залишити людину з карткою, яка змінилась сама +// собою; не підмінити — залишити її з карткою, яка бреше. Тому мітка +// часу: поля оновлюються, і водночас видно, коли й чому. +func (s *Store) noteHardwareChange(ctx context.Context, tenantID string, d *npv1.DiscoveredDevice) (bool, error) { + oid := strings.TrimSpace(d.GetSysObjectId()) + if oid == "" { + return false, nil + } + + var changed bool + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + return tx.QueryRow(ctx, ` + UPDATE inv.devices + SET hardware_changed_at = now(), updated_at = now() + WHERE id = $1 AND tenant_id = $2 + AND COALESCE(sys_object_id,'') NOT IN ('', $3) + RETURNING true + `, d.GetDeviceId(), tenantID, oid).Scan(&changed) + }) + if errors.Is(err, pgx.ErrNoRows) { + return false, nil + } + return changed, err +} + +// pinAutoProfile закріплює за хостом підібраний профіль збору конфігу. +// +// Профіль і далі підбирається тими самими правилами — змінюється лише +// те, що результат тепер видно в картці й він не переграється щоразу +// наново. Обраний людиною не чіпається ніколи: прапорець profile_auto +// саме для того й заведений. +// +// Виняток — заміна заліза: автоматично підібраний профіль для коробки, +// якої вже немає, гарантовано хибний. +func (s *Store) pinAutoProfile(ctx context.Context, tenantID, deviceID string, hardwareChanged bool) error { + prof, err := s.ResolveProfile(ctx, tenantID, deviceID, nil) + if err != nil { + // Немає профілю — не привід валити розпізнавання: більшість + // решти роботи вже зроблена й корисна сама собою. + if errors.Is(err, ErrNoProfile) { + return nil + } + return err + } + if prof.ID == "" { + return nil + } + + return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + INSERT INTO ncm.device_policies (tenant_id, device_id, profile_id, profile_auto) + VALUES ($1, $2, $3, true) + ON CONFLICT (device_id) DO UPDATE + SET profile_id = EXCLUDED.profile_id, profile_auto = true + WHERE ncm.device_policies.profile_id IS NULL + OR (ncm.device_policies.profile_auto AND $4) + `, tenantID, deviceID, prof.ID, hardwareChanged) + return err + }) +} + +// noteIdentifyError лишає слід невдачі в картці хоста. +func (s *Store) noteIdentifyError(ctx context.Context, tenantID, deviceID string, cause error) error { + return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + UPDATE inv.devices SET identify_error = $3, updated_at = now() + WHERE id = $1 AND tenant_id = $2 + `, deviceID, tenantID, cause.Error()) + return err + }) +} + +func (s *Store) clearIdentifyError(ctx context.Context, tenantID, deviceID string) error { + return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + UPDATE inv.devices SET identify_error = NULL + WHERE id = $1 AND tenant_id = $2 AND identify_error IS NOT NULL + `, deviceID, tenantID) + return err + }) +} + +// Скільки початку конфігу дивитись у пошуках версії. +// +// Шапка з моделлю й прошивкою стоїть на самому початку — так роблять +// усі, бо це заголовок файлу. Ганяти зразок по тридцяти тисячах рядків +// заради рядка, який завжди в перших двадцяти, означало б платити за +// кожен зібраний конфіг рівно нічим не виправдану ціну. +const configHeadBytes = 4096 + +// fillFromConfig дістає версію, модель і серійний номер із тіла конфігу. +// +// Запасний шлях до того, що не приїхало по SNMP. Уже відоме не +// перезаписується: SNMP — джерело свіжіше, конфіг міг бути зібраний +// учора. +func (s *Store) fillFromConfig(ctx context.Context, tenantID, deviceID string, body []byte) error { + if len(body) == 0 { + return nil + } + head := body + if len(head) > configHeadBytes { + head = head[:configHeadBytes] + } + text := string(head) + + return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + var modelRe, versionRe, serialRe *string + err := tx.QueryRow(ctx, ` + SELECT r.config_model_regex, r.config_version_regex, r.config_serial_regex + FROM inv.ident_rules r, inv.devices dev + WHERE dev.id = $1 AND dev.tenant_id = $2 + AND r.enabled + AND (r.tenant_id IS NULL OR r.tenant_id = dev.tenant_id) + AND (dev.sys_object_id = r.oid_prefix + OR dev.sys_object_id LIKE r.oid_prefix || '.%') + AND (r.descr_regex IS NULL + OR COALESCE(dev.sys_descr,'') ~ r.descr_regex) + ORDER BY r.tenant_id NULLS LAST, + (r.descr_regex IS NULL), + length(r.oid_prefix) DESC + LIMIT 1 + `, deviceID, tenantID).Scan(&modelRe, &versionRe, &serialRe) + if errors.Is(err, pgx.ErrNoRows) { + return nil + } + if err != nil { + return err + } + + model := firstGroup(modelRe, text) + version := firstGroup(versionRe, text) + serial := firstGroup(serialRe, text) + if model == "" && version == "" && serial == "" { + return nil + } + + _, err = tx.Exec(ctx, ` + UPDATE inv.devices SET + model = CASE WHEN COALESCE(model,'') = '' AND $3 <> '' + THEN $3 ELSE model END, + os_version = CASE WHEN COALESCE(os_version,'') = '' AND $4 <> '' + THEN $4 ELSE os_version END, + serial_number = CASE WHEN COALESCE(serial_number,'') = '' AND $5 <> '' + THEN $5 ELSE serial_number END, + updated_at = now() + WHERE id = $1 AND tenant_id = $2 + `, deviceID, tenantID, model, version, serial) + return err + }) +} diff --git a/server/internal/store/autochecks.go b/server/internal/store/autochecks.go index 89ecf29..d647028 100644 --- a/server/internal/store/autochecks.go +++ b/server/internal/store/autochecks.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" "time" "github.com/jackc/pgx/v5" @@ -21,6 +22,32 @@ import ( // поки що просто обрізаємо й пишемо про це в журнал. const MaxInterfacesPerCheck = 256 +// Скільки рядків inv.interfaces узагалі читати за раз. +// +// Стеля вище — про розмір PDU й лишається недоторканою. Ця — про те, що +// фільтр складу портів застосовує Go, а отже кандидатів треба спершу +// прочитати: LIMIT 257, який стояв тут раніше, відрізав би порти ще до +// того, як зразок їх побачив, і «xe-0/0/47» просто не існувало б для +// фільтра. Число з великим запасом: найбільша коробка на стенді має 100 +// портів, шасі з повною набивкою — близько тисячі. +const maxInterfaceScan = 4096 + +// ifEligibleSQL — базові правила складу портів, спільні для чека й +// попереднього перегляду. +// +// Одним рядком, а не двома копіями, саме тому, що перегляд мусить +// показувати те, що станеться насправді. Розійшовшись на одному «і», +// два переліки почали б відрізнятись рівно на ті порти, через які +// людина й прийшла дивитись. +// +// Loopback і відсутні порти графіка не дають, а місце в PDU займають; +// `monitored` — ручний вимикач людини на конкретному порту, і фільтр +// шаблону його не скасовує. +const ifEligibleSQL = `monitored + AND if_index IS NOT NULL + AND COALESCE(type, '') <> 'softwareLoopback' + AND oper_status <> 'notPresent'` + // InterfaceCheckInterval — типовий інтервал опитування лічильників. // // 60 секунд — компроміс: частіше не має сенсу для 32-бітних лічильників @@ -49,6 +76,25 @@ type ifCheckTarget struct { // Повертає задачу для TaskDelta, якщо щось змінилось. nil означає // «нічого робити»: або немає SNMP-креденшела, або немає інтерфейсів, // або список не змінився з минулого разу. +// +// Чому це й далі Go, а не прототип шаблону (0059). Порт — теж рядок +// таблиці зі змінним індексом, і спокуса виразити його прототипом +// очевидна. Але snmp.if не збирає метрики: він повертає +// InterfaceCounters, а для цього зонд тримає попередній замір, рахує +// швидкості за ФАКТИЧНИМ інтервалом, ловить перевертання лічильника й +// ділить на номінальну швидкість порту. Прототип віддав би сирі октети +// без жодного з цих чотирьох умінь. +// +// Далі — прив'язки. Лічильники лягають у ts.if_counters за +// interface_id, а не в ts.samples за міткою; на цьому interface_id +// тримаються анімація трафіку на мапі, інспектор лінка, filtered_out_at +// і тригери з джерелом «interface». Прототип породжує серії з мітками й +// без interface_id. +// +// Тобто спільного між ними рівно одне слово — «таблиця». Прототипи +// покривають випадок, якого раніше не було покрито нічим; цей випадок +// покритий, і переносити його заради симетрії означало б обміняти +// найпомітнішу частину продукту на мінус двісті рядків. func (s *Store) EnsureInterfaceChecks(ctx context.Context, a *Agent, deviceID string) (*npv1.Task, error) { var task *npv1.Task @@ -71,36 +117,11 @@ func (s *Store) EnsureInterfaceChecks(ctx context.Context, a *Agent, deviceID st return nil } - rows, err := tx.Query(ctx, ` - SELECT if_index, id::text, COALESCE(speed_bps, 0) - FROM inv.interfaces - WHERE device_id = $1 - AND tenant_id = $2 - AND monitored - AND if_index IS NOT NULL - -- Loopback і відсутні порти графіка не дають, а місце - -- в PDU займають. - AND COALESCE(type, '') <> 'softwareLoopback' - AND oper_status <> 'notPresent' - ORDER BY if_index - LIMIT $3 - `, deviceID, a.TenantID, MaxInterfacesPerCheck+1) + targets, err := selectInterfaceTargets(ctx, tx, a.TenantID, deviceID) if err != nil { return err } - defer rows.Close() - - params := ifCheckParams{UseHCCounters: true} - for rows.Next() { - var t ifCheckTarget - if err := rows.Scan(&t.IfIndex, &t.InterfaceID, &t.SpeedBps); err != nil { - return err - } - params.Interfaces = append(params.Interfaces, t) - } - if err := rows.Err(); err != nil { - return err - } + params := ifCheckParams{UseHCCounters: true, Interfaces: targets} truncated := false if len(params.Interfaces) > MaxInterfacesPerCheck { @@ -216,6 +237,229 @@ func sameInterfaceSet(oldJSON string, newJSON []byte) bool { return true } +// selectInterfaceTargets вирішує, які порти хоста потраплять у чек. +// +// Одне місце на два виклики (перебудова після автовиявлення й +// перебудова після зміни шаблонів) — саме тому, що правило «який порт +// опитувати» мусить бути одне. Два його примірники розійшлися б, і +// склад чека залежав би від того, що саме щойно сталося. +// +// Заразом розставляє позначки `filtered_out_at` на самих портах: без +// них обрив графіка нічим не відрізнити від аварії. +func selectInterfaceTargets(ctx context.Context, tx pgx.Tx, tenantID, deviceID string) ([]ifCheckTarget, error) { + // Фільтри читаємо ДО портів: якщо серед них є зіпсований, склад + // чека краще не чіпати взагалі, ніж перебудувати його за половиною + // правил. + set, err := deviceInterfaceFilters(ctx, tx, deviceID) + if err != nil { + return nil, err + } + + rows, err := tx.Query(ctx, ` + SELECT if_index, id::text, COALESCE(speed_bps, 0), + name, COALESCE(alias, ''), COALESCE(type, '') + FROM inv.interfaces + WHERE device_id = $1 + AND tenant_id = $2 + AND `+ifEligibleSQL+` + ORDER BY if_index + LIMIT $3 + `, deviceID, tenantID, maxInterfaceScan) + if err != nil { + return nil, err + } + defer rows.Close() + + var ( + targets []ifCheckTarget + // Порти, які пройшли базові правила, але не пройшли фільтр. + excluded []string + eligible int + ) + for rows.Next() { + var t ifCheckTarget + var in InterfaceCandidate + if err := rows.Scan(&t.IfIndex, &t.InterfaceID, &t.SpeedBps, + &in.Name, &in.Alias, &in.Type); err != nil { + return nil, err + } + eligible++ + if set.match(in) { + targets = append(targets, t) + } else { + excluded = append(excluded, t.InterfaceID) + } + } + if err := rows.Err(); err != nil { + return nil, err + } + + // Фільтр, який не пропустив НІЧОГО, — це майже завжди помилка в + // зразку, а не намір: порожній snmp.if-чек ззовні не відрізняється + // від справного, поки хтось не помітить, що метрик портів немає вже + // тиждень. + // + // Тому склад чека лишається таким, який був, а транзакція + // відкочується цілком — разом із позначками на портах. Викличник + // пише це в журнал, а людина бачить те саме число в попередньому + // перегляді ще до збереження. + if !set.empty() && eligible > 0 && len(targets) == 0 { + return nil, fmt.Errorf("%w: пристрій %s, портів під наглядом %d, фільтр шаблонів (%s) не пропустив жодного — склад чека лишено без змін", + ErrInterfaceFilterEmpty, deviceID, eligible, strings.Join(set.names, ", ")) + } + + if err := markFilteredOut(ctx, tx, tenantID, deviceID, excluded); err != nil { + return nil, err + } + return targets, nil +} + +// ApplyInterfaceFilters переписує склад уже наявного snmp.if-чека. +// +// Потрібна тоді, коли змінились не порти, а правила: шаблон причепили, +// відчепили або поправили в ньому зразок. Чекати наступного обходу +// автовиявлення означало б, що людина натиснула «Зберегти» й нічого не +// сталося — а «нічого» тут може тривати годинами. +// +// Чека НЕ створює навмисно: створення прив'язане до звіту +// автовиявлення, бо лише там відомо, який агент опитує цей хост і кому +// штовхати оновлений план. Тут ми лише приводимо у відповідність те, що +// вже є; агент забере новий склад разом із наступною звіркою плану. +func (s *Store) ApplyInterfaceFilters(ctx context.Context, tenantID, deviceID string) error { + return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + return applyInterfaceFiltersTx(ctx, tx, tenantID, deviceID) + }) +} + +func applyInterfaceFiltersTx(ctx context.Context, tx pgx.Tx, tenantID, deviceID string) error { + var checkID string + if err := tx.QueryRow(ctx, ` + SELECT id::text FROM core.checks + WHERE device_id = $1 AND tenant_id = $2 AND check_type = 'snmp.if' + ORDER BY created_at LIMIT 1 + `, deviceID, tenantID).Scan(&checkID); err != nil { + if isNoRows(err) { + return nil + } + return err + } + + targets, err := selectInterfaceTargets(ctx, tx, tenantID, deviceID) + switch { + case errors.Is(err, ErrInterfaceFilterEmpty): + // Тут ця відмова не має нікуди підійматись. Реконсиляція + // шаблонів іде і в потоці розпізнавання хоста, і в масовій + // правці — там помилка одного фільтра поклала б увесь звіт, а + // цей самий факт і так буде сказаний тричі: 409 при збереженні + // шаблону, попередження в журналі автовиявлення й число «0 зі + // 100» у попередньому перегляді. + // + // Склад чека при цьому лишається попереднім — саме те, чого ми + // й хочемо: перестати збирати все відразу гірше, ніж збирати + // зайве. + return nil + case err != nil: + return err + } + if len(targets) > MaxInterfacesPerCheck { + targets = targets[:MaxInterfacesPerCheck] + } + // Порожній склад чека не пишемо ніколи: або фільтр помилковий (і + // тоді вище вже була помилка), або портів справді не лишилось — і + // тоді чек має дочекатись автовиявлення, яке одне знає, чи це + // назавжди. + if len(targets) == 0 { + return nil + } + + payload, err := json.Marshal(ifCheckParams{UseHCCounters: true, Interfaces: targets}) + if err != nil { + return err + } + _, err = tx.Exec(ctx, ` + UPDATE core.checks SET params = $2::jsonb, updated_at = now() + WHERE id = $1 + `, checkID, string(payload)) + return err +} + +// ErrInterfaceFilterEmpty — фільтри шаблонів не пропустили жодного порту. +// +// Окрема помилка, а не просто текст: її ловлять і сервер (щоб не +// перебудовувати чек), і HTTP-шар (щоб відмовити в збереженні шаблону +// зрозумілим кодом, а не «внутрішньою помилкою»). +var ErrInterfaceFilterEmpty = errors.New("фільтр портів не пропустив жодного порту") + +// deviceInterfaceFilters збирає фільтри всіх шаблонів хоста. +// +// Шаблони без фільтра пропускаються: відсутність думки про порти не є +// думкою «брати все» — інакше один шаблон загального призначення +// знецінював би фільтр іншого. +// +// Зіпсований фільтр (не компілюється) — це помилка, а не «беремо все». +// Розширити склад чека зі ста портів назад до ста через друкарську +// помилку в чужому шаблоні гірше, ніж лишити все як є й написати про це. +func deviceInterfaceFilters(ctx context.Context, tx pgx.Tx, deviceID string) (*interfaceFilterSet, error) { + rows, err := tx.Query(ctx, ` + SELECT t.name, t.if_filter::text + FROM tpl.device_templates dt + JOIN tpl.templates t ON t.id = dt.template_id + WHERE dt.device_id = $1 + ORDER BY t.name + `, deviceID) + if err != nil { + return nil, err + } + defer rows.Close() + + set := &interfaceFilterSet{} + for rows.Next() { + var name, raw string + if err := rows.Scan(&name, &raw); err != nil { + return nil, err + } + f := parseInterfaceFilter(raw) + if f.IsEmpty() { + continue + } + cf, err := compileInterfaceFilter(f) + if err != nil { + return nil, fmt.Errorf("фільтр портів шаблону «%s»: %w", name, err) + } + set.names = append(set.names, name) + set.filters = append(set.filters, cf) + } + return set, rows.Err() +} + +// markFilteredOut ставить і знімає позначку «виключено фільтром». +// +// Двома запитами, а не одним UPDATE по всіх портах: обидва мають умову +// на поточне значення, тож порт, стан якого не змінився, не оновлюється +// взагалі. Це не мікрооптимізація — на кожному порту висить тригер +// touch_updated_at, і сліпий UPDATE ворушив би `updated_at` сотні портів +// щохвилини, роблячи це поле марним. +func markFilteredOut(ctx context.Context, tx pgx.Tx, tenantID, deviceID string, excluded []string) error { + if excluded == nil { + excluded = []string{} + } + if _, err := tx.Exec(ctx, ` + UPDATE inv.interfaces SET filtered_out_at = now() + WHERE device_id = $1 AND tenant_id = $2 + AND id = ANY($3::uuid[]) + AND filtered_out_at IS NULL + `, deviceID, tenantID, excluded); err != nil { + return err + } + _, err := tx.Exec(ctx, ` + UPDATE inv.interfaces SET filtered_out_at = NULL + WHERE device_id = $1 AND tenant_id = $2 + AND NOT (id = ANY($3::uuid[])) + AND filtered_out_at IS NOT NULL + `, deviceID, tenantID, excluded) + return err +} + // PlanHash перераховує хеш плану без побудови самого плану. // // Потрібен після зміни чеків: агент має отримати новий хеш разом із @@ -228,3 +472,120 @@ func (s *Store) PlanHash(ctx context.Context, a *Agent) ([]byte, error) { } return plan.GetPlanHash(), nil } + +// Два інтервали, бо в чека розпізнавання дві різні ролі. +// +// Поки хост не розпізнано, від цього чека залежить усе інше: підбір +// шаблонів, підбір профілю збору конфігів. Людина щойно завела хост і +// дивиться на екран — тут дорога кожна хвилина. +// +// Щойно розпізнано, питання закрите й лишається тільки сторожова +// функція: залізо в шафі міняють, і картка має помітити це сама. +// Три OID раз на шість годин — нижче за рівень шуму на будь-якому +// пристрої. +// +// Розклад агента кладе задачі на сітку інтервалу: при шести годинах +// перший запуск міг би припасти й на п'яту годину очікування. Саме +// тому початковий інтервал малий, а не тому, що дані швидко старіють. +const ( + IdentifyProbeInterval = 2 * time.Minute + IdentifyCheckInterval = 6 * time.Hour +) + +// IdentifyCheckType — тип чека розпізнавання. +// +// Власний тип, а не полегшений `topology.discover`, хоч модуль той +// самий: у списку перевірок хоста рядок мусить називати те, що робить. +// «Neighbor Discovery», який не шукає жодного сусіда, — це підказка, +// що бреше. +// +// Префікс `topology.` обов'язковий: Registry.Register вимагає, щоб тип +// чека починався з ключа модуля. +const IdentifyCheckType = "topology.identify" + +// EnsureIdentifyChecks заводить чек розпізнавання хостам, які його +// потребують. +// +// Підбір профілю збору конфігів і шаблонів опитування спирається на +// sysObjectID — а він нізвідки не береться, доки хост нічим не +// опитують. Виходило замкнене коло: автоматика чекає на дані, дані +// чекають на те, що людина руками заведе чек автовиявлення. +// +// Розриваємо його тут: хост має SNMP-доступ — значить, дозволено його +// запитати, хто він такий. +// +// Чек не створюється, якщо автовиявлення вже налаштоване вручну: його +// системна група так само заповнить поля, а два чеки одного типу лише +// подвоїли б трафік. +func (s *Store) EnsureIdentifyChecks(ctx context.Context, a *Agent) (int, error) { + var made int + err := s.InTenantTx(ctx, a.TenantID, func(tx pgx.Tx) error { + // Приводимо інтервал до ролі: нерозпізнаним — часто, + // розпізнаним — рідко. В обидва боки, бо пристрій може і + // зникнути з обліку, і повернутись іншою моделлю. + if _, err := tx.Exec(ctx, ` + UPDATE core.checks ch + SET interval_sec = want.sec, updated_at = now() + FROM inv.devices d, + LATERAL (SELECT CASE WHEN d.sys_object_id IS NULL + THEN $3::int ELSE $4::int END) AS want(sec) + WHERE d.id = ch.device_id + AND ch.tenant_id = $1 + AND d.agent_id = $2 + AND ch.check_type = $5 + AND ch.interval_sec <> want.sec + `, a.TenantID, a.ID, + int32(IdentifyProbeInterval/time.Second), + int32(IdentifyCheckInterval/time.Second), + IdentifyCheckType); err != nil { + return err + } + + // Чек заводиться будь-якому хосту зі SNMP-доступом, а не лише + // нерозпізнаному. + // + // Спершу умова була «ще не розпізнаний»: чек потрібен, щоб + // хост розпізнати, а далі він просто лишається. Хибність цього + // міркування показав живий стенд: коли чек зникав з іншої + // причини, він не повертався НІКОЛИ — хост уже розпізнаний, під + // умову не підпадає, і сторожова функція (помітити заміну + // заліза) мовчки припинялась назавжди. + // + // Різниця між «розпізнати» і «стежити» лишилась там, де їй + // місце, — в інтервалі вище: дві хвилини проти шести годин. + return tx.QueryRow(ctx, ` + WITH candidates AS ( + SELECT d.id + FROM inv.devices d + WHERE d.tenant_id = $1 + AND d.agent_id = $2 + AND d.enabled + AND d.deleted_at IS NULL + AND EXISTS ( + SELECT 1 FROM inv.device_credentials dc + JOIN inv.credentials c ON c.id = dc.credential_id + WHERE dc.device_id = d.id + AND c.tenant_id = d.tenant_id + AND c.proto IN ('snmp_v2c','snmp_v3') + ) + -- Ні свого чека, ні вручну заведеного автовиявлення: + -- воно знімає ту саму системну групу, і другий чек + -- лише подвоїв би трафік. + AND NOT EXISTS ( + SELECT 1 FROM core.checks ch + WHERE ch.device_id = d.id + AND ch.tenant_id = d.tenant_id + AND ch.check_type IN ($3, 'topology.discover') + ) + ), ins AS ( + INSERT INTO core.checks + (tenant_id, device_id, check_type, params, interval_sec, timeout_ms, retries) + SELECT $1, candidates.id, $3, '{}'::jsonb, $4, 10000, 1 + FROM candidates + RETURNING 1 + ) + SELECT count(*) FROM ins + `, a.TenantID, a.ID, IdentifyCheckType, int32(IdentifyProbeInterval/time.Second)).Scan(&made) + }) + return made, err +} diff --git a/server/internal/store/checks.go b/server/internal/store/checks.go index 171070d..aef366d 100644 --- a/server/internal/store/checks.go +++ b/server/internal/store/checks.go @@ -7,6 +7,7 @@ import ( "fmt" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" "github.com/netpulse/netpulse/server/internal/crypto" ) @@ -24,6 +25,11 @@ type CheckType struct { Metrics json.RawMessage `json:"metrics"` /** Чи ввімкнений плагін у цього тенанта. */ Available bool `json:"available"` + // Чек заводить і планує система. З довідника не прибирається — + // перевірка типів при збереженні має його знати, — але в переліку + // для ручного додавання йому не місце: система вже завела свій, + // і другий такий самий упирався б у checks_uniq. + AutoManaged bool `json:"auto_managed"` } // Check — одна перевірка на хості. @@ -52,7 +58,8 @@ func (s *Store) ListCheckTypes(ctx context.Context, tenantID string) ([]CheckTyp -- тенанту або він базовий: вимагати «встановлення» -- для пінга означало б зустрічати кожного клієнта -- порожнім списком перевірок. - (p.is_core OR COALESCE(pi.enabled, false)) + (p.is_core OR COALESCE(pi.enabled, false)), + ct.auto_managed FROM core.check_types ct JOIN core.plugins p ON p.key = ct.plugin_key LEFT JOIN core.plugin_installs pi @@ -67,7 +74,7 @@ func (s *Store) ListCheckTypes(ctx context.Context, tenantID string) ([]CheckTyp var c CheckType var schema, metrics string if err := rows.Scan(&c.Key, &c.PluginKey, &c.Name, &schema, &metrics, - &c.Available); err != nil { + &c.Available, &c.AutoManaged); err != nil { return err } c.ParamsSchema = json.RawMessage(schema) @@ -194,6 +201,16 @@ func (s *Store) SetDeviceChecks(ctx context.Context, tenantID, deviceID string, RETURNING id::text `, tenantID, deviceID, c.CheckType, params, c.IntervalSec, c.TimeoutMs, c.Retries, c.Enabled).Scan(&id); err != nil { + // Дубль ловимо окремо: checks_uniq спрацьовує на + // (хост, тип, інтерфейс, параметри), і для людини це + // не збій сервера, а «такий чек уже є». Без цієї + // гілки вона бачила б «внутрішню помилку» й не мала + // жодної підказки, що робити далі. + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23505" { + return fmt.Errorf("%w: перевірка %s з такими ж параметрами "+ + "у цього хоста вже є", ErrInvalid, c.CheckType) + } return fmt.Errorf("перевірка %s: %w", c.CheckType, err) } } @@ -205,12 +222,26 @@ func (s *Store) SetDeviceChecks(ctx context.Context, tenantID, deviceID string, // Породжені шаблоном — так само: ними володіє реконсиляція, і // видалення тут означало б, що вони зникають на кожне // збереження форми, щоб за секунду з'явитися знову. + // + // І окремо — автокеровані типи. Їх ховають від переліку для + // ручного додавання, і саме тому вони НЕ приходять назад у + // цьому запиті: форма про них не знає, а отже не може їх + // «лишити». Без цієї умови кожне збереження форми тихо зносило + // б розпізнавання хоста й опитування портів — тобто ту саму + // автоматику, заради якої їх і сховали. + // + // Помилку внесено разом із auto_managed і знайдено на живому + // стенді: у п'яти хостів із шести зник topology.identify. _, err := tx.Exec(ctx, ` - DELETE FROM core.checks - WHERE tenant_id = $1 AND device_id = $2 - AND interface_id IS NULL - AND template_id IS NULL - AND NOT (id = ANY($3::uuid[])) + DELETE FROM core.checks c + WHERE c.tenant_id = $1 AND c.device_id = $2 + AND c.interface_id IS NULL + AND c.template_id IS NULL + AND NOT (c.id = ANY($3::uuid[])) + AND NOT EXISTS ( + SELECT 1 FROM core.check_types ct + WHERE ct.key = c.check_type AND ct.auto_managed + ) `, tenantID, deviceID, nonNilIDs(keep)) return err }) diff --git a/server/internal/store/dashboards_public.go b/server/internal/store/dashboards_public.go index a584273..542743b 100644 --- a/server/internal/store/dashboards_public.go +++ b/server/internal/store/dashboards_public.go @@ -46,7 +46,7 @@ func (s *Store) DashboardByPublicToken(ctx context.Context, token string) (strin tenantID string d Dashboard ) - err := s.pool.QueryRow(ctx, ` + err := s.bg.QueryRow(ctx, ` SELECT tenant_id::text, id::text, name, slug, kind::text, refresh_sec, is_default FROM core.dashboards WHERE public_token = $1 `, token).Scan(&tenantID, &d.ID, &d.Name, &d.Slug, &d.Kind, &d.RefreshSec, &d.IsDefault) diff --git a/server/internal/store/device_detail.go b/server/internal/store/device_detail.go new file mode 100644 index 0000000..5090940 --- /dev/null +++ b/server/internal/store/device_detail.go @@ -0,0 +1,708 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "strings" + "time" + + "github.com/jackc/pgx/v5" +) + +// Картка хоста: усе, що система про нього знає, розкладене по полицях. +// +// Головне питання, на яке відповідає цей файл, — «звідки взялась ця +// метрика». Досі відповіді не існувало ніде: перелік рядів +// (`ListSeries`) показував ЩО зібрано, перелік перевірок +// (`ListDeviceChecks`) показував лише РУЧНІ, а шаблонні — ті, що й +// збирають майже все, — не показував узагалі. Між двома половинами +// зяяла діра, і саме в неї провалювались чотириста рядів, які +// збираються без жодного видимого чека. + +// MetricOrigin — походження одного ряду метрик. +// +// Kind відповідає на питання «хто це завів і де це міняти»: +// +// template — чек прийшов із шаблону; правити треба в шаблоні +// auto — чек завела сама система (порти, розпізнавання) +// manual — чек завела людина в картці хоста +// none — чека немає; ряд лишився від колишнього збору +// +// Останній випадок не теоретичний: на стенді 466 рядів `if.*` живуть +// без жодного snmp.if-чека й не оновлюються з учора. Без цього поля +// вони виглядають як звичайні метрики зі старим значенням — тобто як +// збій збору, а не як історія. +type MetricOrigin struct { + Kind string `json:"kind"` + CheckID string `json:"check_id,omitempty"` + CheckType string `json:"check_type,omitempty"` + CheckName string `json:"check_name,omitempty"` + Enabled bool `json:"enabled"` + IntervalSec int `json:"interval_sec,omitempty"` + TemplateID string `json:"template_id,omitempty"` + TemplateName string `json:"template_name,omitempty"` + ItemName string `json:"item_name,omitempty"` + LastRunAt *time.Time `json:"last_run_at,omitempty"` + LastError string `json:"last_error,omitempty"` +} + +// LatestMetric — рядок розділу «Останні дані». +type LatestMetric struct { + SeriesID int64 `json:"series_id"` + MetricKey string `json:"metric_key"` + Unit string `json:"unit,omitempty"` + PluginKey string `json:"plugin_key,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + InterfaceID string `json:"interface_id,omitempty"` + InterfaceName string `json:"interface_name,omitempty"` + LastValue *float64 `json:"last_value,omitempty"` + LastAt *time.Time `json:"last_at,omitempty"` + // Stale рахує сервер, а не браузер: поріг залежить від інтервалу + // чека, а інтервал знає лише той бік, який щойно зіставив ряд із + // чеком. Порахований у браузері, він розійшовся б із цим + // зіставленням при першій же зміні правила. + Stale bool `json:"stale"` + Origin MetricOrigin `json:"origin"` +} + +// DeviceCheck — перевірка хоста разом із тим, хто її завів. +// +// Окремо від store.Check навмисно: той тип обслуговує РЕДАКТОР ручних +// перевірок, і додати до нього шаблонні означало б показати в редакторі +// те, що з нього не редагується. Тут — перелік на читання, і в нього +// входить усе, що справді опитує хост. +type DeviceCheck struct { + ID string `json:"id"` + CheckType string `json:"check_type"` + TypeName string `json:"type_name,omitempty"` + Params json.RawMessage `json:"params"` + IntervalSec int `json:"interval_sec"` + TimeoutMs int `json:"timeout_ms"` + Retries int `json:"retries"` + Enabled bool `json:"enabled"` + AutoManaged bool `json:"auto_managed"` + Origin string `json:"origin"` + TemplateID string `json:"template_id,omitempty"` + TemplateName string `json:"template_name,omitempty"` + ItemKey string `json:"template_item_key,omitempty"` + InterfaceID string `json:"interface_id,omitempty"` + InterfaceName string `json:"interface_name,omitempty"` + LastRunAt *time.Time `json:"last_run_at,omitempty"` + NextRunAt *time.Time `json:"next_run_at,omitempty"` + LastError string `json:"last_error,omitempty"` + // Метрики, які цей ТИП уміє віддавати (довідник core.check_types). + Metrics []string `json:"metrics"` + // Скільки рядів система віднесла саме до цієї перевірки. Нуль у + // ввімкненого чека — найцікавіший рядок у переліку: опитування йде, + // а даних немає. + SeriesCount int `json:"series_count"` +} + +// DeviceLatest — відповідь розділу «Останні дані». +type DeviceLatest struct { + Metrics []LatestMetric `json:"metrics"` + Checks []DeviceCheck `json:"checks"` + // Ряди, для яких чека не знайшлось. Дублює лічильник у Metrics, + // але потрібен окремо: саме він відповідає на питання «чому в + // хоста без шаблонів усе одно є метрики». + Orphans int `json:"orphans"` +} + +// staleFloor — мінімальний поріг «не оновлюється». +// +// Навіть у хвилинного чека між тактами буває пропуск: зонд перечитує +// чергу, пристрій відповідає з другої спроби. Позначати таке червоним +// означало б привчити не дивитись на позначку взагалі. +const staleFloor = 5 * time.Minute + +// staleNoCheck — поріг для ряду, за яким не стоїть жодна перевірка. +// +// Такий ряд не оновиться вже ніколи, але кричати про це в першу ж +// хвилину після зняття шаблону — зайве: людина щойно це й зробила. +const staleNoCheck = 15 * time.Minute + +// DeviceLatestData — усі метрики хоста з останнім значенням і походженням. +// +// Два запити, а не N+1: один по рядах (з LATERAL на останню точку) і +// один по перевірках. Зіставлення робиться в Go — перевірок у хоста +// одиниці, а виражати пріоритет зіставлення в SQL означало б написати +// CASE на чотири гілки й потім не мати змоги його перевірити тестом. +// +// Про LATERAL замість DISTINCT ON: заміряно на стенді, хост зі 200 +// рядами, 63 тис. вибірок. DISTINCT ON читає гіпертаблицю ЦІЛКОМ +// (Seq Scan по всіх чанках) — 20.1 мс, і зростає разом з історією. +// LATERAL робить один індексний доторк на ряд у кожному чанку — +// 0.78 мс, і зростає разом із кількістю рядів, тобто з тим, що +// показуємо. Через рік retention різниця буде не в 25 разів. +func (s *Store) DeviceLatestData(ctx context.Context, tenantID, deviceID string) (*DeviceLatest, error) { + out := &DeviceLatest{Metrics: []LatestMetric{}, Checks: []DeviceCheck{}} + + checks, err := s.DeviceChecksAll(ctx, tenantID, deviceID) + if err != nil { + return nil, err + } + out.Checks = checks + + // Назви елементів шаблонів: metric_key → людська назва. Саме вона + // відповідає на «що це число означає» — ключ `sys.uptime_sec` це + // пояснює, а `1.3.6.1.2.1.1.3.0` вже ні. + itemNames, err := s.deviceTemplateItemNames(ctx, tenantID, deviceID) + if err != nil { + return nil, err + } + + err = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, ` + SELECT s.id, s.metric_key, COALESCE(s.unit,''), COALESCE(s.plugin_key::text,''), + s.labels::text, + COALESCE(s.interface_id::text,''), COALESCE(i.name,''), + l.value, l.ts + FROM ts.series s + LEFT JOIN inv.interfaces i ON i.id = s.interface_id + LEFT JOIN LATERAL ( + SELECT m.value, m.ts FROM ts.samples m + WHERE m.series_id = s.id ORDER BY m.ts DESC LIMIT 1 + ) l ON true + WHERE s.tenant_id = $1 AND s.device_id = $2 + ORDER BY s.metric_key, i.if_index NULLS FIRST, i.name + `, tenantID, deviceID) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var m LatestMetric + var labels string + if err := rows.Scan(&m.SeriesID, &m.MetricKey, &m.Unit, &m.PluginKey, + &labels, &m.InterfaceID, &m.InterfaceName, + &m.LastValue, &m.LastAt); err != nil { + return err + } + m.Labels = decodeLabels(labels) + out.Metrics = append(out.Metrics, m) + } + return rows.Err() + }) + if err != nil { + return nil, err + } + + now := time.Now() + seriesPerCheck := map[string]int{} + for i := range out.Metrics { + m := &out.Metrics[i] + m.Origin = matchOrigin(m, checks, itemNames) + if m.Origin.CheckID != "" { + seriesPerCheck[m.Origin.CheckID]++ + } else { + out.Orphans++ + } + m.Stale = isStale(m, now) + } + for i := range out.Checks { + out.Checks[i].SeriesCount = seriesPerCheck[out.Checks[i].ID] + } + return out, nil +} + +// isStale — чи перестав ряд оновлюватись. +func isStale(m *LatestMetric, now time.Time) bool { + if m.LastAt == nil { + return true + } + limit := staleNoCheck + if m.Origin.IntervalSec > 0 && m.Origin.Enabled { + // Три такти: один пропуск — це життя, три поспіль — це збій. + if d := 3 * time.Duration(m.Origin.IntervalSec) * time.Second; d > staleFloor { + limit = d + } else { + limit = staleFloor + } + } + return now.Sub(*m.LastAt) > limit +} + +// checkParams — те з params, що дозволяє впізнати «свої» ряди. +type checkParams struct { + // snmp.get: людина (або шаблон) прямо назвала ключ метрики. + OIDs []struct { + MetricKey string `json:"metric_key"` + } `json:"oids"` + // snmp.if: перелік портів складає автовиявлення. + Interfaces []struct { + InterfaceID string `json:"interface_id"` + } `json:"interfaces"` +} + +// matchOrigin зіставляє ряд із перевіркою, яка його наповнює. +// +// Прив'язки ряду до чека в схемі немає: агент реєструє серію по +// (пристрій, ключ метрики, мітки), а не по чеку, і правильно робить — +// одна серія переживає перескладання пачки OID. Тож зіставлення — +// висновок, і робиться воно за спаданням надійності: +// +// 1. ключ метрики прямо названий у params чека (snmp.get) — точно він; +// 2. порт ряду є в переліку портів чека (snmp.if) — точно він; +// 3. ключ метрики стоїть у довіднику типу (core.check_types.metrics); +// 4. збігається лише плагін — це вже здогад, але кращий за «невідомо». +// +// Порядок важливіший за повноту: помилитись тут означає показати +// людині не той шаблон, у який іти щось міняти. +func matchOrigin(m *LatestMetric, checks []DeviceCheck, itemNames map[string]string) MetricOrigin { + best := -1 + bestPri := 99 + + for i := range checks { + c := &checks[i] + pri := 99 + + var p checkParams + if len(c.Params) > 0 { + _ = json.Unmarshal(c.Params, &p) + } + + for _, o := range p.OIDs { + if o.MetricKey != "" && o.MetricKey == m.MetricKey { + pri = 1 + break + } + } + if pri > 2 && m.InterfaceID != "" { + for _, iface := range p.Interfaces { + if iface.InterfaceID == m.InterfaceID { + pri = 2 + break + } + } + } + if pri > 3 { + for _, mk := range c.Metrics { + if mk == m.MetricKey { + pri = 3 + break + } + } + } + if pri > 4 && m.PluginKey != "" && pluginOf(c.CheckType) == m.PluginKey { + pri = 4 + } + + if pri < bestPri { + best, bestPri = i, pri + } + } + + if best < 0 || bestPri == 99 { + return MetricOrigin{Kind: "none", ItemName: itemNames[m.MetricKey]} + } + + c := &checks[best] + return MetricOrigin{ + Kind: c.Origin, + CheckID: c.ID, + CheckType: c.CheckType, + CheckName: c.TypeName, + Enabled: c.Enabled, + IntervalSec: c.IntervalSec, + TemplateID: c.TemplateID, + TemplateName: c.TemplateName, + ItemName: itemNames[m.MetricKey], + LastRunAt: c.LastRunAt, + LastError: c.LastError, + } +} + +// pluginOf — «icmp.ping» → «icmp». Обмеження check_types_prefix_matches_plugin +// гарантує, що ключ типу завжди починається з ключа плагіна. +func pluginOf(checkType string) string { + if i := strings.IndexByte(checkType, '.'); i > 0 { + return checkType[:i] + } + return checkType +} + +// DeviceChecksAll — УСІ перевірки хоста, включно зі шаблонними. +// +// ListDeviceChecks навмисно віддає лише ручні: він годує редактор, а +// шаблонний чек звідти не редагується. Але людині, яка питає «що взагалі +// опитує цей хост», потрібні саме всі — інакше на екрані стоїть «одна +// перевірка», а метрик приїжджає шість. +func (s *Store) DeviceChecksAll(ctx context.Context, tenantID, deviceID string) ([]DeviceCheck, error) { + out := []DeviceCheck{} + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, ` + SELECT c.id::text, c.check_type, COALESCE(ct.name,''), c.params::text, + c.interval_sec, c.timeout_ms, c.retries, c.enabled, + COALESCE(ct.auto_managed,false), + COALESCE(c.template_id::text,''), COALESCE(t.name,''), + COALESCE(c.template_item_key,''), + COALESCE(c.interface_id::text,''), COALESCE(i.name,''), + c.last_run_at, c.next_run_at, COALESCE(c.last_error,''), + COALESCE(ct.metrics::text,'[]') + FROM core.checks c + LEFT JOIN core.check_types ct ON ct.key = c.check_type + LEFT JOIN tpl.templates t ON t.id = c.template_id + LEFT JOIN inv.interfaces i ON i.id = c.interface_id + WHERE c.tenant_id = $1 AND c.device_id = $2 + ORDER BY c.check_type, t.name + `, tenantID, deviceID) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var c DeviceCheck + var params, metrics string + if err := rows.Scan(&c.ID, &c.CheckType, &c.TypeName, ¶ms, + &c.IntervalSec, &c.TimeoutMs, &c.Retries, &c.Enabled, + &c.AutoManaged, &c.TemplateID, &c.TemplateName, &c.ItemKey, + &c.InterfaceID, &c.InterfaceName, + &c.LastRunAt, &c.NextRunAt, &c.LastError, &metrics); err != nil { + return err + } + c.Params = json.RawMessage(params) + c.Metrics = []string{} + _ = json.Unmarshal([]byte(metrics), &c.Metrics) + switch { + case c.TemplateID != "": + c.Origin = "template" + case c.AutoManaged: + c.Origin = "auto" + default: + c.Origin = "manual" + } + out = append(out, c) + } + return rows.Err() + }) + return out, err +} + +// deviceTemplateItemNames — назви елементів шаблонів, прив'язаних до хоста. +// +// Ключ — metric_key, бо саме він є в ряді. Збіг ключа в двох шаблонах +// означає одну й ту саму метрику, названу двічі; беремо першу за +// алфавітом шаблону, щоб підпис не стрибав між перезавантаженнями. +func (s *Store) deviceTemplateItemNames(ctx context.Context, tenantID, deviceID string) (map[string]string, error) { + out := map[string]string{} + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, ` + SELECT COALESCE(it.metric_key, ''), COALESCE(it.name, '') + FROM tpl.device_templates dt + JOIN tpl.templates t ON t.id = dt.template_id + JOIN tpl.items it ON it.template_id = dt.template_id + WHERE dt.tenant_id = $1 AND dt.device_id = $2 + ORDER BY t.name, it.key + `, tenantID, deviceID) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var key, name string + if err := rows.Scan(&key, &name); err != nil { + return err + } + // Порожній ключ трапляється: елемент icmp-шаблону описує + // перевірку, а не окрему метрику (їх у неї три). Такий + // запис зіставляти нема з чим, і в мапі він лише зайняв би + // ключ "" — тобто підписав би собою перший-ліпший ряд без + // ключа, якби такий колись з'явився. + // + // COALESCE у запиті обов'язковий: у tpl.items колонка + // NULLABLE, і в живих даних там саме NULL, а не порожній + // рядок. Без нього сканування падає, і сторінка хоста + // віддає «внутрішню помилку» замість усіх своїх даних — + // через один рядок довідника, який до цих даних не має + // стосунку. + if key == "" { + continue + } + if _, ok := out[key]; !ok { + out[key] = name + } + } + return rows.Err() + }) + return out, err +} + +// DeviceInterface — порт із автовиявлення разом з останнім трафіком. +type DeviceInterface struct { + ID string `json:"id"` + IfIndex *int64 `json:"if_index,omitempty"` + Name string `json:"name"` + Alias string `json:"alias,omitempty"` + Type string `json:"type,omitempty"` + MAC string `json:"mac,omitempty"` + MTU *int `json:"mtu,omitempty"` + SpeedBps *int64 `json:"speed_bps,omitempty"` + Duplex string `json:"duplex,omitempty"` + AdminStatus string `json:"admin_status"` + OperStatus string `json:"oper_status"` + IsUplink bool `json:"is_uplink"` + Monitored bool `json:"monitored"` + LastChange *time.Time `json:"last_change_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + // Останні лічильники — те, заради чого на порт і дивляться. + CountersAt *time.Time `json:"counters_at,omitempty"` + InBps *float64 `json:"in_bps,omitempty"` + OutBps *float64 `json:"out_bps,omitempty"` + UtilIn *float64 `json:"util_in_pct,omitempty"` + UtilOut *float64 `json:"util_out_pct,omitempty"` + InErrors *int64 `json:"in_errors,omitempty"` + OutErrors *int64 `json:"out_errors,omitempty"` + // Скільки рядів метрик прив'язано саме до цього порту: звідси + // людина переходить у «Останні дані», а не гадає, чи щось збирається. + SeriesCount int `json:"series_count"` +} + +// DeviceInterfaces — порти хоста з останніми лічильниками. +// +// Один запит на весь перелік: у JUN.QFX сто портів, і окремий запит на +// кожен перетворив би відкриття вкладки на сотню походів у базу. Заміряно +// на стенді: 0.90 мс на сто портів. +func (s *Store) DeviceInterfaces(ctx context.Context, tenantID, deviceID string) ([]DeviceInterface, error) { + out := []DeviceInterface{} + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, ` + SELECT i.id::text, i.if_index, i.name, COALESCE(i.alias,''), + COALESCE(i.type,''), COALESCE(i.mac::text,''), i.mtu, + i.speed_bps, COALESCE(i.duplex,''), + i.admin_status::text, i.oper_status::text, + i.is_uplink, i.monitored, i.last_change_at, i.updated_at, + f.ts, f.in_bps, f.out_bps, f.util_in_pct, f.util_out_pct, + f.in_errors, f.out_errors, + COALESCE(sc.n, 0) + FROM inv.interfaces i + LEFT JOIN LATERAL ( + -- util_* лежать як real; приводимо явно, щоб не залежати + -- від того, чи вміє драйвер класти float4 у float64. + SELECT c.ts, c.in_bps, c.out_bps, + c.util_in_pct::double precision AS util_in_pct, + c.util_out_pct::double precision AS util_out_pct, + c.in_errors, c.out_errors + FROM ts.if_counters c + WHERE c.interface_id = i.id ORDER BY c.ts DESC LIMIT 1 + ) f ON true + LEFT JOIN ( + SELECT interface_id, count(*) AS n FROM ts.series + WHERE tenant_id = $1 AND device_id = $2 AND interface_id IS NOT NULL + GROUP BY interface_id + ) sc ON sc.interface_id = i.id + WHERE i.tenant_id = $1 AND i.device_id = $2 + ORDER BY i.if_index NULLS LAST, i.name + `, tenantID, deviceID) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var d DeviceInterface + if err := rows.Scan(&d.ID, &d.IfIndex, &d.Name, &d.Alias, &d.Type, + &d.MAC, &d.MTU, &d.SpeedBps, &d.Duplex, + &d.AdminStatus, &d.OperStatus, &d.IsUplink, &d.Monitored, + &d.LastChange, &d.UpdatedAt, + &d.CountersAt, &d.InBps, &d.OutBps, &d.UtilIn, &d.UtilOut, + &d.InErrors, &d.OutErrors, &d.SeriesCount); err != nil { + return err + } + out = append(out, d) + } + return rows.Err() + }) + return out, err +} + +// DeviceCounts — скільки чого система про хост зберігає. +// +// Це зміст картки, а не статистика: кожне число — це підпис під +// вкладкою, у яку по ньому й переходять. Порожня вкладка, про яку не +// сказано заздалегідь, читається як зламана. +type DeviceCounts struct { + Checks int `json:"checks"` + ChecksTemplate int `json:"checks_template"` + ChecksManual int `json:"checks_manual"` + ChecksAuto int `json:"checks_auto"` + Series int `json:"series"` + Interfaces int `json:"interfaces"` + InterfacesUp int `json:"interfaces_up"` + Configs int `json:"configs"` + Alerts int `json:"alerts"` +} + +// TemplateRef — шаблон, прив'язаний до хоста. +type TemplateRef struct { + ID string `json:"id"` + Key string `json:"key"` + Name string `json:"name"` + Items int `json:"items"` +} + +// CredentialRef — доступ, прив'язаний до хоста. +type CredentialRef struct { + ID string `json:"id"` + Name string `json:"name"` + Proto string `json:"proto"` +} + +// DeviceOverview — вкладка «Огляд»: паспорт хоста й зміст решти вкладок. +type DeviceOverview struct { + // Розпізнане по SNMP. Разом із помилкою й часом: «модель порожня» + // і «розпізнавання впало годину тому» — різні діагнози, і лікуються + // вони по-різному. + SysObjectID string `json:"sys_object_id,omitempty"` + SysDescr string `json:"sys_descr,omitempty"` + SystemName string `json:"system_name,omitempty"` + FQDN string `json:"fqdn,omitempty"` + Serial string `json:"serial_number,omitempty"` + ChassisID string `json:"chassis_id,omitempty"` + BaseMAC string `json:"base_mac,omitempty"` + Source string `json:"source,omitempty"` + IdentifiedAt *time.Time `json:"identified_at,omitempty"` + IdentifyRequestedAt *time.Time `json:"identify_requested_at,omitempty"` + HardwareChangedAt *time.Time `json:"hardware_changed_at,omitempty"` + IdentifyError string `json:"identify_error,omitempty"` + StatusChangedAt *time.Time `json:"status_changed_at,omitempty"` + Notes string `json:"notes,omitempty"` + + AgentID string `json:"agent_id,omitempty"` + AgentName string `json:"agent_name,omitempty"` + AgentStatus string `json:"agent_status,omitempty"` + + Templates []TemplateRef `json:"templates"` + Credentials []CredentialRef `json:"credentials"` + Counts DeviceCounts `json:"counts"` + + LastConfigAt *time.Time `json:"last_config_at,omitempty"` + LastJobStatus string `json:"last_job_status,omitempty"` + LastJobAt *time.Time `json:"last_job_at,omitempty"` + LastJobError string `json:"last_job_error,omitempty"` + // Остання успішна відповідь пінга — окремо від last_seen_at, бо + // той оновлює будь-який успішний чек. + LastPingAt *time.Time `json:"last_ping_at,omitempty"` +} + +// DeviceOverviewData — паспорт хоста одним запитом плюс два дрібні переліки. +// +// Скалярні підзапити, а не окремі походи: усі вони — count по індексу +// device_id, і планувальник рахує їх у тому самому проході. +func (s *Store) DeviceOverviewData(ctx context.Context, tenantID, deviceID string) (*DeviceOverview, error) { + o := &DeviceOverview{Templates: []TemplateRef{}, Credentials: []CredentialRef{}} + + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + err := tx.QueryRow(ctx, ` + SELECT COALESCE(d.sys_object_id,''), COALESCE(d.sys_descr,''), + COALESCE(d.system_name,''), COALESCE(d.fqdn,''), + COALESCE(d.serial_number,''), COALESCE(d.chassis_id,''), + COALESCE(d.base_mac::text,''), COALESCE(d.source,''), + d.identified_at, d.identify_requested_at, d.hardware_changed_at, + COALESCE(d.identify_error,''), d.status_changed_at, + COALESCE(d.notes,''), + COALESCE(d.agent_id::text,''), COALESCE(ag.name,''), + COALESCE(ag.status::text,''), + (SELECT count(*) FROM core.checks c WHERE c.device_id = d.id), + (SELECT count(*) FROM core.checks c + WHERE c.device_id = d.id AND c.template_id IS NOT NULL), + (SELECT count(*) FROM core.checks c + JOIN core.check_types ct ON ct.key = c.check_type + WHERE c.device_id = d.id AND c.template_id IS NULL AND ct.auto_managed), + (SELECT count(*) FROM ts.series s WHERE s.device_id = d.id), + (SELECT count(*) FROM inv.interfaces i WHERE i.device_id = d.id), + (SELECT count(*) FROM inv.interfaces i + WHERE i.device_id = d.id AND i.oper_status = 'up'), + (SELECT count(*) FROM ncm.configs cf WHERE cf.device_id = d.id), + (SELECT max(cf.collected_at) FROM ncm.configs cf WHERE cf.device_id = d.id), + (SELECT count(*) FROM alr.alerts a + WHERE a.device_id = d.id AND a.state IN ('firing','acknowledged','suppressed')), + (SELECT max(ic.ts) FROM ts.icmp_samples ic + WHERE ic.device_id = d.id AND ic.reachable) + FROM inv.devices d + LEFT JOIN core.agents ag ON ag.id = d.agent_id + WHERE d.tenant_id = $1 AND d.id = $2 AND d.deleted_at IS NULL + `, tenantID, deviceID).Scan( + &o.SysObjectID, &o.SysDescr, &o.SystemName, &o.FQDN, + &o.Serial, &o.ChassisID, &o.BaseMAC, &o.Source, + &o.IdentifiedAt, &o.IdentifyRequestedAt, &o.HardwareChangedAt, + &o.IdentifyError, &o.StatusChangedAt, &o.Notes, + &o.AgentID, &o.AgentName, &o.AgentStatus, + &o.Counts.Checks, &o.Counts.ChecksTemplate, &o.Counts.ChecksAuto, + &o.Counts.Series, &o.Counts.Interfaces, &o.Counts.InterfacesUp, + &o.Counts.Configs, &o.LastConfigAt, &o.Counts.Alerts, &o.LastPingAt) + if err != nil { + return err + } + o.Counts.ChecksManual = o.Counts.Checks - o.Counts.ChecksTemplate - o.Counts.ChecksAuto + + trows, err := tx.Query(ctx, ` + SELECT t.id::text, t.key::text, t.name, + (SELECT count(*) FROM tpl.items it WHERE it.template_id = t.id) + FROM tpl.device_templates dt + JOIN tpl.templates t ON t.id = dt.template_id + WHERE dt.tenant_id = $1 AND dt.device_id = $2 + ORDER BY t.name + `, tenantID, deviceID) + if err != nil { + return err + } + defer trows.Close() + for trows.Next() { + var t TemplateRef + if err := trows.Scan(&t.ID, &t.Key, &t.Name, &t.Items); err != nil { + return err + } + o.Templates = append(o.Templates, t) + } + if err := trows.Err(); err != nil { + return err + } + + crows, err := tx.Query(ctx, ` + SELECT c.id::text, c.name, c.proto::text + FROM inv.device_credentials dc + JOIN inv.credentials c ON c.id = dc.credential_id + WHERE c.tenant_id = $1 AND dc.device_id = $2 + ORDER BY c.proto, c.name + `, tenantID, deviceID) + if err != nil { + return err + } + defer crows.Close() + for crows.Next() { + var c CredentialRef + if err := crows.Scan(&c.ID, &c.Name, &c.Proto); err != nil { + return err + } + o.Credentials = append(o.Credentials, c) + } + if err := crows.Err(); err != nil { + return err + } + + // Останнє завдання збору конфігу — окремо від самих версій: + // найцікавіший стан це «версій три, а останній збір упав». + var status, jobErr string + var at *time.Time + err = tx.QueryRow(ctx, ` + SELECT status::text, COALESCE(error,''), COALESCE(finished_at, created_at) + FROM ncm.jobs + WHERE tenant_id = $1 AND device_id = $2 + ORDER BY created_at DESC LIMIT 1 + `, tenantID, deviceID).Scan(&status, &jobErr, &at) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return err + } + if err == nil { + o.LastJobStatus, o.LastJobError, o.LastJobAt = status, jobErr, at + } + return nil + }) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + return o, nil +} diff --git a/server/internal/store/device_detail_test.go b/server/internal/store/device_detail_test.go new file mode 100644 index 0000000..f235f89 --- /dev/null +++ b/server/internal/store/device_detail_test.go @@ -0,0 +1,181 @@ +package store + +import ( + "encoding/json" + "testing" + "time" +) + +// Зіставлення ряду з перевіркою — висновок, а не факт зі схеми. Тест +// стереже саме ПОРЯДОК висновків: помилка тут не падає й не світиться, +// вона тихо показує людині не той шаблон, у який іти щось міняти. +func TestMatchOriginPriority(t *testing.T) { + checks := []DeviceCheck{ + { + ID: "icmp", CheckType: "icmp.ping", Origin: "template", + TemplateID: "t1", TemplateName: "Доступність (ICMP)", Enabled: true, + IntervalSec: 60, Params: json.RawMessage(`{"count":3}`), + Metrics: []string{"icmp.rtt_avg", "icmp.loss_pct", "icmp.jitter"}, + }, + { + ID: "get", CheckType: "snmp.get", Origin: "template", + TemplateID: "t2", TemplateName: "Загальне (SNMP)", Enabled: true, + IntervalSec: 60, + Params: json.RawMessage( + `{"oids":[{"oid":".1.3.6.1.2.1.1.3.0","metric_key":"sys.uptime_sec"}]}`), + Metrics: []string{}, + }, + { + ID: "if", CheckType: "snmp.if", Origin: "auto", AutoManaged: true, + Enabled: true, IntervalSec: 60, + Params: json.RawMessage(`{"interfaces":[{"if_index":1,"interface_id":"IF-1"}]}`), + Metrics: []string{"if.in_bps", "if.out_bps"}, + }, + } + + cases := []struct { + name string + metric LatestMetric + wantID string + wantKnd string + }{ + { + // Ключ прямо названий у params — найнадійніше джерело. + name: "ключ метрики з params", + metric: LatestMetric{MetricKey: "sys.uptime_sec", PluginKey: "snmp"}, + wantID: "get", + wantKnd: "template", + }, + { + // Порт у переліку чека переважає збіг за довідником: обидва + // вказують на snmp.if, але перший знає ще й ЯКИЙ порт. + name: "порт із переліку чека", + metric: LatestMetric{ + MetricKey: "if.in_bps", PluginKey: "snmp", InterfaceID: "IF-1", + }, + wantID: "if", + wantKnd: "auto", + }, + { + // Той самий плагін snmp, що й у snmp.get, але ключ стоїть у + // довіднику саме snmp.if — довідник має бути сильнішим за + // збіг плагіна, інакше метрики портів приписались би до + // шаблонного snmp.get. + name: "довідник типу переважає плагін", + metric: LatestMetric{ + MetricKey: "if.out_bps", PluginKey: "snmp", InterfaceID: "IF-9", + }, + wantID: "if", + wantKnd: "auto", + }, + { + name: "довідник icmp", + metric: LatestMetric{MetricKey: "icmp.jitter", PluginKey: "icmp"}, + wantID: "icmp", + wantKnd: "template", + }, + { + // Плагін збігається, ключа немає ніде — здогад, але кращий + // за «невідомо»: чек icmp.ping у хоста один. + name: "тільки плагін", + metric: LatestMetric{MetricKey: "icmp.rtt_p95", PluginKey: "icmp"}, + wantID: "icmp", + wantKnd: "template", + }, + { + // Найважливіший випадок: серія є, чека немає. Саме такий + // стан на стенді має 466 рядів if.* — і саме він виглядав + // як «метрики беруться нізвідки». + name: "чека немає", + metric: LatestMetric{MetricKey: "cpu.util", PluginKey: "unknown"}, + wantID: "", + wantKnd: "none", + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := matchOrigin(&c.metric, checks, map[string]string{}) + if got.CheckID != c.wantID { + t.Errorf("check_id = %q, очікували %q", got.CheckID, c.wantID) + } + if got.Kind != c.wantKnd { + t.Errorf("kind = %q, очікували %q", got.Kind, c.wantKnd) + } + }) + } +} + +func TestIsStale(t *testing.T) { + now := time.Date(2026, 8, 26, 12, 0, 0, 0, time.UTC) + at := func(d time.Duration) *time.Time { t := now.Add(-d); return &t } + + cases := []struct { + name string + m LatestMetric + want bool + }{ + { + // Один пропуск у хвилинного чека — це життя, а не збій. + name: "пропуск такту", + m: LatestMetric{ + LastAt: at(90 * time.Second), + Origin: MetricOrigin{IntervalSec: 60, Enabled: true}, + }, + want: false, + }, + { + // П'ятихвилинний чек: три такти — це чверть години, і саме + // вони, а не нижня межа, вирішують. + name: "три такти поспіль", + m: LatestMetric{ + LastAt: at(20 * time.Minute), + Origin: MetricOrigin{IntervalSec: 300, Enabled: true}, + }, + want: true, + }, + { + // Нижня межа тримає хвилинний чек у спокої довше, ніж три + // такти: пропуск на такій частоті — не подія. + name: "хвилинний чек, п'ять хвилин мовчання", + m: LatestMetric{ + LastAt: at(5 * time.Minute), + Origin: MetricOrigin{IntervalSec: 60, Enabled: true}, + }, + want: false, + }, + { + // Секундний чек не має ставати червоним щохвилини — нижня + // межа порога тримає його в спокої. + name: "дуже частий чек", + m: LatestMetric{ + LastAt: at(2 * time.Minute), + Origin: MetricOrigin{IntervalSec: 5, Enabled: true}, + }, + want: false, + }, + { + name: "ряд без чека, дані свіжі", + m: LatestMetric{LastAt: at(time.Minute), Origin: MetricOrigin{Kind: "none"}}, + want: false, + }, + { + name: "ряд без чека, дані вчорашні", + m: LatestMetric{LastAt: at(20 * time.Hour), Origin: MetricOrigin{Kind: "none"}}, + want: true, + }, + { + name: "жодної точки", + m: LatestMetric{Origin: MetricOrigin{IntervalSec: 60, Enabled: true}}, + want: true, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := isStale(&c.m, now); got != c.want { + t.Errorf("isStale = %v, очікували %v", got, c.want) + } + }) + } +} diff --git a/server/internal/store/device_filter.go b/server/internal/store/device_filter.go new file mode 100644 index 0000000..029f35a --- /dev/null +++ b/server/internal/store/device_filter.go @@ -0,0 +1,102 @@ +package store + +import ( + "fmt" + "strings" +) + +// Одна умова DeviceFilter — на всі запити, що ним відбирають. +// +// Навіщо окремий файл. Той самий шматок WHERE вже стояв у двох місцях: +// у доборі цілей для масових команд і в доборі цілей для масової правки +// хостів. Третє місце (добір хостів на сторінці «Конфіги») перетворило б +// збіг на систему: три копії умови розходяться не одразу, а на першому ж +// новому полі — і тоді однаково заповнений фільтр дає різні набори на +// різних сторінках. Помітити це неможливо інакше, ніж звірянням двох +// переліків руками. +// +// Тому умова живе тут одна, а запити вставляють її в себе. Ціна — SQL +// збирається рядками; вигода — нове поле фільтра з'являється відразу +// скрізь, і забути одне з місць нема де. +// +// Обмеження, яке треба тримати в голові: вставка розраховує на +// конкретні псевдоніми таблиць — `d` для inv.devices і `st` для +// inv.sites (LEFT JOIN, бо майданчик не обов'язковий). Запит, який хоче +// цю умову, називає таблиці так само. + +// deviceFilterSQL повертає умови фільтра й значення до них. +// +// `n` — номер першого вільного плейсхолдера в запиті: умова нумерує +// свої $-параметри від нього й далі, тож викликач лишається вільним у +// тому, скільки власних параметрів поставив перед нею. +func deviceFilterSQL(f DeviceFilter, n int) (cond string, args []any) { + verOp, verVal := versionCond(f) + + // Плейсхолдери рахуються від n, а не пишуться числами: інакше будь-яка + // вставка нового параметра посеред умови мовчки зсунула б решту. + p := func(i int) string { return fmt.Sprintf("$%d", n+i) } + + var b strings.Builder + fmt.Fprintf(&b, ` + AND (NOT %[1]s::boolean OR d.enabled) + AND (cardinality(%[2]s::text[]) = 0 + OR lower(COALESCE(d.vendor,'')) = ANY(%[2]s::text[])) + AND (cardinality(%[3]s::text[]) = 0 OR d.kind::text = ANY(%[3]s::text[])) + AND (cardinality(%[4]s::text[]) = 0 OR COALESCE(st.name,'') = ANY(%[4]s::text[])) + AND (cardinality(%[5]s::text[]) = 0 OR d.status::text = ANY(%[5]s::text[])) + AND (cardinality(%[6]s::uuid[]) = 0 OR EXISTS ( + SELECT 1 FROM inv.device_group_members m + WHERE m.device_id = d.id AND m.group_id = ANY(%[6]s::uuid[]) + )) + -- Швидкий пошук іде по всьому, що видно в переліку хостів. + -- + -- Адреса тут не для повноти: «айпі» — це те, чим інженер + -- називає хост уголос, і поле, у яке він його вводить, має + -- його знайти. Майданчик і версія ПЗ теж у цьому переліку, + -- хоч і мають власні поля фільтра: людина, яка набирає + -- «Миронівка», не має спершу здогадатись, що це майданчик, + -- а не частина імені. + AND (%[7]s = '' OR d.name ILIKE '%%' || %[7]s || '%%' + OR COALESCE(host(d.address),'') ILIKE '%%' || %[7]s || '%%' + OR COALESCE(d.vendor,'') ILIKE '%%' || %[7]s || '%%' + OR COALESCE(d.model,'') ILIKE '%%' || %[7]s || '%%' + OR COALESCE(d.os_version,'') ILIKE '%%' || %[7]s || '%%' + OR COALESCE(st.name,'') ILIKE '%%' || %[7]s || '%%') + AND (cardinality(%[8]s::text[]) = 0 + OR lower(COALESCE(d.model,'')) = ANY(%[8]s::text[])) + -- Умова на версію. Порівняння без регістру: те саме поле + -- заповнюють і розпізнавання по sysDescr, і людина руками. + AND (CASE %[9]s::text + WHEN 'eq' THEN lower(COALESCE(d.os_version,'')) = lower(%[10]s::text) + -- 'ne' навмисно захоплює й порожню версію: хост, про + -- прошивку якого ми нічого не знаємо, точно не на + -- цільовій, і зі списку «що лишилось оновити» він + -- випадати не має. + WHEN 'ne' THEN lower(COALESCE(d.os_version,'')) <> lower(%[10]s::text) + WHEN 'contains' THEN COALESCE(d.os_version,'') ILIKE '%%' || %[10]s::text || '%%' + WHEN 'ncontains' THEN COALESCE(d.os_version,'') NOT ILIKE '%%' || %[10]s::text || '%%' + WHEN 'empty' THEN COALESCE(d.os_version,'') = '' + ELSE true + END)`, + p(0), p(1), p(2), p(3), p(4), p(5), p(6), p(7), p(8), p(9)) + + return b.String(), []any{ + f.OnlyEnabled, + lowerAll(f.Vendors), + nonNilIDs(f.Kinds), + nonNilIDs(f.Sites), + nonNilIDs(f.Statuses), + nonNilIDs(f.GroupIDs), + strings.TrimSpace(f.Query), + lowerAll(f.Models), + verOp, + verVal, + } +} + +// deviceFilterArgs — скільки плейсхолдерів займає умова. +// +// Потрібне тим запитам, які ставлять власні параметри ПІСЛЯ фільтра: +// без цього числа довелось би рахувати $-и очима щоразу, коли у фільтрі +// з'явиться поле. +const deviceFilterArgs = 10 diff --git a/server/internal/store/device_filter_test.go b/server/internal/store/device_filter_test.go new file mode 100644 index 0000000..dd7050e --- /dev/null +++ b/server/internal/store/device_filter_test.go @@ -0,0 +1,83 @@ +package store + +import ( + "fmt" + "strings" + "testing" +) + +// Умову фільтра вставляють у три різні запити, і кожен ставить її після +// свого числа власних параметрів. Зсув на одиницю тут не падає й не +// помиляється помітно — він мовчки порівнює не ті колонки. Тому тест +// саме на нумерацію. +func TestDeviceFilterSQLNumbersPlaceholdersFromOffset(t *testing.T) { + for _, start := range []int{2, 4, 5, 11} { + cond, args := deviceFilterSQL(DeviceFilter{}, start) + + if len(args) != deviceFilterArgs { + t.Fatalf("значень %d, а deviceFilterArgs каже %d", len(args), deviceFilterArgs) + } + // Жодного плейсхолдера до початку діапазону й жодного після кінця. + if before := fmt.Sprintf("$%d", start-1); strings.Contains(cond, before+" ") || + strings.Contains(cond, before+":") { + t.Fatalf("умова з %d чіпає чужий %s", start, before) + } + after := fmt.Sprintf("$%d", start+deviceFilterArgs) + if strings.Contains(cond, after) { + t.Fatalf("умова з %d вийшла за свій діапазон: %s", start, after) + } + // І кожен свій — на місці. + for i := 0; i < deviceFilterArgs; i++ { + if p := fmt.Sprintf("$%d", start+i); !strings.Contains(cond, p) { + t.Fatalf("умова з %d не використовує %s", start, p) + } + } + } +} + +// «Айпі» — те, чим інженер називає хост уголос. Поле швидкого пошуку +// мусить його знаходити, інакше людина шукає адресу очима в переліку. +func TestDeviceFilterSQLQuerySearchesAddress(t *testing.T) { + cond, _ := deviceFilterSQL(DeviceFilter{Query: "10.1.0."}, 4) + for _, col := range []string{ + "host(d.address)", "d.name", "d.vendor", "d.model", "d.os_version", "st.name", + } { + if !strings.Contains(cond, col+"") { + t.Fatalf("швидкий пошук не дивиться в %s", col) + } + } +} + +// Значення йдуть рівно в тому порядку, у якому умова їх нумерує. +func TestDeviceFilterSQLArgOrder(t *testing.T) { + _, args := deviceFilterSQL(DeviceFilter{ + Query: " Миронівка ", + Vendors: []string{"Huawei"}, + OnlyEnabled: true, + VersionOp: VersionOpNe, + VersionValue: " 5.70 ", + }, 4) + + if args[0] != true { + t.Fatalf("перше значення мало бути only_enabled, отримали %#v", args[0]) + } + if v, ok := args[1].([]string); !ok || len(v) != 1 || v[0] != "huawei" { + t.Fatalf("виробники мали приїхати в нижньому регістрі, отримали %#v", args[1]) + } + if args[6] != "Миронівка" { + t.Fatalf("пошук мав приїхати обрізаним, отримали %#v", args[6]) + } + if args[8] != VersionOpNe || args[9] != "5.70" { + t.Fatalf("умова версії приїхала як %#v / %#v", args[8], args[9]) + } +} + +// Порожнє значення при заданій операції означає «умови немає», а не +// «версія дорівнює порожньому рядку»: фільтр складають на льоту, і +// стерте поле має повернути повний перелік, а не порожній. +func TestDeviceFilterSQLEmptyVersionValueDropsCondition(t *testing.T) { + _, args := deviceFilterSQL(DeviceFilter{VersionOp: VersionOpEq, VersionValue: " "}, 4) + if args[8] != "" { + t.Fatalf("операція мала зникнути разом зі значенням, отримали %#v", args[8]) + } +} diff --git a/server/internal/store/devices_bulk.go b/server/internal/store/devices_bulk.go new file mode 100644 index 0000000..f14d6a3 --- /dev/null +++ b/server/internal/store/devices_bulk.go @@ -0,0 +1,864 @@ +package store + +import ( + "context" + "fmt" + "strings" + + "github.com/jackc/pgx/v5" +) + +// Масова правка й масове видалення хостів. +// +// Дві дії в одному файлі, бо в них спільне найважче — добір набору. +// Оператор бачить перелік, відмічає в ньому рядки й очікує, що діятиме +// саме на них. Між тим, як він подивився, і тим, як натиснув, минає +// час: хтось міг завести хост, забрати доступ, видалити інший. Тому +// набір тут резолвиться так само, як у масовому виконанні команд +// (див. ResolveCommandTargets): клієнт шле перелік id РАЗОМ із +// фільтром, сервер бере перетин, а те, що відпало, повертає окремо. +// +// Друга спільна річ — межі доступу. Береться Scope.Writable, а не +// Readable: «бачу хост» і «можу його змінити» — різні права, і хост, +// до якого людині дали лише читання, не має мовчки потрапити в набір +// масової дії. Мовчки — ключове слово: він не просто відсіюється, а +// повертається у списку відсіяних, інакше людина рахувала б, що +// змінила п'ятдесят, а змінила сорок. + +// MaxBulkDevices — стеля на одну масову дію. +// +// Двісті, а не «скільки влізе». Причина не в продуктивності запиту: +// UPDATE на десять тисяч рядків Postgres зробить швидко. Причина в +// тому, що вся правка йде однією транзакцією, а разом із хостами вона +// чіпає членство в групах, доступи й розклади бекапів — тобто тримає +// блокування на пів-інвентарю, поки диспетчер колектора намагається +// прочитати з тих самих таблиць свій план на наступний такт. +// +// Двісті — це також приблизно те, що людина здатна побачити в +// підтвердженні. Масова дія, перелік якої не вміщається в жоден екран, +// підтверджується наосліп, а «підтверджено наосліп» і «підтверджено» — +// різні речі. Хто справді міняє тисячу хостів, зробить це п'ятьма +// заходами й побачить результат кожного. +const MaxBulkDevices = 200 + +// BulkDeviceTarget — хост у наборі масової дії. +// +// Разом із тим, що до нього прив'язане. Ці числа потрібні саме тут, а +// не після дії: питання «що станеться з перевірками, розкладом і +// вузлом на мапі» людина ставить ДО натискання, і відповідь має бути +// на тому ж екрані, де кнопка. +type BulkDeviceTarget struct { + DeviceID string `json:"device_id"` + Name string `json:"name"` + Address string `json:"address,omitempty"` + Kind string `json:"kind"` + Vendor string `json:"vendor,omitempty"` + SiteName string `json:"site_name,omitempty"` + Status string `json:"status"` + Enabled bool `json:"enabled"` + + // Скільки чеків опитує цей хост. Після видалення вони лишаються в + // базі, але планувальник їх не бачить — він бере лише живі хости. + Checks int `json:"checks"` + // Активні алерти: firing / acknowledged / suppressed. + ActiveAlerts int `json:"active_alerts"` + // Скільки вузлів на мапах посилаються на цей хост. + MapNodes int `json:"map_nodes"` + // Чи є власний розклад збору конфігів. + HasBackupPolicy bool `json:"has_backup_policy"` + // Скільки версій конфігу вже зібрано. Архівне видалення їх не + // чіпає, повне — стирає разом із зашифрованими тілами. + Configs int `json:"configs"` + + // Далі — числа, які має сенс читати саме перед ПОВНИМ видаленням. + // + // Вони тут, а не окремою ручкою, з тієї ж причини, з якої тут + // стоять перевірки й вузли мап: людина обирає між «прибрати з + // переліку» й «видалити назавжди» в одному вікні, і різниця між + // цими двома діями виражається саме числами. Дві ручки означали б, + // що половина відповіді приходить після того, як вибір уже зроблено. + + // Сумарний розмір збережених конфігів. Байти, а не версії: «12 + // версій» нічого не каже про те, скільки місця звільниться. + ConfigBytes int64 `json:"config_bytes"` + // Рядів метрик — те, що на сторінці «Метрики» виглядає як графіки. + Series int `json:"series"` + // Вимірювань у цих рядах. Рахується зі стелею (див. + // purgeCountCap): точне число тут не варте повного перегляду + // гіпертаблиці, а «понад мільйон» лякає рівно так само. + Samples int64 `json:"samples"` + // SamplesCapped — лічильник уперся в стелю, справжнє число більше. + SamplesCapped bool `json:"samples_capped,omitempty"` + // Усі алерти, а не лише активні: історія розбору інцидентів по + // цьому хосту зникає разом із ним. + Alerts int `json:"alerts"` + // Портів у інвентарі. + Interfaces int `json:"interfaces"` + // Гілка в Git-архіві. Порожньо — хост жодного разу не комітився, + // тобто прибирати на дзеркалі нема чого. + Branch string `json:"branch,omitempty"` +} + +// purgeCountCap — стеля лічильника вимірювань. +// +// Точний count(*) по гіпертаблиці за місяць даних для двохсот хостів — +// це хвилини читання заради числа у вікні підтвердження. Питання, на +// яке людина відповідає цим вікном, звучить «багато чи мало», а не +// «скільки саме»: «понад 1 000 000» відповідає на нього так само точно, +// як 1 348 512, і коштує рівно стелю. +const purgeCountCap = 1_000_000 + +// ResolveBulkTargets — перетин «що обрала людина» і «що їй дозволено». +// +// Фільтр той самий DeviceFilter, що й у масовому виконанні команд. +// Другої мови фільтрів у продукті бути не повинно: людина, яка +// відібрала в переліку хостів «усі проблемні в групі Миронівка», має +// відібрати те саме будь-де ще тими самими полями. +// +// На відміну від ResolveCommandTargets, тут перелік id обов'язковий. +// Різниця не косметична: команду запускають ПО ФІЛЬТРУ («прогнати на +// всіх Huawei»), а хости правлять і видаляють ПО ВІДМІЧЕНИХ РЯДКАХ. +// Дозволити тут порожній перелік означало б дати спосіб видалити +// півінвентарю запитом, у якому не названо жодного хоста. +func (s *Store) ResolveBulkTargets(ctx context.Context, tenantID string, + sc Scope, f DeviceFilter, ids []string) ([]BulkDeviceTarget, error) { + + return s.resolveBulkTargets(ctx, tenantID, sc, f, ids, false) +} + +// ResolveBulkPurgeTargets — те саме, але бачить і прибрані хости. +// +// Без цього «прибрати з переліку» перетворювалось на пастку: хост +// зникав з усіх переліків, а разом із ним зникала й будь-яка можливість +// його добити. Зібране лишалось у базі назавжди — рівно те сміття, +// заради якого повне видалення й заводили. +// +// Стосується ЛИШЕ повного видалення. Масова правка прибраного хоста +// сенсу не має: міняти налаштування тому, кого вже немає, — це спосіб +// повернути його наполовину. +func (s *Store) ResolveBulkPurgeTargets(ctx context.Context, tenantID string, + sc Scope, f DeviceFilter, ids []string) ([]BulkDeviceTarget, error) { + + return s.resolveBulkTargets(ctx, tenantID, sc, f, ids, true) +} + +func (s *Store) resolveBulkTargets(ctx context.Context, tenantID string, + sc Scope, f DeviceFilter, ids []string, withDeleted bool) ([]BulkDeviceTarget, error) { + + if len(ids) == 0 { + return nil, fmt.Errorf("%w: не обрано жодного хоста", ErrInvalid) + } + if len(ids) > MaxBulkDevices { + return nil, fmt.Errorf("%w: за раз можна взяти не більше %d хостів, обрано %d", + ErrInvalid, MaxBulkDevices, len(ids)) + } + + var out []BulkDeviceTarget + + // Умова фільтра — та сама вставка, що й у масовому виконанні команд + // (device_filter.go): один фільтр — одне тлумачення. Хост, який там + // потрапив під «усе, що не на цій прошивці», має потрапити під нього + // й тут. + // $2 — стеля лічильника вимірювань; далі перелік id, межі доступу, + // ознака «бачити прибраних» і лише потім умови фільтра. + cond, condArgs := deviceFilterSQL(f, 7) + args := append([]any{ + tenantID, purgeCountCap, nonNilIDs(ids), sc.Unrestricted, nonNilIDs(sc.Writable), + withDeleted, + }, condArgs...) + + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, ` + SELECT d.id::text, d.name, COALESCE(host(d.address),''), d.kind::text, + COALESCE(d.vendor,''), COALESCE(st.name,''), + d.status::text, d.enabled, + (SELECT count(*) FROM core.checks c WHERE c.device_id = d.id), + (SELECT count(*) FROM alr.alerts a + WHERE a.device_id = d.id + AND a.state IN ('firing','acknowledged','suppressed')), + (SELECT count(*) FROM topo.map_nodes n WHERE n.device_id = d.id), + EXISTS (SELECT 1 FROM ncm.device_policies p WHERE p.device_id = d.id), + (SELECT count(*) FROM ncm.configs cf WHERE cf.device_id = d.id), + (SELECT COALESCE(sum(cf.size_bytes),0) FROM ncm.configs cf + WHERE cf.device_id = d.id), + (SELECT count(*) FROM ts.series s WHERE s.device_id = d.id), + -- Стеля в підзапиті, а не count(*) по всьому: див. + -- purgeCountCap. LIMIT усередині — саме те, що + -- перетворює повний перегляд на обмежений. + (SELECT count(*) FROM ( + SELECT 1 FROM ts.samples sm + WHERE sm.series_id IN (SELECT s2.id FROM ts.series s2 + WHERE s2.device_id = d.id) + LIMIT $2::int) q), + (SELECT count(*) FROM alr.alerts a2 WHERE a2.device_id = d.id), + (SELECT count(*) FROM inv.interfaces i WHERE i.device_id = d.id), + COALESCE((SELECT cf.branch FROM ncm.configs cf + WHERE cf.device_id = d.id AND cf.branch <> '' + ORDER BY cf.collected_at DESC LIMIT 1), '') + FROM inv.devices d + LEFT JOIN inv.sites st ON st.id = d.site_id + WHERE d.tenant_id = $1 AND (d.deleted_at IS NULL OR $6::boolean) + AND d.id = ANY($3::uuid[]) + AND ($4::boolean OR d.id = ANY($5::uuid[]))`+cond+` + ORDER BY d.name + `, args...) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var t BulkDeviceTarget + if err := rows.Scan(&t.DeviceID, &t.Name, &t.Address, &t.Kind, &t.Vendor, + &t.SiteName, &t.Status, &t.Enabled, + &t.Checks, &t.ActiveAlerts, &t.MapNodes, &t.HasBackupPolicy, + &t.Configs, &t.ConfigBytes, &t.Series, &t.Samples, + &t.Alerts, &t.Interfaces, &t.Branch); err != nil { + return err + } + // Гілка, під якою хост комітився, могла ще не оновитись + // після перейменування — тоді показуємо ту, яку він мав би + // зараз. Видалення прибирає обидві (див. purgeOne), але + // показати треба ту, що впізнається в дзеркалі. + if t.Branch == "" && t.Configs > 0 { + t.Branch = DeviceBranch(t.DeviceID, t.Name, t.Address) + } + t.SamplesCapped = t.Samples >= purgeCountCap + out = append(out, t) + } + return rows.Err() + }) + return out, err +} + +// --------------------------------------------------------------------- +// Форма масової правки +// +// Головне рішення всієї функції: «не чіпати» — це не «стерти». +// +// Форма на десять полів, застосована до п'ятдесяти хостів, не має +// занулити те, чого людина не заповнювала. Тому кожне поле має три +// стани, і всі три названі явно: +// +// поля немає в JSON — не чіпати (значення кожного хоста лишається своїм) +// {"op":"set", ...} — задати всім однакове +// {"op":"clear"} — очистити всім +// +// Порожній рядок як ознака «очистити» тут не годиться, хоч так і +// зроблено в одиничній формі: там людина бачить поточне значення в +// полі й стирає його свідомо, а тут поле порожнє ЗАВЖДИ — у п'ятдесяти +// хостів немає спільного «поточного значення», яке можна було б +// показати. Порожнє поле в масовій формі означає «я про це не +// говорив», і трактувати його як «зітри в усіх» — найдорожча помилка, +// яку ця функція може зробити. +// +// Той самий підхід уже несе патч мапи (див. maps_write.go): там nil +// означає «не чіпати», бо перетягування вузла шле лише координати. +// Різниця в тому, що там третього стану не потрібно, а тут потрібен. +// --------------------------------------------------------------------- + +// StringOp — текстове або посилальне поле. +type StringOp struct { + Op string `json:"op"` + Value string `json:"value,omitempty"` +} + +// BoolOp — прапорець. Очищати нема чого: false — це значення, а не +// відсутність значення. +type BoolOp struct { + Op string `json:"op"` + Value bool `json:"value"` +} + +// ListOp — набір зв'язків (групи, шаблони, доступи). +// +// Тут трьох станів мало, і це не примха форми, а суть множини. +// «Додати до груп» і «прибрати з груп» — різні дії з різними +// наслідками, і жодна з них не виражається через «задати перелік»: +// хости в наборі мають РІЗНІ поточні групи, і спільного «задати» для +// них не існує, поки людина не скаже, що старе можна викинути. +// +// add — дописати, наявне лишити +// remove — прибрати саме ці, решту лишити +// replace — викинути все й поставити рівно це +// +// replace лишено навмисно, попри те що воно найнебезпечніше: без нього +// неможливо навести лад там, де групи вже наставлені абияк. Але в +// інтерфейсі воно окремим вибором зі своїм попередженням, а не +// поведінкою за замовчуванням. +type ListOp struct { + Op string `json:"op"` + IDs []string `json:"ids"` +} + +// BulkBackupOp — розклад збору конфігів. +// +// Тут «задати» діє на весь об'єкт розкладу одразу, а не на кожне його +// поле окремо. Розклад — це одне рішення («щодня о третій, профілем +// huawei-vrp, доступом noc-ssh»), а не набір незалежних дрібниць: +// увімкнути збір, не сказавши чим і коли, означає завести політику, +// яка мовчки нічого не збирає. Три стани лишаються на рівні об'єкта: +// поля немає — розклади хостів не чіпаємо, set — ставимо всім один, +// clear — прибираємо розклад зовсім. +type BulkBackupOp struct { + Op string `json:"op"` + ProfileID string `json:"profile_id,omitempty"` + CredentialID string `json:"credential_id,omitempty"` + Enabled bool `json:"enabled"` + Cron string `json:"cron,omitempty"` + OnSyslog bool `json:"on_syslog"` + FollowsDefault bool `json:"follows_default"` +} + +// BulkDeviceChanges — що саме міняється. +// +// Чого тут немає й не буде: імені та адреси. Вони унікальні на хост +// (ім'я — унікальним індексом, адреса — фізично), і масова зміна тут +// означала б або помилку оператора, або спробу зламати інвентар. +// Немає й нотаток: це людський текст про конкретний хост, і затерти +// п'ятдесят різних приміток одним абзацом — те саме, що затерти імена. +type BulkDeviceChanges struct { + // Зонд-опитувач. Найчастіша причина масової правки взагалі: + // дільницю перевели на інший зонд. + AgentID *StringOp `json:"agent_id,omitempty"` + // Тип хоста. Очищення немає: kind — NOT NULL з типовим 'other', і + // «очистити» тут означало б «поставити other», тобто те саме set. + Kind *StringOp `json:"kind,omitempty"` + // Виробник. Очищення осмислене: порожній виробник повертає хост + // під автоматичне розпізнавання, яке своє значення не перезаписує. + Vendor *StringOp `json:"vendor,omitempty"` + Enabled *BoolOp `json:"enabled,omitempty"` + + Groups *ListOp `json:"groups,omitempty"` + Templates *ListOp `json:"templates,omitempty"` + Credentials *ListOp `json:"credentials,omitempty"` + Backup *BulkBackupOp `json:"backup,omitempty"` + // Ручні перевірки — див. devices_bulk_checks.go. Тут вони окремим + // об'єктом, а не переліком, бо «додати» і «прибрати» називають тип, + // якого стосуються, і змішувати їх в одну дію нема сенсу. + Checks *BulkChecksOp `json:"checks,omitempty"` +} + +// Empty — чи форма взагалі щось каже. +// +// Порожня масова правка не є безпечним «нічого не сталось»: людина +// натиснула кнопку й чекає на результат, і мовчазне «готово» вона +// прочитає як «застосовано». Тому це помилка, а не успіх. +func (c BulkDeviceChanges) Empty() bool { + return c.AgentID == nil && c.Kind == nil && c.Vendor == nil && + c.Enabled == nil && c.Groups == nil && c.Templates == nil && + c.Credentials == nil && c.Backup == nil && c.Checks == nil +} + +func (o *StringOp) valid() error { + if o == nil { + return nil + } + switch o.Op { + case "set", "clear": + return nil + } + return fmt.Errorf("%w: невідома дія над полем: %q", ErrInvalid, o.Op) +} + +func (o *ListOp) valid() error { + if o == nil { + return nil + } + switch o.Op { + case "add", "remove", "replace": + default: + return fmt.Errorf("%w: невідома дія над переліком: %q", ErrInvalid, o.Op) + } + // «Додати нічого» і «прибрати нічого» — не помилка бази, а + // незаповнена форма. Мовчки виконати її означає показати «готово» + // там, де не сталось нічого. + if o.Op != "replace" && len(o.IDs) == 0 { + return fmt.Errorf("%w: не обрано жодного елемента для дії %q", ErrInvalid, o.Op) + } + return nil +} + +// BulkUpdateDevices застосовує правку до набору хостів. +// +// Одна транзакція на скалярні поля, групи, доступи й розклад: це або +// сталося з усіма обраними хостами, або не сталося ні з ким. Половина +// дільниці, переведена на новий зонд, — гірший стан, ніж жодного +// переведеного, бо про неї ніхто не знає. +// +// Шаблони — окремо й після транзакції. Призначення шаблону тягне за +// собою перерахунок чеків і правил алертів (ReconcileDeviceTemplates, +// ReconcileTemplateRules), кожен зі своєю транзакцією. Затягнути це +// всередину означало б тримати блокування на core.checks і alr.rules +// стільки, скільки триває перерахунок для всіх двохсот хостів. +// +// Ручні перевірки — теж усередині транзакції (див. +// devices_bulk_checks.go): це кілька простих операторів над +// core.checks, а не перерахунок, і атомарність тут важить більше за +// час блокування. Результат по кожному хосту повертається окремо: на +// відміну від решти полів, «додати перевірку» має три різні наслідки — +// додано, уже було, не вдалося, — і жодного з них не видно в числі +// змінених хостів. +func (s *Store) BulkUpdateDevices(ctx context.Context, tenantID string, sc Scope, + ids []string, ch BulkDeviceChanges) ([]string, []string, *BulkChecksResult, error) { + + if ch.Empty() { + return nil, nil, nil, fmt.Errorf("%w: не задано жодної зміни", ErrInvalid) + } + if len(ids) == 0 { + return nil, nil, nil, fmt.Errorf("%w: не обрано жодного хоста", ErrInvalid) + } + if len(ids) > MaxBulkDevices { + return nil, nil, nil, fmt.Errorf("%w: за раз можна змінити не більше %d хостів, обрано %d", + ErrInvalid, MaxBulkDevices, len(ids)) + } + for _, o := range []*StringOp{ch.AgentID, ch.Kind, ch.Vendor} { + if err := o.valid(); err != nil { + return nil, nil, nil, err + } + } + if ch.Kind != nil && ch.Kind.Op == "clear" { + return nil, nil, nil, fmt.Errorf("%w: тип хоста не можна очистити — його можна лише задати", + ErrInvalid) + } + for _, o := range []*ListOp{ch.Groups, ch.Templates, ch.Credentials} { + if err := o.valid(); err != nil { + return nil, nil, nil, err + } + } + if ch.Backup != nil && ch.Backup.Op != "set" && ch.Backup.Op != "clear" { + return nil, nil, nil, fmt.Errorf("%w: невідома дія над розкладом: %q", ErrInvalid, ch.Backup.Op) + } + if err := ch.Checks.valid(); err != nil { + return nil, nil, nil, err + } + + var ( + final []string + names []string + targets []bulkCheckDevice + checkRes *BulkChecksResult + ) + + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + // Набір звужується ще раз, уже під блокуванням транзакції: + // перевірка прав у обробнику відповідає на питання «чи можна + // було», а цей запит — на питання «чи ще існує». + // + // Адреса й доменне ім'я їдуть звідси ж: підстановка в + // параметри перевірки має брати їх у тому ж стані, у якому + // хост зафіксовано в наборі, а не окремим запитом після. + rows, err := tx.Query(ctx, ` + SELECT id::text, name, COALESCE(host(address),''), COALESCE(fqdn,'') + FROM inv.devices + WHERE tenant_id = $1 AND id = ANY($2::uuid[]) AND deleted_at IS NULL + AND ($3::boolean OR id = ANY($4::uuid[])) + ORDER BY name + `, tenantID, nonNilIDs(ids), sc.Unrestricted, nonNilIDs(sc.Writable)) + if err != nil { + return err + } + for rows.Next() { + var d bulkCheckDevice + if err := rows.Scan(&d.ID, &d.Name, &d.Address, &d.FQDN); err != nil { + rows.Close() + return err + } + final = append(final, d.ID) + names = append(names, d.Name) + targets = append(targets, d) + } + rows.Close() + if err := rows.Err(); err != nil { + return err + } + if len(final) == 0 { + return fmt.Errorf("%w: жоден з обраних хостів більше не доступний на запис", + ErrInvalid) + } + + if err := bulkSetFields(ctx, tx, tenantID, final, ch); err != nil { + return err + } + if err := bulkSetGroups(ctx, tx, tenantID, final, ch.Groups); err != nil { + return err + } + if err := bulkSetCredentials(ctx, tx, tenantID, final, ch.Credentials); err != nil { + return err + } + if err := bulkSetTemplateLinks(ctx, tx, tenantID, final, ch.Templates); err != nil { + return err + } + if err := bulkSetBackup(ctx, tx, tenantID, final, ch.Backup); err != nil { + return err + } + checkRes, err = bulkApplyChecks(ctx, tx, tenantID, targets, ch.Checks) + return err + }) + if err != nil { + return nil, nil, nil, err + } + + // Перерахунок після коміту. Помилка тут не скасовує правку — вона + // вже в базі; і мовчати про неї теж не можна, бо хост із + // призначеним, але не розгорнутим шаблоном виглядає налаштованим і + // нічого не опитує. + if ch.Templates != nil { + for _, id := range final { + if err := s.ReconcileDeviceTemplates(ctx, tenantID, id); err != nil { + return final, names, checkRes, fmt.Errorf("розгортання шаблонів: %w", err) + } + } + // Правила алертів — раз на шаблон, а не раз на хост: вони + // належать шаблону, і перерахунок для другого хоста зробив би + // ту саму роботу вдруге. + for _, tplID := range ch.Templates.IDs { + if err := s.ReconcileTemplateRules(ctx, tenantID, tplID); err != nil { + return final, names, checkRes, fmt.Errorf("правила шаблону: %w", err) + } + } + } + + return final, names, checkRes, nil +} + +// bulkSetFields — скалярні поля одним UPDATE. +// +// CASE на кожне поле замість збирання SQL рядками: запит лишається +// одним і тим самим текстом для планувальника, а «не чіпати» +// перетворюється на присвоєння полю його ж значення — тобто на +// відсутність зміни, а не на її імітацію. +func bulkSetFields(ctx context.Context, tx pgx.Tx, tenantID string, + ids []string, ch BulkDeviceChanges) error { + + if ch.AgentID == nil && ch.Kind == nil && ch.Vendor == nil && ch.Enabled == nil { + return nil + } + + op := func(o *StringOp) (string, string) { + if o == nil { + return "", "" + } + return o.Op, o.Value + } + agentOp, agentVal := op(ch.AgentID) + kindOp, kindVal := op(ch.Kind) + vendorOp, vendorVal := op(ch.Vendor) + + enabledOp, enabledVal := "", false + if ch.Enabled != nil { + enabledOp, enabledVal = "set", ch.Enabled.Value + } + + // Права тут уже не перевіряються: ids — це той перелік, який + // щойно повернув запит на початку транзакції, тобто вже звужений + // і тенантом, і Scope.Writable. + _, err := tx.Exec(ctx, ` + UPDATE inv.devices SET + agent_id = CASE $3::text + WHEN 'set' THEN NULLIF($4::text,'')::uuid + WHEN 'clear' THEN NULL + ELSE agent_id END, + kind = CASE $5::text + WHEN 'set' THEN $6::text::inv.device_kind + ELSE kind END, + vendor = CASE $7::text + WHEN 'set' THEN NULLIF(btrim($8::text),'') + WHEN 'clear' THEN NULL + ELSE vendor END, + enabled = CASE $9::text + WHEN 'set' THEN $10::boolean + ELSE enabled END, + updated_at = now() + WHERE tenant_id = $1 AND id = ANY($2::uuid[]) AND deleted_at IS NULL + `, tenantID, ids, + agentOp, agentVal, kindOp, kindVal, vendorOp, vendorVal, + enabledOp, enabledVal) + return err +} + +// bulkSetGroups — членство в групах. +// +// Групи звіряються з тенантом усередині кожного запиту, а не перед +// ними: інакше чужий id у списку тихо переклав би хости в групу +// сусіда. Так само зроблено в SetDeviceGroups для одного хоста. +func bulkSetGroups(ctx context.Context, tx pgx.Tx, tenantID string, + ids []string, op *ListOp) error { + + if op == nil { + return nil + } + + if op.Op == "replace" || op.Op == "remove" { + q := ` + DELETE FROM inv.device_group_members m + USING inv.device_groups g + WHERE m.group_id = g.id AND g.tenant_id = $1 + AND m.device_id = ANY($2::uuid[]) + ` + args := []any{tenantID, ids} + if op.Op == "remove" { + q += ` AND m.group_id = ANY($3::uuid[])` + args = append(args, nonNilIDs(op.IDs)) + } + if _, err := tx.Exec(ctx, q, args...); err != nil { + return err + } + if op.Op == "remove" { + return nil + } + } + + if len(op.IDs) == 0 { + return nil + } + _, err := tx.Exec(ctx, ` + INSERT INTO inv.device_group_members (group_id, device_id) + SELECT g.id, d.id + FROM inv.device_groups g + CROSS JOIN unnest($2::uuid[]) AS d(id) + WHERE g.tenant_id = $1 AND g.id = ANY($3::uuid[]) + ON CONFLICT DO NOTHING + `, tenantID, ids, nonNilIDs(op.IDs)) + return err +} + +// bulkSetCredentials — доступи до обладнання. +// +// Пріоритет 100 — той самий, що ставить одинична форма: масова правка +// не має заводити хостам порядок перебору, якого людина не бачила. +func bulkSetCredentials(ctx context.Context, tx pgx.Tx, tenantID string, + ids []string, op *ListOp) error { + + if op == nil { + return nil + } + + if op.Op == "replace" || op.Op == "remove" { + q := ` + DELETE FROM inv.device_credentials dc + USING inv.credentials c + WHERE dc.credential_id = c.id AND c.tenant_id = $1 + AND dc.device_id = ANY($2::uuid[]) + ` + args := []any{tenantID, ids} + if op.Op == "remove" { + q += ` AND dc.credential_id = ANY($3::uuid[])` + args = append(args, nonNilIDs(op.IDs)) + } + if _, err := tx.Exec(ctx, q, args...); err != nil { + return err + } + if op.Op == "remove" { + return nil + } + } + + if len(op.IDs) == 0 { + return nil + } + _, err := tx.Exec(ctx, ` + INSERT INTO inv.device_credentials (device_id, credential_id, priority) + SELECT d.id, c.id, 100 + FROM inv.credentials c + CROSS JOIN unnest($2::uuid[]) AS d(id) + WHERE c.tenant_id = $1 AND c.id = ANY($3::uuid[]) + ON CONFLICT DO NOTHING + `, tenantID, ids, nonNilIDs(op.IDs)) + return err +} + +// bulkSetTemplateLinks — самі зв'язки хост↔шаблон. +// +// Перерахунок чеків тут не робиться навмисно, він іде після коміту: +// див. коментар у BulkUpdateDevices. +// +// Шаблон звіряється не лише з тенантом: вбудовані шаблони спільні й +// мають tenant_id IS NULL, тож умова саме така, як у переліку шаблонів +// для форми. Без неї масова правка стала б способом причепити хосту +// чужий шаблон за вгаданим id. +func bulkSetTemplateLinks(ctx context.Context, tx pgx.Tx, tenantID string, + ids []string, op *ListOp) error { + + if op == nil { + return nil + } + + if op.Op == "replace" || op.Op == "remove" { + q := `DELETE FROM tpl.device_templates WHERE device_id = ANY($1::uuid[])` + args := []any{ids} + if op.Op == "remove" { + q += ` AND template_id = ANY($2::uuid[])` + args = append(args, nonNilIDs(op.IDs)) + } + if _, err := tx.Exec(ctx, q, args...); err != nil { + return err + } + if op.Op == "remove" { + return nil + } + } + + if len(op.IDs) == 0 { + return nil + } + _, err := tx.Exec(ctx, ` + INSERT INTO tpl.device_templates (device_id, template_id, tenant_id) + SELECT d.id, t.id, $1 + FROM tpl.templates t + CROSS JOIN unnest($2::uuid[]) AS d(id) + WHERE t.id = ANY($3::uuid[]) + AND (t.tenant_id IS NULL OR t.tenant_id = $1) + ON CONFLICT DO NOTHING + `, tenantID, ids, nonNilIDs(op.IDs)) + return err +} + +// bulkSetBackup — розклад збору конфігів. +// +// next_backup_at обнуляється так само, як в одиничному SetBackupPolicy: +// розклад міг стати частішим, і лишити стару позначку означало б +// чекати за старим розкладом до першого спрацювання нового. +func bulkSetBackup(ctx context.Context, tx pgx.Tx, tenantID string, + ids []string, op *BulkBackupOp) error { + + if op == nil { + return nil + } + + if op.Op == "clear" { + // Саме DELETE, а не enabled = false. «Вимкнений розклад» і + // «розкладу немає» розрізняються в картці хоста, і масова дія + // не має лишати по собі стан, якого людина не просила. + _, err := tx.Exec(ctx, ` + DELETE FROM ncm.device_policies + WHERE tenant_id = $1 AND device_id = ANY($2::uuid[]) + `, tenantID, ids) + return err + } + + cron := strings.TrimSpace(op.Cron) + if cron == "" { + cron = "0 3 * * *" + } + _, err := tx.Exec(ctx, ` + INSERT INTO ncm.device_policies + (device_id, tenant_id, profile_id, credential_id, enabled, cron, + on_syslog, follows_default, next_backup_at) + SELECT d.id, $1, $3, $4, $5, $6, $7, $8, NULL + FROM unnest($2::uuid[]) AS d(id) + ON CONFLICT (device_id) DO UPDATE SET + profile_id = EXCLUDED.profile_id, + credential_id = EXCLUDED.credential_id, + enabled = EXCLUDED.enabled, + cron = EXCLUDED.cron, + on_syslog = EXCLUDED.on_syslog, + follows_default = EXCLUDED.follows_default, + next_backup_at = NULL, + updated_at = now() + `, tenantID, ids, nullUUID(op.ProfileID), nullUUID(op.CredentialID), + op.Enabled, cron, op.OnSyslog, op.FollowsDefault) + return err +} + +// --------------------------------------------------------------------- +// Масове видалення +// --------------------------------------------------------------------- + +// BulkDeleteDevices — АРХІВНЕ видалення набору хостів. +// +// Одна з двох дій кнопки «Видалити»; друга — PurgeDevices +// (devices_purge.go), яка стирає хост назавжди. Тут — рівно те саме, +// що робить DeleteDevice для одного: deleted_at = now() +// і enabled = false. Розходитись із ним не можна принципово — «видалив +// п'ятдесят» і «видалив по одному п'ятдесят разів» мусять давати той +// самий стан бази, інакше половина системи знатиме про хост, а +// половина ні. +// +// Що з цього випливає для всього прив'язаного, і чому це саме так: +// +// - чеки лишаються рядками в core.checks, але планувальник бере лише +// живі й увімкнені хости — опитування спиняється; +// - розклад бекапів лишається, але DuePolicies так само дивиться на +// deleted_at — збір спиняється; +// - активні алерти закриються самі: рушій рахує кандидатів по живих +// хостах і закриває різницею множин (ResolveMissing), тобто на +// найближчому такті оцінки правила; +// - вузли на мапах ЛИШАЮТЬСЯ. Читання мапи не фільтрує видалені +// хости, тож вузол стоїть на місці з останнім відомим станом. Це +// не забутий випадок: мапа — це креслення, і дірка в ньому має +// з'явитись рішенням людини, а не наслідком дії в іншому розділі; +// - метрики й зібрані конфіги не чіпаються взагалі. Заради цього +// архівне видалення й існує окремо від повного; +// - гілка в Git-архіві лишається як була, і на дзеркалі теж. Хост, +// який колись повернуть, має продовжити СВОЮ історію конфігів, а +// не почати другу. +// +// Усе перелічене показується в підтвердженні числами по кожному хосту +// (див. BulkDeviceTarget) — щоб це читали ДО натискання. +func (s *Store) BulkDeleteDevices(ctx context.Context, tenantID string, sc Scope, + ids []string) ([]string, []string, error) { + + if len(ids) == 0 { + return nil, nil, fmt.Errorf("%w: не обрано жодного хоста", ErrInvalid) + } + if len(ids) > MaxBulkDevices { + return nil, nil, fmt.Errorf("%w: за раз можна видалити не більше %d хостів, обрано %d", + ErrInvalid, MaxBulkDevices, len(ids)) + } + + var ( + deleted []string + names []string + ) + + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, ` + UPDATE inv.devices + SET deleted_at = now(), enabled = false, archived_enabled = enabled + WHERE tenant_id = $1 AND id = ANY($2::uuid[]) AND deleted_at IS NULL + AND ($3::boolean OR id = ANY($4::uuid[])) + RETURNING id::text, name + `, tenantID, nonNilIDs(ids), sc.Unrestricted, nonNilIDs(sc.Writable)) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var id, name string + if err := rows.Scan(&id, &name); err != nil { + return err + } + deleted = append(deleted, id) + names = append(names, name) + } + if err := rows.Err(); err != nil { + return err + } + + // Те саме, що робить DeleteDevice для одного хоста: увімкнений + // чек видаленого хоста план не бере, зате він назавжди лишається + // «таким, що пропустив свій такт» на сторінці «Черги». + // + // archived_off — щоб RestoreDevices знало, які саме чеки + // вимкнула ця дія, а які були вимкнені й до неї. + if len(deleted) > 0 { + if _, err := tx.Exec(ctx, ` + UPDATE core.checks + SET enabled = false, archived_off = true, updated_at = now() + WHERE tenant_id = $1 AND device_id = ANY($2::uuid[]) AND enabled + `, tenantID, nonNilIDs(deleted)); err != nil { + return err + } + } + return nil + }) + if err != nil { + return nil, nil, err + } + if len(deleted) == 0 { + return nil, nil, fmt.Errorf("%w: жоден з обраних хостів більше не доступний на запис", + ErrInvalid) + } + return deleted, names, nil +} diff --git a/server/internal/store/devices_bulk_checks.go b/server/internal/store/devices_bulk_checks.go new file mode 100644 index 0000000..bc75853 --- /dev/null +++ b/server/internal/store/devices_bulk_checks.go @@ -0,0 +1,434 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/jackc/pgx/v5" +) + +// Масова дія над ручними перевірками хостів. +// +// Ручні перевірки колись лишили поза масовою формою з міркуванням, що +// вони поодинокі за визначенням — те, чого немає в шаблонах. Насправді +// поодинока тут причина, а не кількість: «додати http.status усім +// вебсерверам дільниці» і «прибрати icmp.ping із сорока хостів, яким +// його колись наставили руками» — це одне рішення на сорок рядків, тобто +// рівно те, заради чого масова форма й існує. Без неї людина або робить +// сорок однакових рухів, або не робить нічого — і другий варіант +// трапляється частіше. +// +// --------------------------------------------------------------------- +// Чому немає «замінити весь набір» +// +// Та сама межа, що проведена для груп, і з тієї самої причини, лише +// гострішої. У груп replace лишили, бо без нього неможливо навести лад +// там, де наставлено абияк, і бо групи видно в переліку хостів. Набори +// перевірок не видно ніде: людина, яка натисне «замінити», зітре те, +// чого не бачила й побачити не могла, — і дізнається про це тоді, коли +// перестане приходити алерт. Тому дій рівно три, і кожна називає тип, +// якого стосується. +// +// --------------------------------------------------------------------- +// Що таке «ручна» перевірка +// +// template_id IS NULL AND interface_id IS NULL — рівно та ж межа, яку +// проводить SetDeviceChecks, коли прибирає зняте у формі хоста. +// Перевірку, породжену шаблоном, міняють у шаблоні: правка тут зникла б +// на найближчій реконсиляції. Перевірку на інтерфейсі заводить +// автовиявлення, і вона взагалі не про хост, а про порт. + +// BulkChecksOp — одна дія над одним типом перевірки. +// +// Тип називається явно й завжди один. Це не обмеження реалізації: +// «додати перевірку» без назви типу не є висловлюванням, а дозволити +// кілька типів за раз означало б показувати результат («додано / +// уже було / не вдалося») перехресною таблицею, яку ніхто не читає. +type BulkChecksOp struct { + // add — завести перевірку тим, у кого її немає + // remove — прибрати перевірку цього типу + // tune — змінити розклад наявної: інтервал, таймаут, повтори, + // увімкнено/вимкнено + Op string `json:"op"` + CheckType string `json:"check_type"` + + // Параметри — тільки для add. Для tune їх свідомо немає: у + // http.status параметр — це URL, і масово задати той самий URL + // сорока хостам, у яких він уже різний, означає зламати сорок + // перевірок одним рухом. Змінити параметр — це рішення про + // конкретний хост, і робиться воно в його картці. + Params json.RawMessage `json:"params,omitempty"` + + // nil означає «не чіпати» — той самий тристановий принцип, що й у + // решті масової форми, лише всередині однієї дії. Для add nil — це + // «взяти типове». + IntervalSec *int `json:"interval_sec,omitempty"` + TimeoutMs *int `json:"timeout_ms,omitempty"` + Retries *int `json:"retries,omitempty"` + Enabled *bool `json:"enabled,omitempty"` +} + +// BulkCheckOutcome — хост, з яким нічого не сталося, і чому. +type BulkCheckOutcome struct { + Device string `json:"device"` + Reason string `json:"reason"` +} + +// BulkChecksResult — що вийшло по кожному хосту. +// +// Три списки, а не одне число. Масове додавання ГАРАНТОВАНО натрапляє +// на хости, у яких така перевірка вже є, і це не помилка, а звичайний +// стан: людина відібрала дільницю, а половину дільниці колись уже +// налаштували руками. Показати «додано 12» і промовчати про решту +// означає збрехати; показати помилку означає злякати на рівному місці. +// Тому те саме розрізнення, що й у dropped: зроблено / не було чого +// робити / не вдалося. +type BulkChecksResult struct { + Op string `json:"op"` + CheckType string `json:"check_type"` + // Хости, де дія справді щось змінила. + Done []string `json:"done"` + // Хости, де робити не було чого: перевірка вже є (add) або її + // немає (remove, tune). + Skipped []BulkCheckOutcome `json:"skipped"` + // Хости, де дію зробити не вдалося. Зараз єдина причина — + // підстановка, якій нема чим підставитись (хост без адреси). + Failed []BulkCheckOutcome `json:"failed"` +} + +func (o *BulkChecksOp) valid() error { + if o == nil { + return nil + } + switch o.Op { + case "add", "remove", "tune": + default: + return fmt.Errorf("%w: невідома дія над перевірками: %q", ErrInvalid, o.Op) + } + if strings.TrimSpace(o.CheckType) == "" { + return fmt.Errorf("%w: не названо тип перевірки", ErrInvalid) + } + // «Змінити наявні», не сказавши що саме, — незаповнена форма, а не + // дія без наслідків. Мовчазне «готово» тут прочиталося б як + // «застосовано». + if o.Op == "tune" && o.IntervalSec == nil && o.TimeoutMs == nil && + o.Retries == nil && o.Enabled == nil { + return fmt.Errorf("%w: не задано, що саме змінити в перевірці %s", + ErrInvalid, o.CheckType) + } + return nil +} + +// --------------------------------------------------------------------- +// Підстановка +// +// Головне питання масової роботи з перевірками — параметри. Вони різні +// за типом: у icmp.ping це кількість пакетів, у http.status — URL. +// Кількість пакетів у сорока хостів однакова, і задати її масово +// осмислено. URL — ні: сорок хостів мають сорок різних URL, і спільного +// значення для них не існує. +// +// Заборонити типи з обов'язковими параметрами означало б заборонити +// рівно той випадок, з якого все почалося: у http.status url +// обов'язковий. Тому дозволено, але з єдиною підстановкою — на те, чим +// хости в наборі законно різняться, тобто на них самих: +// +// {{address}} — адреса хоста +// {{name}} — ім'я хоста +// {{fqdn}} — доменне ім'я, якщо задане +// +// Трьох достатньо й більше не буде. Будь-яке «різне», що не є самим +// хостом, — це не масова дія, а сорок окремих рішень, і робити їх треба +// в сорока картках. +// --------------------------------------------------------------------- + +// bulkCheckDevice — хост у масовій дії над перевірками. +type bulkCheckDevice struct { + ID string + Name string + Address string + FQDN string +} + +// expandCheckParams підставляє значення хоста в рядкові параметри. +// +// Лише рядки й елементи рядкових масивів: підставляти в число нема чого, +// а лізти в довільно вкладені структури означало б підмінювати те, про +// що форма не питала. +// +// Порожнє значення — помилка, а не порожня підстановка. URL +// «http:///status» ліг би в базу мовчки, а помітили б його через добу +// в переліку впалих перевірок — за кілометр від того місця, де його +// створили. +func expandCheckParams(raw json.RawMessage, d bulkCheckDevice) (string, error) { + text := strings.TrimSpace(string(raw)) + if text == "" || text == "null" { + return "{}", nil + } + if !strings.Contains(text, "{{") { + return text, nil + } + + var params map[string]any + if err := json.Unmarshal([]byte(text), ¶ms); err != nil { + return "", fmt.Errorf("%w: параметри перевірки не є об'єктом JSON", ErrInvalid) + } + + subst := func(s string) (string, error) { + for _, t := range []struct { + token string + value string + }{ + {"{{address}}", d.Address}, + {"{{name}}", d.Name}, + {"{{fqdn}}", d.FQDN}, + } { + if !strings.Contains(s, t.token) { + continue + } + if strings.TrimSpace(t.value) == "" { + return "", fmt.Errorf("немає чим підставити %s", t.token) + } + s = strings.ReplaceAll(s, t.token, t.value) + } + return s, nil + } + + for k, v := range params { + switch val := v.(type) { + case string: + s, err := subst(val) + if err != nil { + return "", err + } + params[k] = s + case []any: + for i, item := range val { + s, ok := item.(string) + if !ok { + continue + } + out, err := subst(s) + if err != nil { + return "", err + } + val[i] = out + } + } + } + + out, err := json.Marshal(params) + if err != nil { + return "", err + } + return string(out), nil +} + +// --------------------------------------------------------------------- +// Виконання +// --------------------------------------------------------------------- + +// bulkApplyChecks виконує дію над ручними перевірками набору хостів. +// +// Усередині тієї ж транзакції, що й решта масової правки: «половина +// дільниці отримала перевірку» — гірший стан, ніж «не отримав ніхто», +// бо про нього ніхто не дізнається. Хости, яким не вдалося, транзакцію +// не валять: «немає чим підставити адресу» — це властивість хоста, а не +// збій запиту, і скасовувати через неї роботу для решти означало б +// зробити один недооформлений хост стопором для всієї дільниці. +func bulkApplyChecks(ctx context.Context, tx pgx.Tx, tenantID string, + devices []bulkCheckDevice, op *BulkChecksOp) (*BulkChecksResult, error) { + + if op == nil { + return nil, nil + } + + res := &BulkChecksResult{ + Op: op.Op, + CheckType: op.CheckType, + Done: []string{}, + Skipped: []BulkCheckOutcome{}, + Failed: []BulkCheckOutcome{}, + } + + switch op.Op { + case "add": + return res, bulkAddChecks(ctx, tx, tenantID, devices, op, res) + case "remove": + return res, bulkTouchChecks(ctx, tx, tenantID, devices, op, res, true) + default: + return res, bulkTouchChecks(ctx, tx, tenantID, devices, op, res, false) + } +} + +// bulkAddChecks — завести перевірку тим, у кого її ще немає. +// +// По хосту, а не одним запитом: параметри після підстановки в кожного +// свої, та й результат однаково потрібен поштучно. Двісті окремих +// INSERT-ів усередині однієї транзакції коштують менше, ніж один рядок +// невиразної відповіді. +func bulkAddChecks(ctx context.Context, tx pgx.Tx, tenantID string, + devices []bulkCheckDevice, op *BulkChecksOp, res *BulkChecksResult) error { + + interval := 60 + if op.IntervalSec != nil { + interval = clampInterval(*op.IntervalSec) + } + timeout := 3000 + if op.TimeoutMs != nil && *op.TimeoutMs > 0 { + timeout = *op.TimeoutMs + } + retries := 2 + if op.Retries != nil && *op.Retries >= 0 { + retries = *op.Retries + } + enabled := op.Enabled == nil || *op.Enabled + + for _, d := range devices { + params, err := expandCheckParams(op.Params, d) + if err != nil { + res.Failed = append(res.Failed, BulkCheckOutcome{Device: d.Name, Reason: err.Error()}) + continue + } + + // NOT EXISTS по ТИПУ, а не по параметрах. Унікальний індекс + // checks_uniq рахує ще й md5(params), тобто пропустив би другий + // http.status з іншим URL — і хост лишився б із двома + // перевірками того самого типу, яких у формі картки завести + // неможливо. ON CONFLICT DO NOTHING поруч лишається як остання + // сітка: перевірка на інтерфейсі з такими ж параметрами в + // NOT EXISTS не потрапляє, а в індекс — потрапляє, і без цієї + // гілки 23505 обірвав би всю транзакцію на одному хості. + var id string + err = tx.QueryRow(ctx, ` + INSERT INTO core.checks + (tenant_id, device_id, check_type, params, interval_sec, + timeout_ms, retries, enabled) + SELECT $1, $2::uuid, $3, $4::jsonb, $5, $6, $7, $8 + WHERE NOT EXISTS ( + SELECT 1 FROM core.checks c + WHERE c.tenant_id = $1 AND c.device_id = $2::uuid + AND c.check_type = $3 + AND c.interface_id IS NULL AND c.template_id IS NULL + ) + ON CONFLICT DO NOTHING + RETURNING id::text + `, tenantID, d.ID, op.CheckType, params, interval, timeout, retries, enabled).Scan(&id) + + switch { + case err == nil: + res.Done = append(res.Done, d.Name) + case errors.Is(err, pgx.ErrNoRows): + res.Skipped = append(res.Skipped, BulkCheckOutcome{ + Device: d.Name, Reason: "перевірка цього типу вже є", + }) + default: + return fmt.Errorf("перевірка %s на хості %s: %w", op.CheckType, d.Name, err) + } + } + return nil +} + +// bulkTouchChecks — прибрати або змінити наявні перевірки типу. +// +// Одним запитом на весь набір: тут нема чого підставляти, а RETURNING +// device_id сам розділяє «зроблено» і «не було чого робити» — саме те, +// що треба показати людині. +func bulkTouchChecks(ctx context.Context, tx pgx.Tx, tenantID string, + devices []bulkCheckDevice, op *BulkChecksOp, res *BulkChecksResult, remove bool) error { + + ids := make([]string, 0, len(devices)) + for _, d := range devices { + ids = append(ids, d.ID) + } + + var ( + rows pgx.Rows + err error + ) + if remove { + rows, err = tx.Query(ctx, ` + DELETE FROM core.checks + WHERE tenant_id = $1 AND device_id = ANY($2::uuid[]) + AND check_type = $3 + AND interface_id IS NULL AND template_id IS NULL + RETURNING device_id::text + `, tenantID, nonNilIDs(ids), op.CheckType) + } else { + // Саме типовані вказівники, а не any(nil): nil тут доїжджає до + // Postgres як NULL потрібного типу, і COALESCE нижче читає його + // як «не чіпати» без здогадок про тип параметра. + var interval *int + if op.IntervalSec != nil { + v := clampInterval(*op.IntervalSec) + interval = &v + } + var timeout *int + if op.TimeoutMs != nil && *op.TimeoutMs > 0 { + timeout = op.TimeoutMs + } + var retries *int + if op.Retries != nil && *op.Retries >= 0 { + retries = op.Retries + } + // COALESCE, а не CASE: NULL тут і є «не чіпати», і кожне поле + // відповідає на це питання окремо — рівно як решта масової + // форми відповідає на нього окремо по кожному полю хоста. + rows, err = tx.Query(ctx, ` + UPDATE core.checks SET + interval_sec = COALESCE($4::int, interval_sec), + timeout_ms = COALESCE($5::int, timeout_ms), + retries = COALESCE($6::int, retries), + enabled = COALESCE($7::boolean, enabled), + updated_at = now() + WHERE tenant_id = $1 AND device_id = ANY($2::uuid[]) + AND check_type = $3 + AND interface_id IS NULL AND template_id IS NULL + RETURNING device_id::text + `, tenantID, nonNilIDs(ids), op.CheckType, interval, timeout, retries, op.Enabled) + } + if err != nil { + return err + } + defer rows.Close() + + touched := map[string]bool{} + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return err + } + touched[id] = true + } + if err := rows.Err(); err != nil { + return err + } + + reason := "такої ручної перевірки немає" + for _, d := range devices { + if touched[d.ID] { + res.Done = append(res.Done, d.Name) + } else { + res.Skipped = append(res.Skipped, BulkCheckOutcome{Device: d.Name, Reason: reason}) + } + } + return nil +} + +// clampInterval тримає інтервал у межах CHECK-обмеження core.checks. +// +// Обрізаємо так само, як SetDeviceChecks: масова форма не має падати +// там, де одинична мовчки виправляє, — інакше та сама цифра в двох +// місцях означала б різне. +func clampInterval(v int) int { + if v < 5 { + return 60 + } + if v > 86400 { + return 86400 + } + return v +} diff --git a/server/internal/store/devices_bulk_checks_test.go b/server/internal/store/devices_bulk_checks_test.go new file mode 100644 index 0000000..7311ae6 --- /dev/null +++ b/server/internal/store/devices_bulk_checks_test.go @@ -0,0 +1,96 @@ +package store + +import ( + "encoding/json" + "strings" + "testing" +) + +// Перевіряється єдине рішення, яке в цій частині можна ухвалити +// неправильно й не помітити: підстановка. Помилка тут не впаде +// складанням — вона мовчки покладе в базу сорок перевірок із +// «http:///», а помітять їх через добу в переліку впалих. + +func devFixture() bulkCheckDevice { + return bulkCheckDevice{ + ID: "00000000-0000-0000-0000-000000000001", + Name: "ПОЛЬОВЕ.7", + Address: "10.1.0.75", + FQDN: "polove7.example.net", + } +} + +func TestExpandLeavesPlainParamsAlone(t *testing.T) { + in := json.RawMessage(`{"count":3,"packet_size":56}`) + out, err := expandCheckParams(in, devFixture()) + if err != nil { + t.Fatalf("несподівана помилка: %v", err) + } + // Байт-у-байт: параметри без підстановки не мають переживати + // перепакування JSON, бо md5(params) входить у checks_uniq, і + // перевпорядковані ключі зробили б «ту саму» перевірку іншою. + if out != string(in) { + t.Fatalf("параметри без підстановки змінились: %s", out) + } +} + +func TestExpandFillsHostTokens(t *testing.T) { + out, err := expandCheckParams( + json.RawMessage(`{"url":"http://{{address}}/health","keyword":"{{name}}"}`), devFixture()) + if err != nil { + t.Fatalf("несподівана помилка: %v", err) + } + if !strings.Contains(out, "http://10.1.0.75/health") { + t.Fatalf("адресу не підставлено: %s", out) + } + if !strings.Contains(out, "ПОЛЬОВЕ.7") { + t.Fatalf("імʼя не підставлено: %s", out) + } +} + +// Хост без адреси — не збій, а звичайний недооформлений рядок +// інвентарю. Але перевірка з порожнім URL не запуститься ніколи, тож +// такий хост має чесно потрапити в «не вдалося», а не отримати +// зіпсований чек. +func TestExpandRefusesWhenNothingToSubstitute(t *testing.T) { + d := devFixture() + d.Address = "" + _, err := expandCheckParams(json.RawMessage(`{"url":"http://{{address}}/"}`), d) + if err == nil { + t.Fatal("порожня адреса мала б дати помилку") + } + if !strings.Contains(err.Error(), "{{address}}") { + t.Fatalf("з помилки не видно, чого бракує: %v", err) + } +} + +func TestExpandWalksStringArrays(t *testing.T) { + out, err := expandCheckParams( + json.RawMessage(`{"hosts":["{{fqdn}}","static.example"]}`), devFixture()) + if err != nil { + t.Fatalf("несподівана помилка: %v", err) + } + if !strings.Contains(out, "polove7.example.net") || !strings.Contains(out, "static.example") { + t.Fatalf("масив рядків оброблено неправильно: %s", out) + } +} + +// «Змінити наявні», не сказавши що саме, — незаповнена форма. Мовчазне +// «готово» тут прочиталося б як «застосовано до сорока хостів». +func TestTuneWithoutFieldsIsRefused(t *testing.T) { + op := &BulkChecksOp{Op: "tune", CheckType: "icmp.ping"} + if err := op.valid(); err == nil { + t.Fatal("порожня дія tune мала б бути відхилена") + } + sec := 30 + op.IntervalSec = &sec + if err := op.valid(); err != nil { + t.Fatalf("заповнена дія tune відхилена: %v", err) + } +} + +func TestUnknownChecksOpIsRefused(t *testing.T) { + if err := (&BulkChecksOp{Op: "replace", CheckType: "icmp.ping"}).valid(); err == nil { + t.Fatal("replace над перевірками свідомо не існує — має бути відхилено") + } +} diff --git a/server/internal/store/devices_purge.go b/server/internal/store/devices_purge.go new file mode 100644 index 0000000..3519395 --- /dev/null +++ b/server/internal/store/devices_purge.go @@ -0,0 +1,542 @@ +package store + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/jackc/pgx/v5" +) + +// Повне видалення хоста — те, після якого від нього не лишається нічого. +// +// ЧОМУ ЦЕ З'ЯВИЛОСЬ ПОРУЧ ІЗ М'ЯКИМ, А НЕ ЗАМІСТЬ НЬОГО +// +// М'яке видалення (deleted_at = now()) завели з правильного міркування: +// жорстке зняло б за собою телеметрію й історію алертів, а їх тримають +// заради розбору аварій і заради вимог відповідності. Міркування +// лишається правильним — і саме тому архівне видалення нікуди не +// зникло. +// +// Хибним виявився висновок, що воно може бути ЄДИНИМ. Три факти проти: +// +// 1. «Видалити» в інтерфейсі означає видалити. Людина, яка натиснула +// кнопку й побачила, що хост зник зі списку, не має підстав +// здогадуватись, що його метрики далі займають місце, а гілка з +// конфігами далі їде на зовнішній Git. +// 2. Архів, до якого немає дороги, архівом не є. Переліку видалених +// хостів у продукті немає, відновлення теж немає: рядок із +// deleted_at недосяжний нізвідки, крім SQL. Тобто «зберігаємо для +// розбору» на практиці означає «займає місце й ніколи не +// знадобиться». +// 3. Половина прибраного гірша за нуль прибраного. На цьому стенді від +// двох м'яко видалених хостів лишились 7 рядів метрик і 3 +// перевірки, які не належать жодному видимому хосту, — рівно ті +// «ряди без перевірки» й «чеки, що мовчать», за якими потім ходять +// із питанням «звідки це». +// +// Тому вибір робить людина у вікні видалення, і обидві дії названі +// своїми словами: «прибрати з переліку» й «видалити назавжди». Різниця +// незворотна, і ховати її за одним словом «видалити» не можна в жоден +// бік. +// +// ЩО САМЕ ПРИБИРАЄТЬСЯ +// +// Каскади бази роблять більшу частину: на inv.devices стоїть 21 +// зовнішній ключ, і всі, крім topo.neighbors.resolved_device_id +// (SET NULL), — CASCADE. Одного DELETE досить, щоб зникли перевірки, +// алерти, мовчання, періоди SLA, доступи, членство в групах, теги, +// порти, цілі команд, результати відповідності, версії конфігів, +// розклад збору, завдання, відкати, лінки, вузли мап, сусіди, шаблони +// й ряди метрик. +// +// Каскад НЕ дістає трьох речей, і кожна з них тут прибирається руками: +// +// - Зашифровані тіла конфігів у core.secrets. ncm.configs посилається +// на них через body_secret_id з ON DELETE SET NULL, тож каскадне +// видалення версій лишило б шифротекст у базі назавжди й без жодного +// способу до нього дійти. Саме там лежать мегабайти. Розв'язано тим +// самим deleteConfigRows, що й ручне видалення версій. +// - Телеметрія в гіпертаблицях. TimescaleDB не дозволяє посилатись на +// гіпертаблицю зовнішнім ключем, тому ts.icmp_samples, +// ts.if_counters, ts.syslog, ts.snmp_traps, ts.device_status_history +// й alr.alerts_history тримають device_id БЕЗ каскаду, а +// alr.notifications і ts.link_status — alert_id та link_id, які +// каскадом зникають самі. Перевірено на живій схемі (21 ключ на +// inv.devices, жодного з цих шести серед них), а не з опису таблиць. +// - Гілка в Git — локальна й на дзеркалі. Черга видалень посилань, +// див. ncm_refqueue.go. +// +// ЩО ЛИШАЄТЬСЯ, І ЦЕ НАВМИСНО +// +// - Журнал аудиту (core.audit_log). Запис про видалення має пережити +// видалення, інакше його нема сенсу писати; тому в ньому лежать +// ІМ'Я й адреса, а не лише id, якого вже не існує. +// - Спільні об'єкти: доступи (inv.credentials), майданчики, групи, +// шаблони, профілі. Зникає членство хоста в них, а не вони самі. +// - Об'єкти Git. Знявши посилання, ми не стираємо коміти з диска — +// їх збере gc. Тіла конфігів прибираються там, де вони справді +// лежать: у core.secrets. +// - Матеріалізовані згортки метрик (ts.samples_5m і решта). Вони +// ключуються series_id, ряд якого вже немає, тож у продукті вони +// недосяжні; фізично зникнуть із вікном ретеншену згортки. + +// PurgedDevice — хост, якого більше немає, і що саме з ним пішло. +// +// Ім'я й адреса тут не для краси: це те, що поїде в аудит і в +// відповідь. Після коміту жодним запитом їх уже не дістати. +type PurgedDevice struct { + DeviceID string `json:"device_id"` + Name string `json:"name"` + Address string `json:"address,omitempty"` + + Configs int `json:"configs"` + FreedBytes int64 `json:"freed_bytes"` + Series int `json:"series"` + Samples int64 `json:"samples"` + Branches []string `json:"branches,omitempty"` +} + +// PurgeFailure — хост, який видалити не вдалось. +// +// Окремим списком, а не помилкою всієї дії: видалення йде по одному +// хосту в своїй транзакції, і збій на тридцять сьомому не має скасувати +// тридцять шість уже зроблених. Скасувати їх усе одно неможливо — +// «відкотити» повне видалення нема з чого. +type PurgeFailure struct { + DeviceID string `json:"device_id"` + Name string `json:"name"` + Error string `json:"error"` +} + +// PurgeResult — підсумок повного видалення. +type PurgeResult struct { + Purged []PurgedDevice `json:"purged"` + Failed []PurgeFailure `json:"failed,omitempty"` + + Configs int `json:"configs"` + FreedBytes int64 `json:"freed_bytes"` + Series int `json:"series"` + Samples int64 `json:"samples"` + Branches int `json:"branches"` +} + +// telemetryBatch — скільки рядків телеметрії прибирати за одну +// транзакцію. +// +// Не про швидкість. Хост із сотнею портів за місяць накопичує мільйони +// рядків у ts.if_counters, і один DELETE на всі тримав би блокування й +// ріс би в WAL стільки, скільки триває видалення. Партія розміром зі +// сто тисяч робить кожну транзакцію короткою, а перерваний посеред +// роботи процес лишає хост уже архівованим (див. порядок кроків нижче) +// — тобто повтор просто доробляє почате, а не починає спочатку. +const telemetryBatch = 100_000 + +// PurgeDevices видаляє хости назавжди. +// +// Стеля та сама, що й у решти масових дій (MaxBulkDevices): підтвердити +// наосліп можна що завгодно, але тут ціна помилки не «переналаштували +// не те», а «немає більше нічого». +// +// Кожен хост — окремо, і це головна відмінність від BulkUpdateDevices, +// де одна транзакція на весь набір. Там половина переведеної дільниці +// гірша за жодної переведеної, бо про неї ніхто не знає. Тут навпаки: +// «видалено 36 з 50» — зрозумілий стан, який доробляється повтором, а +// одна транзакція на п'ятдесят хостів означала б каскад на пів +// інвентарю й блокування таблиць, з яких диспетчер колектора саме зараз +// читає свій план. +func (s *Store) PurgeDevices(ctx context.Context, tenantID string, sc Scope, + ids []string) (*PurgeResult, error) { + + if len(ids) == 0 { + return nil, fmt.Errorf("%w: не обрано жодного хоста", ErrInvalid) + } + if len(ids) > MaxBulkDevices { + return nil, fmt.Errorf("%w: за раз можна видалити не більше %d хостів, обрано %d", + ErrInvalid, MaxBulkDevices, len(ids)) + } + + // Набір звужується ще раз, уже проти бази: перевірка прав в + // обробнику відповідає на питання «чи можна було», а цей запит — на + // «чи ще існує». deleted_at тут НЕ фільтрується навмисно: хост, + // раніше прибраний з переліку, — саме той, який найчастіше треба + // добити, і відмовити йому означало б лишити його недосяжним + // назавжди. + type target struct{ id, name, addr string } + var targets []target + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, ` + SELECT id::text, name, COALESCE(host(address),'') + FROM inv.devices + WHERE tenant_id = $1 AND id = ANY($2::uuid[]) + AND ($3::boolean OR id = ANY($4::uuid[])) + ORDER BY name + `, tenantID, nonNilIDs(ids), sc.Unrestricted, nonNilIDs(sc.Writable)) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var t target + if err := rows.Scan(&t.id, &t.name, &t.addr); err != nil { + return err + } + targets = append(targets, t) + } + return rows.Err() + }) + if err != nil { + return nil, err + } + if len(targets) == 0 { + return nil, fmt.Errorf("%w: жоден з обраних хостів більше не доступний на запис", + ErrInvalid) + } + + res := &PurgeResult{} + for _, t := range targets { + if ctx.Err() != nil { + break + } + one, err := s.purgeOne(ctx, tenantID, t.id, t.name, t.addr) + if err != nil { + res.Failed = append(res.Failed, PurgeFailure{ + DeviceID: t.id, Name: t.name, Error: err.Error(), + }) + continue + } + res.Purged = append(res.Purged, *one) + res.Configs += one.Configs + res.FreedBytes += one.FreedBytes + res.Series += one.Series + res.Samples += one.Samples + res.Branches += len(one.Branches) + } + if len(res.Purged) == 0 && len(res.Failed) > 0 { + return res, fmt.Errorf("жоден хост видалити не вдалося: %s", res.Failed[0].Error) + } + return res, nil +} + +// purgeOne — повне видалення одного хоста. +// +// Порядок кроків не довільний, і кожен наступний спирається на +// попередній. +// +// 1. Хост спершу АРХІВУЄТЬСЯ (deleted_at, enabled = false, чеки +// вимкнено) — тим самим кодом, що й звичайне архівне видалення. +// Це не формальність: доки хост живий, зонд і далі складає в нього +// нові виміри, і прибирання телеметрії ганялося б за власним +// хвостом. Заразом це і є точка, у якій перерваний процес лишає +// зрозумілий стан: хост уже зник з інтерфейсу, а повтор доробить +// решту. +// 2. Телеметрія без зовнішніх ключів — партіями, кожна своєю +// транзакцією. Найдовший крок, і саме тому він поза підсумковою +// транзакцією. +// 3. Одна підсумкова транзакція: відкати, тіла конфігів у core.secrets, +// сповіщення й стан лінків, сам рядок хоста (каскад забирає решту) +// і рядок черги на видалення гілки. +// 4. Локальна гілка — після коміту. До коміту її чіпати не можна: +// транзакція, що відкотилась після зняття посилання, лишила б +// історію конфігів недосяжною при цілих рядках у базі. +func (s *Store) purgeOne(ctx context.Context, tenantID, deviceID, name, addr string) (*PurgedDevice, error) { + out := &PurgedDevice{DeviceID: deviceID, Name: name, Address: addr} + + // --- крок 1: припинити збір --- + if err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + if _, err := tx.Exec(ctx, ` + UPDATE inv.devices + SET deleted_at = COALESCE(deleted_at, now()), enabled = false, + archived_enabled = COALESCE(archived_enabled, enabled) + WHERE tenant_id = $1 AND id = $2 + `, tenantID, deviceID); err != nil { + return err + } + // Той самий archived_off, що й в архівному видаленні. Хост + // зникне назавжди за кілька кроків, тож позначка тут ні на що + // не впливає — вона стоїть, бо крок 1 має лишатись ОДНІЄЮ дією + // архівування, а не її схожою копією: перерваний посеред роботи + // процес лишає хост саме заархівованим, і з цього стану його + // мусить бути видно й можна повернути. + _, err := tx.Exec(ctx, ` + UPDATE core.checks + SET enabled = false, archived_off = true, updated_at = now() + WHERE tenant_id = $1 AND device_id = $2 AND enabled + `, tenantID, deviceID) + return err + }); err != nil { + return nil, fmt.Errorf("зупинка опитування: %w", err) + } + + // --- крок 2: телеметрія --- + // + // Ряди метрик читаються ОДИН раз і далі використовуються як перелік: + // ts.samples не має ні tenant_id, ні device_id, і єдиний шлях від + // хоста до його вимірів — через ts.series. Після кроку 1 нові ряди + // не з'являються. + var seriesIDs []int64 + if err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, ` + SELECT id FROM ts.series WHERE tenant_id = $1 AND device_id = $2 + `, tenantID, deviceID) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return err + } + seriesIDs = append(seriesIDs, id) + } + return rows.Err() + }); err != nil { + return nil, fmt.Errorf("ряди метрик: %w", err) + } + out.Series = len(seriesIDs) + + if len(seriesIDs) > 0 { + // Виміри прибираються ЯВНО, а не каскадом від ts.series. + // + // Не тому, що каскад не спрацював би. Спрацював би: перевірено + // на TimescaleDB 2.17.2 зі СТИСНЕНИМ шматком — DELETE рядів + // прибрав усі 2000 вимірів, які лежали стиснутими. Це варто + // було виміряти, бо припущення напрошувалось протилежне: + // зовнішній ключ оголошено на шматку, а стиснуті рядки лежать + // не в ньому. + // + // Причина в іншому: каскад робить усе однією операцією, тобто + // на хості з місячною історією тримає одну транзакцію на + // мільйони рядків. Явний прохід ділить це на партії, кожна зі + // своєю транзакцією (див. purgeTelemetry), і заразом рахує, що + // саме зникло — а це число потім читає людина у звіті й аудиті. + // Каскад не рахує нічого. + n, err := s.purgeTelemetry(ctx, tenantID, + `DELETE FROM ts.samples WHERE series_id = ANY($1::bigint[]) AND ts <= $2`, + `SELECT max(ts) FROM (SELECT ts FROM ts.samples + WHERE series_id = ANY($1::bigint[]) ORDER BY ts LIMIT $2) q`, + seriesIDs) + if err != nil { + return nil, fmt.Errorf("виміри метрик: %w", err) + } + out.Samples = n + } + + // Решта телеметрії ходить по device_id — і саме її не бачить жоден + // каскад: гіпертаблиця не може бути ціллю зовнішнього ключа. + for _, tbl := range []string{ + "ts.icmp_samples", "ts.if_counters", "ts.syslog", + "ts.snmp_traps", "ts.device_status_history", "alr.alerts_history", + } { + if _, err := s.purgeTelemetry(ctx, tenantID, + `DELETE FROM `+tbl+` WHERE tenant_id = $1 AND device_id = $3 AND ts <= $2`, + `SELECT max(ts) FROM (SELECT ts FROM `+tbl+` + WHERE tenant_id = $1 AND device_id = $3 ORDER BY ts LIMIT $2) q`, + nil, deviceID); err != nil { + return nil, fmt.Errorf("%s: %w", tbl, err) + } + } + + // --- крок 3: підсумкова транзакція --- + var branches []string + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + // Відкати перші: ncm.rollbacks.target_config_id оголошено + // RESTRICT, тобто версія конфігу, на яку посилається відкат, не + // видаляється, поки живий сам відкат. Каскад від хоста забрав + // би обидва, але тіла конфігів ми прибираємо ДО нього — і без + // цього рядка спіткнулись би об RESTRICT. + if _, err := tx.Exec(ctx, ` + DELETE FROM ncm.rollbacks WHERE tenant_id = $1 AND device_id = $2 + `, tenantID, deviceID); err != nil { + return err + } + + // Гілки — усі, під якими хост колись комітився. Зазвичай одна, + // але перейменування могло не доїхати (RenameBranch не валить + // збір конфігу, а лише попереджає), і тоді їх дві. + rows, err := tx.Query(ctx, ` + SELECT DISTINCT branch FROM ncm.configs + WHERE tenant_id = $1 AND device_id = $2 AND branch <> '' + `, tenantID, deviceID) + if err != nil { + return err + } + seen := map[string]bool{} + for rows.Next() { + var b string + if err := rows.Scan(&b); err != nil { + rows.Close() + return err + } + if !seen[b] { + seen[b] = true + branches = append(branches, b) + } + } + rows.Close() + if err := rows.Err(); err != nil { + return err + } + // Плюс те ім'я, яке гілка мала б ЗАРАЗ. Рядок ncm.configs може + // не встигнути про нього дізнатись: між перейменуванням хоста й + // наступним бекапом гілка вже переїхала, а в базі стоїть старе + // ім'я. + if b := DeviceBranch(deviceID, name, addr); !seen[b] { + branches = append(branches, b) + } + + // Тіла конфігів. Той самий deleteConfigRows, що й у ручному + // видаленні версій: шифротекст у core.secrets ніяким каскадом + // не прибирається (посилання SET NULL), а лежать у ньому саме + // мегабайти. + var cfgIDs []string + crows, err := tx.Query(ctx, ` + SELECT id::text FROM ncm.configs WHERE tenant_id = $1 AND device_id = $2 + `, tenantID, deviceID) + if err != nil { + return err + } + for crows.Next() { + var id string + if err := crows.Scan(&id); err != nil { + crows.Close() + return err + } + cfgIDs = append(cfgIDs, id) + } + crows.Close() + if err := crows.Err(); err != nil { + return err + } + if len(cfgIDs) > 0 { + n, freed, err := deleteConfigRows(ctx, tx, tenantID, cfgIDs) + if err != nil { + return err + } + out.Configs, out.FreedBytes = n, freed + } + + // Сповіщення й стан лінків. Обидві таблиці — гіпертаблиці, обидві + // тримають чужий id (alert_id, link_id), і обидва їхні джерела + // зникнуть каскадом за мить. Прибрати їх ПІСЛЯ видалення хоста + // було б уже нічим: переліку не лишилось би. + if _, err := tx.Exec(ctx, ` + DELETE FROM alr.notifications + WHERE tenant_id = $1 AND alert_id IN ( + SELECT id FROM alr.alerts WHERE tenant_id = $1 AND device_id = $2) + `, tenantID, deviceID); err != nil { + return err + } + if _, err := tx.Exec(ctx, ` + DELETE FROM ts.link_status + WHERE tenant_id = $1 AND link_id IN ( + SELECT id FROM topo.links + WHERE tenant_id = $1 + AND (a_device_id = $2::uuid OR b_device_id = $2::uuid)) + `, tenantID, deviceID); err != nil { + return err + } + + // Сам хост. Далі каскад забирає решту двадцяти зв'язків. + tag, err := tx.Exec(ctx, ` + DELETE FROM inv.devices WHERE tenant_id = $1 AND id = $2 + `, tenantID, deviceID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + + // Черга на видалення гілок — у ТІЙ САМІЙ транзакції, що й + // видалення хоста. Це і є відповідь на «що буде, якщо дзеркало + // недоступне»: хост зникає зараз, а гілка на тому кінці — + // коли Forgejo відповість. Рядок черги переживає і перезапуск + // процесу, і тижневу відмову дзеркала. + return enqueueRefDeletes(ctx, tx, tenantID, deviceID, name, branches) + }) + if err != nil { + return nil, err + } + out.Branches = branches + + // --- крок 4: локальна гілка --- + // + // Помилка тут не скасовує видалення: хоста вже немає, а гілка + // лишається в черзі й буде прибрана тим самим тактом, що розгрібає + // дзеркало. Тому лише журнал. + if s.git != nil { + for _, b := range branches { + if _, gerr := s.git.DeleteBranch(RepoName(tenantID), b); gerr != nil { + slog.Warn("git: локальну гілку не прибрано, лишається в черзі", + "branch", b, "device", name, "error", gerr) + } + } + } + return out, nil +} + +// purgeTelemetry прибирає рядки гіпертаблиці партіями. +// +// Партія відбирається не за кількістю, а за ЧАСОМ: спершу шукається +// позначка часу N-го найстарішого рядка, потім видаляється все до неї +// включно. Прямий `DELETE ... LIMIT` у Postgres не існує, а обхід через +// ctid на гіпертаблиці не працює — рядки лежать у шматках, і ctid +// унікальний лише всередині шматка. Час же є первинним ключем усіх цих +// таблиць, тож відбір по ньому потрапляє точно в шматок і в індекс. +// +// Кожна партія — своя транзакція. Перерваний процес лишає рівно ті +// партії, що встигли, і повтор доробляє решту: хост на цей момент уже +// архівований, тож нові рядки не додаються. +// +// args — необов'язкові додаткові параметри запиту, які починаються з $3. +func (s *Store) purgeTelemetry(ctx context.Context, tenantID, delSQL, pickSQL string, + series []int64, args ...any) (int64, error) { + + var total int64 + for { + var deleted int64 + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + // $1 — tenant або перелік рядів, $2 — розмір партії. + pickArgs := make([]any, 0, 2+len(args)) + if series != nil { + pickArgs = append(pickArgs, series, telemetryBatch) + } else { + pickArgs = append(pickArgs, tenantID, telemetryBatch) + } + pickArgs = append(pickArgs, args...) + + var hi *time.Time + if err := tx.QueryRow(ctx, pickSQL, pickArgs...).Scan(&hi); err != nil { + return err + } + if hi == nil { + return nil + } + + delArgs := make([]any, 0, 2+len(args)) + if series != nil { + delArgs = append(delArgs, series, *hi) + } else { + delArgs = append(delArgs, tenantID, *hi) + } + delArgs = append(delArgs, args...) + + tag, err := tx.Exec(ctx, delSQL, delArgs...) + if err != nil { + return err + } + deleted = tag.RowsAffected() + return nil + }) + if err != nil { + return total, err + } + total += deleted + if deleted == 0 { + return total, nil + } + } +} diff --git a/server/internal/store/devices_purge_test.go b/server/internal/store/devices_purge_test.go new file mode 100644 index 0000000..d3952f8 --- /dev/null +++ b/server/internal/store/devices_purge_test.go @@ -0,0 +1,254 @@ +package store + +import ( + "context" + "os" + "testing" + "time" + + "github.com/netpulse/netpulse/server/internal/gitstore" +) + +// Перевірка повного видалення проти СПРАВЖНЬОЇ бази. +// +// Чому не мок і не перевірка SQL очима. Уся суть цієї дії в тому, що +// частину роботи робить не наш код, а каскади бази — і рівно там, де +// вони не спрацьовують, лишається сміття, якого ніхто не побачить. +// Тричі в цьому проєкті вже виявлялось, що дані не збігаються з +// оголошенням; довіряти тут опису таблиць не можна. +// +// Тест мовчки пропускається без NETPULSE_TEST_DSN: `go test ./...` не +// має вимагати бази. Запускати його треба на ОДНОРАЗОВІЙ базі — +// він видаляє те, що створив, і робить це назавжди. +// +// docker run --rm -d --name np-test -e POSTGRES_PASSWORD=x \ +// timescale/timescaledb:2.17.2-pg16 +// NETPULSE_TEST_DSN=postgres://postgres:x@np-test/np go test ./internal/store/ -run Purge +func TestPurgeDeviceLeavesNothing(t *testing.T) { + dsn := os.Getenv("NETPULSE_TEST_DSN") + if dsn == "" { + t.Skip("NETPULSE_TEST_DSN не задано — перевірка проти бази пропускається") + } + ctx := context.Background() + + s, err := New(ctx, dsn) + if err != nil { + t.Fatalf("підключення: %v", err) + } + defer s.Close() + s.UseGit(gitstore.New(t.TempDir())) + + var tenantID string + slug := "purge-test-" + time.Now().Format("150405") + if err := s.pool.QueryRow(ctx, ` + INSERT INTO core.tenants (slug, name) VALUES ($1, 'Тест видалення') RETURNING id::text + `, slug).Scan(&tenantID); err != nil { + t.Fatalf("кабінет: %v", err) + } + defer func() { + _, _ = s.pool.Exec(context.Background(), + `DELETE FROM core.tenants WHERE id = $1`, tenantID) + }() + + var repoID, deviceID, ifaceID, secretID, alertID string + var seriesID int64 + + must := func(what string, err error) { + t.Helper() + if err != nil { + t.Fatalf("%s: %v", what, err) + } + } + + must("репозиторій", s.pool.QueryRow(ctx, ` + INSERT INTO ncm.repos (tenant_id, name, storage_path, mirror_enabled, remote_url, remote_auth) + VALUES ($1, 'default', '/tmp/x.git', true, 'https://git.example/np.git', 'https') + RETURNING id::text`, tenantID).Scan(&repoID)) + + must("хост", s.pool.QueryRow(ctx, ` + INSERT INTO inv.devices (tenant_id, name, address, kind) + VALUES ($1, 'Леніна.21', '10.9.9.9', 'switch') RETURNING id::text`, + tenantID).Scan(&deviceID)) + + must("порт", s.pool.QueryRow(ctx, ` + INSERT INTO inv.interfaces (tenant_id, device_id, if_index, name) + VALUES ($1, $2, 1, 'ge-0/0/1') RETURNING id::text`, + tenantID, deviceID).Scan(&ifaceID)) + + _, err = s.pool.Exec(ctx, ` + INSERT INTO core.checks (tenant_id, device_id, check_type) VALUES ($1, $2, 'icmp.ping')`, + tenantID, deviceID) + must("перевірка", err) + + must("ряд метрик", s.pool.QueryRow(ctx, ` + INSERT INTO ts.series (tenant_id, device_id, metric_key) VALUES ($1, $2, 'icmp.rtt') + RETURNING id`, tenantID, deviceID).Scan(&seriesID)) + + _, err = s.pool.Exec(ctx, ` + INSERT INTO ts.samples (ts, series_id, value) + SELECT now() - (g || ' minutes')::interval, $1, g + FROM generate_series(1, 500) g`, seriesID) + must("виміри", err) + + // Ще 400 вимірів — десятиденної давності й СТИСНУТІ. + // + // Це не зайва ретельність. Стиснення в TimescaleDB тримає рядки не + // в самому шматку, а в окремій таблиці, і зовнішній ключ оголошено + // на шматку — тобто напрошується висновок, що видалення стиснутого + // не дістане. Висновок хибний (2.17.2 розтискає під DML сама), але + // перевіряти його треба тут, а не в голові: на бойових інсталяціях + // стиснуте — це майже вся телеметрія. + _, err = s.pool.Exec(ctx, ` + INSERT INTO ts.samples (ts, series_id, value) + SELECT now() - interval '10 days' + (g || ' seconds')::interval, $1, g + FROM generate_series(1, 400) g`, seriesID) + must("старі виміри", err) + + _, err = s.pool.Exec(ctx, ` + SELECT compress_chunk(format('%I.%I', chunk_schema, chunk_name)::regclass) + FROM timescaledb_information.chunks + WHERE hypertable_schema = 'ts' AND hypertable_name = 'samples' + AND NOT is_compressed AND range_end < now() - interval '2 days'`) + must("стиснення шматка", err) + + _, err = s.pool.Exec(ctx, ` + INSERT INTO ts.icmp_samples (ts, device_id, tenant_id, reachable) + SELECT now() - (g || ' minutes')::interval, $1, $2, true + FROM generate_series(1, 300) g`, deviceID, tenantID) + must("icmp", err) + + _, err = s.pool.Exec(ctx, ` + INSERT INTO ts.if_counters (ts, interface_id, device_id, tenant_id, in_octets) + SELECT now() - (g || ' minutes')::interval, $1, $2, $3, g + FROM generate_series(1, 300) g`, ifaceID, deviceID, tenantID) + must("лічильники портів", err) + + must("алерт", s.pool.QueryRow(ctx, ` + INSERT INTO alr.alerts (tenant_id, device_id, severity, title, dedup_key) + VALUES ($1, $2, 'high', 'тест', 'k1') RETURNING id::text`, + tenantID, deviceID).Scan(&alertID)) + + _, err = s.pool.Exec(ctx, ` + INSERT INTO alr.notifications (tenant_id, alert_id) VALUES ($1, $2)`, + tenantID, alertID) + must("сповіщення", err) + + _, err = s.pool.Exec(ctx, ` + INSERT INTO alr.alerts_history (ts, id, tenant_id, device_id, severity, title, started_at) + VALUES (now(), core.new_id(), $1, $2, 'high', 'тест', now())`, tenantID, deviceID) + must("історія алертів", err) + + // Тіло конфігу в core.secrets — найважливіше з того, чого каскад не + // прибирає: посилання оголошено ON DELETE SET NULL. + must("тіло конфігу", s.pool.QueryRow(ctx, ` + INSERT INTO core.secrets (tenant_id, kind, key_id, nonce, ciphertext, auth_tag) + VALUES ($1, 'generic', 'k1', '\x00', '\x01', '\x02') RETURNING id::text`, + tenantID).Scan(&secretID)) + + _, err = s.pool.Exec(ctx, ` + INSERT INTO ncm.configs (tenant_id, device_id, repo_id, commit_sha, blob_sha, + branch, path, size_bytes, content_hash, body_secret_id) + VALUES ($1, $2, $3, 'abc', 'def', 'device/Леніна.21-10.9.9.9', + 'Леніна.21/running.cfg', 4096, '\x00', $4)`, + tenantID, deviceID, repoID, secretID) + must("версія конфігу", err) + + // --- сама дія --- + + res, err := s.PurgeDevices(ctx, tenantID, Scope{Unrestricted: true}, []string{deviceID}) + if err != nil { + t.Fatalf("видалення: %v", err) + } + if len(res.Purged) != 1 { + t.Fatalf("очікували один видалений хост, отримали %d (%+v)", len(res.Purged), res.Failed) + } + got := res.Purged[0] + if got.Name != "Леніна.21" || got.Address != "10.9.9.9" { + t.Errorf("ім'я й адреса мають доїхати до звіту (це те, що піде в аудит): %+v", got) + } + if got.Samples != 900 { + t.Errorf("вимірів прибрано %d, очікували 900 (з них 400 — зі стиснутого шматка)", + got.Samples) + } + if got.Configs != 1 || got.FreedBytes != 4096 { + t.Errorf("версій %d, звільнено %d — очікували 1 і 4096", got.Configs, got.FreedBytes) + } + + // --- що лишилось --- + + check := func(what, sql string, args ...any) { + t.Helper() + var n int + if err := s.pool.QueryRow(ctx, sql, args...).Scan(&n); err != nil { + t.Fatalf("%s: %v", what, err) + } + if n != 0 { + t.Errorf("%s: лишилось %d рядків", what, n) + } + } + + check("хост", `SELECT count(*) FROM inv.devices WHERE id = $1`, deviceID) + check("перевірки", `SELECT count(*) FROM core.checks WHERE device_id = $1`, deviceID) + check("порти", `SELECT count(*) FROM inv.interfaces WHERE device_id = $1`, deviceID) + check("ряди метрик", `SELECT count(*) FROM ts.series WHERE device_id = $1`, deviceID) + check("виміри", `SELECT count(*) FROM ts.samples WHERE series_id = $1`, seriesID) + check("icmp", `SELECT count(*) FROM ts.icmp_samples WHERE device_id = $1`, deviceID) + check("лічильники портів", `SELECT count(*) FROM ts.if_counters WHERE device_id = $1`, deviceID) + check("алерти", `SELECT count(*) FROM alr.alerts WHERE device_id = $1`, deviceID) + check("сповіщення", `SELECT count(*) FROM alr.notifications WHERE alert_id = $1`, alertID) + check("історія алертів", `SELECT count(*) FROM alr.alerts_history WHERE device_id = $1`, deviceID) + check("версії конфігу", `SELECT count(*) FROM ncm.configs WHERE device_id = $1`, deviceID) + // Найважливіший рядок тесту: шифротекст, на який більше ніщо не + // посилається, лишився б у базі назавжди. + check("зашифровані тіла", `SELECT count(*) FROM core.secrets WHERE id = $1`, secretID) + + // --- гілка стала в чергу --- + + var branch string + if err := s.pool.QueryRow(ctx, ` + SELECT branch FROM ncm.ref_deletions WHERE tenant_id = $1 ORDER BY branch LIMIT 1 + `, tenantID).Scan(&branch); err != nil { + t.Fatalf("черга видалень гілок порожня: %v", err) + } + if branch != "device/Леніна.21-10.9.9.9" { + t.Errorf("у черзі гілка %q", branch) + } + // Рядок черги мусить ПЕРЕЖИТИ видалення хоста — заради цього на + // device_id і не поставлено зовнішнього ключа. + var deviceIDInQueue *string + if err := s.pool.QueryRow(ctx, ` + SELECT device_id::text FROM ncm.ref_deletions WHERE tenant_id = $1 LIMIT 1 + `, tenantID).Scan(&deviceIDInQueue); err != nil { + t.Fatalf("рядок черги: %v", err) + } + if deviceIDInQueue == nil || *deviceIDInQueue != deviceID { + t.Errorf("у черзі має лишитись id видаленого хоста, маємо %v", deviceIDInQueue) + } + + // Такт колектора бачить цей рядок і знає, що дзеркало налаштоване. + // Без цієї перевірки помилка в JOIN'ах вибірки виявилась би тоді, + // коли гілки мовчки не прибирались би тижнями. + tasks, err := s.PendingRefDeletes(ctx, nil, 100) + if err != nil { + t.Fatalf("вибірка черги: %v", err) + } + var mine *RefDeleteTask + for i := range tasks { + if tasks[i].TenantID == tenantID { + mine = &tasks[i] + break + } + } + if mine == nil { + t.Fatal("такт не побачив рядка черги") + } + if !mine.MirrorReady { + t.Error("дзеркало налаштоване — такт мав це побачити") + } + if mine.LocalDone { + t.Error("локальну гілку ще не прибирали цим шляхом") + } + if mine.Remote.URL != "https://git.example/np.git" { + t.Errorf("адреса дзеркала не доїхала: %q", mine.Remote.URL) + } +} diff --git a/server/internal/store/devices_restore.go b/server/internal/store/devices_restore.go new file mode 100644 index 0000000..65d0830 --- /dev/null +++ b/server/internal/store/devices_restore.go @@ -0,0 +1,310 @@ +package store + +import ( + "context" + "fmt" + "strings" + + "github.com/jackc/pgx/v5" +) + +// Повернення хоста з архіву. +// +// Третя дія над хостом поруч із двома наявними: архівне видалення +// (BulkDeleteDevices) прибирає з переліків, повне (PurgeDevices) стирає +// назавжди, це — повертає. Досі її не було взагалі, і архівне видалення +// через це працювало як пастка: хост не видалений, місце займає, ряди +// метрик лишає, а дістатись до нього не можна нізвідки, крім SQL. +// +// ЧОМУ ЦЕ НЕ `deleted_at = NULL` +// +// Архівування вимикає хост і всі його увімкнені перевірки. Знявши саме +// лише deleted_at, ми повернули б у перелік хост, який мовчить: він на +// місці, графіки порожні, причину не видно ніде. Тому повернення знімає +// рівно те, що поставило архівування, — і спирається на дві колонки з +// 0062, у які архівування записує, що саме воно вимкнуло. +// +// ТРИ РЕЧІ, ЯКІ МОГЛИ ЗМІНИТИСЬ ЗА ЧАС В АРХІВІ +// +// Архів — не сейф: поки хост лежав прибраним, у кабінеті працювали далі. +// Дві унікальні умови бази стосуються саме живих рядків і мовчки +// зіткнулися б із поверненням: +// +// - devices_tenant_name_uniq (tenant_id, lower(name)) WHERE deleted_at +// IS NULL. За місяць в архіві хост із такою ж назвою могли завести +// заново. Ім'я — це те, чим хост називають уголос, і мовчки додати +// до нього « (2)» означало б повернути не той хост, який просили. +// Тому такий випадок — відмова саме по цьому хосту, зі словами, що +// робити далі; решта набору повертається. +// - devices_self_agent_uniq (self_agent_id) WHERE deleted_at IS NULL. +// Прив'язку «цей хост і є машина зонда» за цей час міг забрати інший +// живий хост. Тут навпаки: хост повертається, а прив'язка з нього +// знімається — і про це прямо сказано у відповіді. Різниця з іменем +// не в примсі: ім'я незамінне, а прив'язку людина ставить назад +// двома кліками, і відмовити через неї означало б лишити хост в +// архіві через налаштування, яке й так треба переглянути. +// +// Третє — сам зонд-опитувач (agent_id). Його чіпати не треба: якщо зонд +// видалили, ключ уже поставив NULL (ON DELETE SET NULL з 0003), і хост +// повернеться без опитувача. Вигадувати йому зонд ми не маємо права, а +// порожній зонд видно в переліку хостів окремою колонкою. + +// RestoredDevice — хост, який повернувся, і в якому саме стані. +// +// Ці три поля — не звіт заради звіту. «Повернули» без них означає лише +// «рядок знову видно»: чи опитується хост, чи ввімкнені його перевірки +// й чи лишилась прив'язка — рівно ті питання, які людина поставить +// наступними, і відповіді на них є тільки тут. +type RestoredDevice struct { + DeviceID string `json:"device_id"` + Name string `json:"name"` + // Enabled — чи повернувся хост увімкненим. false означає одне з + // двох: або його вимкнули ще до архівування, або заархівували до + // появи 0062, і тоді відповіді просто немає. + Enabled bool `json:"enabled"` + // Checks — скільки перевірок увімкнено назад. + Checks int `json:"checks"` + // SelfAgentDropped — прив'язку «хост = машина зонда» знято, бо ту + // саму машину вже оголосив своєю інший живий хост. + SelfAgentDropped bool `json:"self_agent_dropped,omitempty"` +} + +// RestoreFailure — хост, якого повернути не вдалось, і чому. +type RestoreFailure struct { + DeviceID string `json:"device_id"` + Name string `json:"name"` + Error string `json:"error"` +} + +// RestoreResult — підсумок повернення. +type RestoreResult struct { + Restored []RestoredDevice `json:"restored"` + Failed []RestoreFailure `json:"failed,omitempty"` + // Checks — усього ввімкнено перевірок по всьому набору. + Checks int `json:"checks"` +} + +// RestoreDevices повертає заархівовані хости в роботу. +// +// Стеля та сама, що й у решти масових дій: повернення саме по собі +// оборотне (хост завжди можна заархівувати знову), але воно вмикає +// опитування, а сорок хостів, які разом пішли в план колектора, — це +// вже навантаження, про яке людина мусить думати свідомо. +// +// Ліміт тарифу тут не перевіряється, і це не пропуск: тригер +// bill.assert_device_limit стоїть на INSERT, тобто рахує ПОЯВУ нових +// хостів. Повернутий хост не новий — він уже був у цьому кабінеті й уже +// був порахований, поки його не прибрали. +func (s *Store) RestoreDevices(ctx context.Context, tenantID string, sc Scope, + ids []string) (*RestoreResult, error) { + + if len(ids) == 0 { + return nil, fmt.Errorf("%w: не обрано жодного хоста", ErrInvalid) + } + if len(ids) > MaxBulkDevices { + return nil, fmt.Errorf("%w: за раз можна повернути не більше %d хостів, обрано %d", + ErrInvalid, MaxBulkDevices, len(ids)) + } + + res := &RestoreResult{Restored: []RestoredDevice{}} + + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + // Набір звужується проти бази ще раз, як і в решті масових дій: + // перевірка прав в обробнику відповідає на «чи можна було», а + // цей запит — на «чи хост ще в архіві». Заразом він відповідає + // на обидва питання про зіткнення — імені й прив'язки, — щоб не + // ловити їх помилкою унікального індексу вже після UPDATE. + rows, err := tx.Query(ctx, ` + SELECT d.id::text, d.name, COALESCE(d.archived_enabled, false), + COALESCE(d.self_agent_id::text, ''), + EXISTS (SELECT 1 FROM inv.devices o + WHERE o.tenant_id = d.tenant_id + AND o.deleted_at IS NULL + AND o.self_agent_id = d.self_agent_id), + EXISTS (SELECT 1 FROM inv.devices n + WHERE n.tenant_id = d.tenant_id + AND n.deleted_at IS NULL + AND lower(n.name) = lower(d.name)) + FROM inv.devices d + WHERE d.tenant_id = $1 AND d.id = ANY($2::uuid[]) + AND d.deleted_at IS NOT NULL + AND ($3::boolean OR d.id = ANY($4::uuid[])) + ORDER BY d.name + `, tenantID, nonNilIDs(ids), sc.Unrestricted, nonNilIDs(sc.Writable)) + if err != nil { + return err + } + var cands []restoreCandidate + for rows.Next() { + var c restoreCandidate + if err := rows.Scan(&c.id, &c.name, &c.enabled, &c.selfAgent, + &c.selfTaken, &c.nameTaken); err != nil { + rows.Close() + return err + } + cands = append(cands, c) + } + rows.Close() + if err := rows.Err(); err != nil { + return err + } + if len(cands) == 0 { + return fmt.Errorf("%w: жоден з обраних хостів не в архіві "+ + "або не доступний вам на запис", ErrInvalid) + } + + plan := planRestore(cands) + res.Restored = plan.restored + res.Failed = plan.failed + back, unbind := plan.back, plan.unbind + if len(back) == 0 { + return nil + } + + if _, err := tx.Exec(ctx, ` + UPDATE inv.devices + SET deleted_at = NULL, + enabled = COALESCE(archived_enabled, false), + archived_enabled = NULL, + -- Стан обнуляється навмисно. Хост лежав в архіві й не + -- опитувався: те, що записано в status, — це те, як він + -- виглядав перед видаленням, і показати тритижневої + -- давнини «недоступний» як поточний стан означало б + -- збрехати ще до першого опитування. + status = 'unknown'::inv.device_status, + status_changed_at = now(), + self_agent_id = CASE WHEN id = ANY($3::uuid[]) + THEN NULL ELSE self_agent_id END, + self_machine_id = CASE WHEN id = ANY($3::uuid[]) + THEN NULL ELSE self_machine_id END + WHERE tenant_id = $1 AND id = ANY($2::uuid[]) AND deleted_at IS NOT NULL + `, tenantID, nonNilIDs(back), nonNilIDs(unbind)); err != nil { + return err + } + + // Вмикаються ЛИШЕ ті перевірки, які вимкнуло архівування. + // + // next_run_at зсувається на зараз, а не лишається тим, що було + // до архівування: інакше кожна повернута перевірка одразу + // виглядає як така, що запізнилась на три тижні, — і сторінка + // «Черги» чесно показує затор, якого немає. + crows, err := tx.Query(ctx, ` + UPDATE core.checks + SET enabled = true, archived_off = false, + next_run_at = now(), updated_at = now() + WHERE tenant_id = $1 AND device_id = ANY($2::uuid[]) AND archived_off + RETURNING device_id::text + `, tenantID, nonNilIDs(back)) + if err != nil { + return err + } + counts := map[string]int{} + for crows.Next() { + var id string + if err := crows.Scan(&id); err != nil { + crows.Close() + return err + } + counts[id]++ + } + crows.Close() + if err := crows.Err(); err != nil { + return err + } + + for i := range res.Restored { + n := counts[res.Restored[i].DeviceID] + res.Restored[i].Checks = n + res.Checks += n + } + return nil + }) + if err != nil { + return nil, err + } + if len(res.Restored) == 0 { + // Сюди доходить лише набір, у якому кожен хост уперся в зайняту + // назву. Помилкою, а не порожнім успіхом: людина натиснула + // «повернути» й мусить дізнатись, чому нічого не повернулось. + return res, fmt.Errorf("%w: %s", ErrInvalid, res.Failed[0].Error) + } + return res, nil +} + +// --------------------------------------------------------------------- +// Рішення про набір +// --------------------------------------------------------------------- + +// restoreCandidate — прибраний хост таким, яким його бачить база перед +// поверненням: разом із відповіддю на обидва питання про зіткнення. +type restoreCandidate struct { + id, name string + enabled bool + selfAgent string + // selfTaken — ту саму машину зонда вже оголосив своєю живий хост. + selfTaken bool + // nameTaken — ім'я вже носить живий хост. + nameTaken bool +} + +// restorePlan — що саме робити з набором. +type restorePlan struct { + back []string + unbind []string + restored []RestoredDevice + failed []RestoreFailure +} + +// planRestore розкладає набір на «повернути», «повернути без прив'язки» +// і «відмовити». +// +// Винесено з транзакції окремою чистою функцією не заради краси. Уся +// нетривіальність повернення саме тут, і перевірити її інакше нічим: +// зіткнення імен і прив'язок у базі відтворюється лише повним стендом +// із двома хостами, майданчиком і зондом, а помилка тут коштує +// унікального індексу посеред UPDATE — тобто відмови всьому набору +// замість одного хоста. +// +// Сусід по набору важить не менше за живий хост. Два прибрані хости +// могли мати однакове ім'я або вказувати на ту саму машину зонда: поки +// обидва лежали в архіві, часткові унікальні індекси їх не бачили +// (обидва — `WHERE deleted_at IS NULL`), а після повернення побачать +// одразу. Тому зайняте накопичується під час обходу, а порядок обходу — +// той, у якому набір прийшов із бази (за іменем): перший претендент +// лишає собі, решта отримує відмову або зняту прив'язку. +func planRestore(cands []restoreCandidate) restorePlan { + var p restorePlan + p.restored = []RestoredDevice{} + + agents := map[string]bool{} + names := map[string]bool{} + + for _, c := range cands { + // Порівняння без регістру — те саме, що в devices_tenant_name_uniq + // (lower(name)). Інше порівняння тут означало б, що код вважає + // набір справним, а база — ні. + key := strings.ToLower(c.name) + if c.nameTaken || names[key] { + p.failed = append(p.failed, RestoreFailure{ + DeviceID: c.id, Name: c.name, + Error: "у переліку вже є хост із такою назвою — " + + "перейменуйте його або цей і поверніть ще раз", + }) + continue + } + names[key] = true + + r := RestoredDevice{DeviceID: c.id, Name: c.name, Enabled: c.enabled} + if c.selfAgent != "" { + if c.selfTaken || agents[c.selfAgent] { + r.SelfAgentDropped = true + p.unbind = append(p.unbind, c.id) + } else { + agents[c.selfAgent] = true + } + } + p.back = append(p.back, c.id) + p.restored = append(p.restored, r) + } + return p +} diff --git a/server/internal/store/devices_restore_test.go b/server/internal/store/devices_restore_test.go new file mode 100644 index 0000000..311d870 --- /dev/null +++ b/server/internal/store/devices_restore_test.go @@ -0,0 +1,126 @@ +package store + +import "testing" + +// Повернення хоста з архіву впирається у дві часткові унікальні умови, +// які стосуються ЛИШЕ живих рядків: ім'я (devices_tenant_name_uniq) і +// прив'язка «хост = машина зонда» (devices_self_agent_uniq). Поки хост +// лежав прибраним, обидві його не бачили, і зіткнення виникає рівно в +// мить повернення. +// +// Перевіряти це на живій базі означало б стенд із двома хостами, зондом +// і майданчиком на кожен випадок; ціна помилки — помилка унікального +// індексу посеред UPDATE, тобто відмова ВСЬОМУ набору замість одного +// хоста. Тому рішення про набір винесене чистою функцією, а тут — усі +// випадки, які вона мусить розрізняти. + +func TestPlanRestoreKeepsNamesUnique(t *testing.T) { + // Живий хост уже носить це ім'я — повертати нікуди. + p := planRestore([]restoreCandidate{ + {id: "1", name: "core-sw", nameTaken: true}, + {id: "2", name: "edge-1"}, + }) + if len(p.back) != 1 || p.back[0] != "2" { + t.Fatalf("повертати треба лише edge-1, а повертається %v", p.back) + } + if len(p.failed) != 1 || p.failed[0].DeviceID != "1" { + t.Fatalf("відмова мала дістатись core-sw, а дісталась %+v", p.failed) + } + if p.failed[0].Error == "" { + t.Fatal("відмова без причини: людина не дізнається, що робити далі") + } +} + +func TestPlanRestoreCatchesDuplicateInsideBatch(t *testing.T) { + // Два ПРИБРАНІ хости з однаковим іменем: у базі вони не зіткнулись, + // бо часткова унікальна умова не бачить прибраних. Зіткнуться, щойно + // повернуться обидва — і зловити це має набір, а не індекс. + // + // Регістр різний навмисно: індекс іде по lower(name). + p := planRestore([]restoreCandidate{ + {id: "1", name: "Core-SW"}, + {id: "2", name: "core-sw"}, + }) + if len(p.back) != 1 || p.back[0] != "1" { + t.Fatalf("перший претендент мав лишити ім'я собі, повертається %v", p.back) + } + if len(p.failed) != 1 || p.failed[0].DeviceID != "2" { + t.Fatalf("другому мала бути відмова, а маємо %+v", p.failed) + } +} + +func TestPlanRestoreDropsTakenSelfAgent(t *testing.T) { + // Прив'язку до машини зонда за час в архіві забрав живий хост. + // Хост усе одно повертається — інакше він лишився б в архіві через + // налаштування, яке й так треба переглянути, — але вже без неї. + p := planRestore([]restoreCandidate{ + {id: "1", name: "srv-1", selfAgent: "agent-a", selfTaken: true}, + }) + if len(p.back) != 1 { + t.Fatalf("хост мав повернутись, повертається %v", p.back) + } + if len(p.unbind) != 1 || p.unbind[0] != "1" { + t.Fatalf("прив'язку мали зняти, знімається з %v", p.unbind) + } + if !p.restored[0].SelfAgentDropped { + t.Fatal("зняту прив'язку не позначено — людина про неї не дізнається") + } + if len(p.failed) != 0 { + t.Fatalf("відмовляти тут нема за що: %+v", p.failed) + } +} + +func TestPlanRestoreSplitsSelfAgentInsideBatch(t *testing.T) { + // Двоє прибраних претендують на ту саму машину зонда. Один може + // лишити прив'язку собі, другий — ні; вирішує порядок набору. + p := planRestore([]restoreCandidate{ + {id: "1", name: "srv-1", selfAgent: "agent-a"}, + {id: "2", name: "srv-2", selfAgent: "agent-a"}, + }) + if len(p.back) != 2 { + t.Fatalf("обидва мали повернутись, повертається %v", p.back) + } + if len(p.unbind) != 1 || p.unbind[0] != "2" { + t.Fatalf("прив'язку мали зняти з другого, знімається з %v", p.unbind) + } + if p.restored[0].SelfAgentDropped || !p.restored[1].SelfAgentDropped { + t.Fatalf("позначки зняття стоять не там: %+v", p.restored) + } +} + +func TestPlanRestoreKeepsFreeSelfAgent(t *testing.T) { + // Прив'язка вільна — знімати її нема причини. Окремий випадок, бо + // саме він найчастіший, і зайве зняття тут означало б, що збір + // конфігів сервера мовчки не починається після повернення. + p := planRestore([]restoreCandidate{ + {id: "1", name: "srv-1", selfAgent: "agent-a", enabled: true}, + // Хост без прив'язки взагалі: порожній ключ не має вважатись + // зайнятим першим же таким хостом. + {id: "2", name: "srv-2"}, + {id: "3", name: "srv-3"}, + }) + if len(p.unbind) != 0 { + t.Fatalf("нічого знімати не треба, а знімається %v", p.unbind) + } + if len(p.back) != 3 || len(p.failed) != 0 { + t.Fatalf("мали повернутись усі три: %v, %+v", p.back, p.failed) + } + // enabled їде з archived_enabled і має дійти до відповіді як є: + // «повернувся вимкненим» — це те, чого людина не побачить ніде, + // крім цього поля. + if !p.restored[0].Enabled || p.restored[1].Enabled { + t.Fatalf("стан enabled перекручено: %+v", p.restored) + } +} + +func TestPlanRestoreEmptyBatch(t *testing.T) { + p := planRestore(nil) + if len(p.back) != 0 || len(p.failed) != 0 { + t.Fatalf("з порожнього набору не береться нічого: %+v", p) + } + // Порожній зріз, а не nil: він їде в JSON відповіді, і `null` там + // означав би для клієнта «поля немає», а не «нікого не повернуто». + if p.restored == nil { + t.Fatal("restored має бути порожнім зрізом, а не nil") + } +} diff --git a/server/internal/store/downloads.go b/server/internal/store/downloads.go new file mode 100644 index 0000000..63f773b --- /dev/null +++ b/server/internal/store/downloads.go @@ -0,0 +1,107 @@ +package store + +import ( + "context" + "time" + + "github.com/jackc/pgx/v5" +) + +// Квитки на завантаження файлу звичайним посиланням. +// +// Чому вони існують — у міграції 0038. Коротко: заголовок Authorization +// уміє додати лише fetch, а файл має тягнути завантажувач браузера, тож +// право доступу мусить бути в URL. +// +// Квиток відкриває рівно один об'єкт в одному форматі й живе хвилини. +// Ширшого доступу він не дає й дати не може: kind і object_id +// зафіксовані в момент видачі, а обробник нічого, крім них, не читає. + +// DownloadTicketTTL — скільки живе виданий квиток. +// +// Дві хвилини — це «натиснув і браузер пішов за файлом» із запасом на +// повільний канал. Довший термін перетворив би посилання на те, що можна +// переслати в чат і чим можна скористатись завтра. +const DownloadTicketTTL = 2 * time.Minute + +// Види квитків. Обробник розбирає саме це поле: object_id без kind +// нічого не означає. +const DownloadKindCommandReport = "ncm.command_run_report" + +// DownloadTicket — розібраний квиток. +type DownloadTicket struct { + Token string + TenantID string + UserID string + Kind string + ObjectID string + Format string +} + +// CreateDownloadTicket видає квиток. +// +// Токен приходить ззовні, а не народжується тут: його робить той самий +// auth.NewToken, що й токен телевізора, і тримати генерацію секретів у +// шарі доступу до даних означало б завести їй друге місце. +func (s *Store) CreateDownloadTicket(ctx context.Context, + token, tenantID, userID, kind, objectID, format string) error { + + // Прибирання протухлих — тут же, а не окремим воркером. Рядків тут + // стільки, скільки натискань на «Завантажити», і заводити під це + // фонову задачу було б дорожче за сам механізм. Година запасу лишає + // слід для розбору «хто щойно качав звіт». + if _, err := s.bg.Exec(ctx, ` + DELETE FROM core.download_tickets WHERE expires_at < now() - interval '1 hour' + `); err != nil { + return err + } + + // Видача квитка — дія в межах кабінету, і контекст тут виставляється + // навмисно: WITH CHECK на core.download_tickets вимагає, щоб + // tenant_id рядка збігався з поточним. Без цього під netpulse_app + // INSERT відхиляється — тобто кнопка «Завантажити» падала б з + // помилкою одразу, а не мовчки. + return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + INSERT INTO core.download_tickets + (token, tenant_id, user_id, kind, object_id, format, expires_at) + VALUES ($1, $2, $3, $4, $5, $6, now() + $7::interval) + `, token, tenantID, nullUUID(userID), kind, objectID, format, + DownloadTicketTTL.String()) + return err + }) +} + +// ResolveDownloadTicket знаходить квиток за токеном. +// +// Поза тенантним контекстом: на момент запиту особи ще немає, тенант +// з'ясовується з самого рядка. Той самий шлях, яким ходить публічний +// дашборд. +// +// Протухлий і неіснуючий токен нерозрізнимі назовні — різниця між ними +// це підказка тому, хто перебирає. +func (s *Store) ResolveDownloadTicket(ctx context.Context, token string) (DownloadTicket, error) { + var t DownloadTicket + if token == "" { + return t, ErrNotFound + } + + // UPDATE, а не SELECT: перше використання фіксується тим самим + // запитом, яким квиток читається. COALESCE лишає час ПЕРШОГО + // звернення — повторний запит браузера при обриві не має стирати + // відповідь на питання «коли файл поїхав». + err := s.bg.QueryRow(ctx, ` + UPDATE core.download_tickets + SET used_at = COALESCE(used_at, now()) + WHERE token = $1 AND expires_at > now() + RETURNING token, tenant_id::text, COALESCE(user_id::text,''), + kind, object_id::text, format + `, token).Scan(&t.Token, &t.TenantID, &t.UserID, &t.Kind, &t.ObjectID, &t.Format) + if err != nil { + if isNoRows(err) { + return DownloadTicket{}, ErrNotFound + } + return DownloadTicket{}, err + } + return t, nil +} diff --git a/server/internal/store/enrollment.go b/server/internal/store/enrollment.go index 85cc1fc..36ed8ee 100644 --- a/server/internal/store/enrollment.go +++ b/server/internal/store/enrollment.go @@ -158,7 +158,7 @@ func (s *Store) RedeemEnrollment(ctx context.Context, token, hostname, requested // Без InTenantTx: тенант тут ще невідомий — саме його ми й // з'ясовуємо з токена. RLS натомість не потрібна: вибірка йде за // унікальним хешем, який знає лише той, кому його видали. - tx, err := s.pool.Begin(ctx) + tx, err := s.bg.Begin(ctx) if err != nil { return nil, err } diff --git a/server/internal/store/events.go b/server/internal/store/events.go index b9a81c5..25f8b92 100644 --- a/server/internal/store/events.go +++ b/server/internal/store/events.go @@ -31,7 +31,7 @@ func (s *Store) FetchEvents(ctx context.Context, afterID int64, limit int) ([]Ev limit = 500 } - rows, err := s.pool.Query(ctx, ` + rows, err := s.bg.Query(ctx, ` SELECT id, tenant_id::text, topic, payload::text, created_at FROM core.event_outbox WHERE id > $1 @@ -62,7 +62,7 @@ func (s *Store) FetchEvents(ctx context.Context, afterID int64, limit int) ([]Ev // завантажив повний стан мапи, і все старіше в ньому вже враховано. func (s *Store) LatestEventID(ctx context.Context) (int64, error) { var id *int64 - if err := s.pool.QueryRow(ctx, + if err := s.bg.QueryRow(ctx, `SELECT max(id) FROM core.event_outbox`).Scan(&id); err != nil { return 0, err } @@ -78,7 +78,7 @@ func (s *Store) LatestEventID(ctx context.Context) (int64, error) { // прибиральнику: невідправлені події видаляти не можна, а відправлені — // можна, і без цього поля таблиця росла б вічно. func (s *Store) MarkEventsPublished(ctx context.Context, throughID int64) error { - _, err := s.pool.Exec(ctx, ` + _, err := s.bg.Exec(ctx, ` UPDATE core.event_outbox SET published_at = now() WHERE id <= $1 AND published_at IS NULL @@ -88,7 +88,7 @@ func (s *Store) MarkEventsPublished(ctx context.Context, throughID int64) error // PruneEvents видаляє доставлені події, старші за вказаний вік. func (s *Store) PruneEvents(ctx context.Context, olderThan time.Duration) (int64, error) { - tag, err := s.pool.Exec(ctx, ` + tag, err := s.bg.Exec(ctx, ` DELETE FROM core.event_outbox WHERE published_at IS NOT NULL AND created_at < now() - $1::interval `, olderThan.String()) diff --git a/server/internal/store/groups.go b/server/internal/store/groups.go index c19a162..d3a1bb9 100644 --- a/server/internal/store/groups.go +++ b/server/internal/store/groups.go @@ -441,14 +441,27 @@ func (s *Store) UpdateDevice(ctx context.Context, tenantID, deviceID string, in }) } -// DeleteDevice — м'яке видалення. +// DeleteDevice — АРХІВНЕ видалення: хост зникає з інтерфейсу, зібране +// лишається в базі. // -// Жорстке зняло б за собою всю телеметрію й історію алертів. Пристрій -// зникає з інтерфейсу, але його минуле лишається доступним для розбору. +// Це одна з двох дій, а не «видалення взагалі». Друга — PurgeDevices +// (devices_purge.go): вона стирає хост разом із телеметрією, архівом +// конфігів і гілкою в Git. Обидві законні, різниця незворотна, тому +// обирає її людина у вікні підтвердження, а не код тут. +// +// Що лишається після цієї: телеметрія, версії конфігів, історія +// алертів, вузли на мапах. Що припиняється: опитування, збір конфігів, +// нові алерти. +// +// Ця ручка лишається архівною й тоді, коли її смикають з API напряму. +// Незворотну дію не роблять параметром запиту, який легко не помітити: +// повне видалення йде окремим шляхом (POST /devices/bulk-delete з +// mode=purge), де перед ним обов'язково стоїть перелік із числами. func (s *Store) DeleteDevice(ctx context.Context, tenantID, deviceID string) error { return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { tag, err := tx.Exec(ctx, ` - UPDATE inv.devices SET deleted_at = now(), enabled = false + UPDATE inv.devices + SET deleted_at = now(), enabled = false, archived_enabled = enabled WHERE tenant_id = $1 AND id = $2 AND deleted_at IS NULL `, tenantID, deviceID) if err != nil { @@ -457,6 +470,32 @@ func (s *Store) DeleteDevice(ctx context.Context, tenantID, deviceID string) err if tag.RowsAffected() == 0 { return ErrNotFound } + + // Чеки вимикаємо разом із хостом. + // + // План їх і так не бере (BuildPlan фільтрує deleted_at), тож + // на опитування це не впливає. Впливає на все інше, що дивиться + // в core.checks: увімкнений чек, який ніколи не виконується, + // виглядає як затор — і сторінка «Черги» чесно показує його + // червоним рівно доти, доки на неї не перестануть дивитись. + // + // Не видаляємо, а вимикаємо: хост відновлюють разом із його + // налаштуваннями, а відновити те, чого немає, нема з чого. + // + // archived_off позначає, що чек вимкнула саме ця дія, а не + // людина. Без позначки повернення хоста мусило б або вмикати + // все підряд — разом із тим, що тримали вимкненим свідомо, — + // або не вмикати нічого, і тоді хост повертався б у переліки + // мовчазним. Умова `AND enabled` тут і є те, що робить + // позначку правдивою: вимкнене до архівування нею не + // позначається. + if _, err := tx.Exec(ctx, ` + UPDATE core.checks + SET enabled = false, archived_off = true, updated_at = now() + WHERE tenant_id = $1 AND device_id = $2 AND enabled + `, tenantID, deviceID); err != nil { + return err + } return nil }) } diff --git a/server/internal/store/identify.go b/server/internal/store/identify.go new file mode 100644 index 0000000..66c8d46 --- /dev/null +++ b/server/internal/store/identify.go @@ -0,0 +1,146 @@ +package store + +import ( + "context" + + "github.com/jackc/pgx/v5" +) + +// IdentifyDecision — відповідь на «розпізнати зараз». +// +// Відмова несе готовий для людини текст, а не код: причин кілька, і +// кожна лікується по-своєму — одна вимагає завести доступ, інша +// почекати, доки зонд повернеться. Змушувати інтерфейс перекладати +// коди назад у речення означало б тримати той самий перелік у двох +// місцях і забути оновити одне з них. +type IdentifyDecision struct { + Queued bool `json:"queued"` + Reason string `json:"reason,omitempty"` +} + +// RequestIdentify просить опитати хост по SNMP просто зараз. +// +// Сам запит не надсилається: API не має сесій зондів — вони живуть у +// колекторі, іншому процесі. Тут лишається намір, який забере +// ClaimIdentifyRequests. +func (s *Store) RequestIdentify(ctx context.Context, tenantID, deviceID string) (IdentifyDecision, error) { + var d IdentifyDecision + + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + var ( + agentID string + agentOnline bool + hasSNMP bool + ) + err := tx.QueryRow(ctx, ` + SELECT COALESCE(d.agent_id::text, ''), + COALESCE(a.status::text = 'online', false), + EXISTS ( + SELECT 1 FROM inv.device_credentials dc + JOIN inv.credentials c ON c.id = dc.credential_id + WHERE dc.device_id = d.id + AND c.tenant_id = d.tenant_id + AND c.proto IN ('snmp_v2c','snmp_v3') + ) + FROM inv.devices d + LEFT JOIN core.agents a ON a.id = d.agent_id + WHERE d.id = $1 AND d.tenant_id = $2 AND d.deleted_at IS NULL + `, deviceID, tenantID).Scan(&agentID, &agentOnline, &hasSNMP) + if err != nil { + return err + } + + switch { + case !hasSNMP: + d.Reason = "у хоста немає SNMP-доступу — додайте його на вкладці «Доступи», " + + "і хост розпізнається сам" + return nil + case agentID == "": + d.Reason = "хост не прив'язаний до зонда — нікому його опитати" + return nil + case !agentOnline: + d.Reason = "зонд не на зв'язку — прохання виконається, щойно він повернеться" + // Намір усе одно лишаємо: коли зонд повернеться, диспетчер + // його забере. Відмова тут описує затримку, а не поразку. + } + + // Чек має існувати до того, як просити його запуску: зонд + // зрушує задачу зі свого розкладу, а не виконує все, що + // прилетіло. Без цього рядка прохання нікуди б не влучило. + if _, err := tx.Exec(ctx, ` + INSERT INTO core.checks + (tenant_id, device_id, check_type, params, interval_sec, timeout_ms, retries) + SELECT $2, $1, $3, '{}'::jsonb, $4, 10000, 1 + WHERE NOT EXISTS ( + SELECT 1 FROM core.checks ch + WHERE ch.device_id = $1 AND ch.tenant_id = $2 + AND ch.check_type IN ($3, 'topology.discover') + ) + `, deviceID, tenantID, IdentifyCheckType, + int32(IdentifyProbeInterval.Seconds())); err != nil { + return err + } + + if _, err := tx.Exec(ctx, ` + UPDATE inv.devices SET identify_requested_at = now(), updated_at = now() + WHERE id = $1 AND tenant_id = $2 + `, deviceID, tenantID); err != nil { + return err + } + + d.Queued = d.Reason == "" + return nil + }) + return d, err +} + +// IdentifyRequest — прохання, готове до надсилання. +type IdentifyRequest struct { + TenantID string + AgentID string + DeviceID string +} + +// ClaimIdentifyRequests забирає прохання для зондів, які зараз на +// зв'язку. +// +// Забирає назовсім: повторне надсилання гірше за втрачене прохання — +// людина натисне ще раз і побачить результат, а зациклене опитування +// вона не побачить узагалі й дізнається про нього з трафіку. +func (s *Store) ClaimIdentifyRequests(ctx context.Context, onlineAgents []string, limit int) ([]IdentifyRequest, error) { + if len(onlineAgents) == 0 { + return nil, nil + } + if limit <= 0 { + limit = 64 + } + + rows, err := s.bg.Query(ctx, ` + UPDATE inv.devices d + SET identify_requested_at = NULL + WHERE d.id IN ( + SELECT id FROM inv.devices + WHERE identify_requested_at IS NOT NULL + AND agent_id = ANY($1::uuid[]) + AND deleted_at IS NULL + ORDER BY identify_requested_at + FOR UPDATE SKIP LOCKED + LIMIT $2 + ) + RETURNING d.tenant_id::text, d.agent_id::text, d.id::text + `, onlineAgents, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []IdentifyRequest + for rows.Next() { + var r IdentifyRequest + if err := rows.Scan(&r.TenantID, &r.AgentID, &r.DeviceID); err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} diff --git a/server/internal/store/iffilter.go b/server/internal/store/iffilter.go new file mode 100644 index 0000000..7d15162 --- /dev/null +++ b/server/internal/store/iffilter.go @@ -0,0 +1,347 @@ +package store + +import ( + "encoding/json" + "fmt" + "regexp" + "strconv" + "strings" +) + +// InterfaceFilter — фільтр складу портів, заданий шаблоном. +// +// Те саме, що в Zabbix зветься фільтром правила виявлення: пристрій +// віддає сто портів, а опитувати треба десять. Без фільтра snmp.if-чек +// бере все, що пройшло базові правила, і на Juniper QFX це сотня портів +// по десять OID кожен — за один такт. +// +// Порожній фільтр (жодної умови) означає «думки про порти немає», а не +// «нічого не пропускати». Різниця важлива там, де хосту причеплено +// кілька шаблонів: шаблон без фільтра не має права звузити те, що +// просить інший. +type InterfaceFilter struct { + // "all" (типово) — порт мусить задовольнити всі умови, + // "any" — досить однієї. + Match string `json:"match,omitempty"` + Conditions []InterfaceCondition `json:"conditions,omitempty"` +} + +// InterfaceCondition — одна умова фільтра. +type InterfaceCondition struct { + // Поле порту: name (ifName), alias (ifAlias/ifDescr), type, speed. + Field string `json:"field"` + // matches / not_matches — зразок; ge / le — порівняння швидкості. + Op string `json:"op"` + // Зразок або швидкість. Швидкість приймає суфікси: 10G, 100M, 1000K. + Value string `json:"value"` +} + +// Поля й операції винесені в константи, бо їх знають три місця: перевірка +// при збереженні, застосування й форма. Розбіжність між ними — це +// фільтр, який у формі виглядає інакше, ніж працює. +const ( + IfFieldName = "name" + IfFieldAlias = "alias" + IfFieldType = "type" + IfFieldSpeed = "speed" + + IfOpMatches = "matches" + IfOpNotMatches = "not_matches" + IfOpGE = "ge" + IfOpLE = "le" + + IfMatchAll = "all" + IfMatchAny = "any" +) + +// InterfaceCandidate — те, до чого застосовується умова. +// +// Власний тип, а не рядок таблиці: фільтр застосовується і до вмісту +// inv.interfaces, і до попереднього перегляду, і в тестах — усі троє +// мусять судити за однаковими даними. +type InterfaceCandidate struct { + Name string + Alias string + Type string + SpeedBps uint64 +} + +// IsEmpty — чи має цей фільтр хоч якусь думку. +func (f InterfaceFilter) IsEmpty() bool { return len(f.Conditions) == 0 } + +// --------------------------------------------------------------------- +// Компіляція +// --------------------------------------------------------------------- + +// Зразки застосовує Go (RE2), а не Postgres (`~`). Три причини, і +// жодна з них не про швидкість. +// +// Перша: криво написаний зразок. У Postgres він валить ЗАПИТ, а разом +// із ним — транзакцію, всередині якої перебудовується чек. Наслідок: +// snmp.if мовчки перестає оновлюватись на кожному такті автовиявлення, +// і побачити це можна хіба в журналі сервера. У Go зразок компілюється +// окремо й наперед: помилка називає себе там, де її зробили, — у формі +// шаблону. +// +// Друга: RE2 не має відкату. Зразок, який людина напише випадково +// (`(a+)+b` і подібне), у бектрекінговому рушії Postgres здатен з'їсти +// хвилини процесорного часу всередині транзакції, що тримає рядки +// inv.interfaces. RE2 такого не вміє за побудовою. +// +// Третя, найважливіша: попередній перегляд мусить казати правду. +// Перегляд іде через HTTP, тобто через Go. Якби бойове фільтрування +// жило в SQL, два рушії розійшлися б на першому ж `\d` (у POSIX ERE це +// просто літера d) — і людина побачила б у перегляді один склад портів, +// а в чеку отримала інший. +// +// У проєкті вже є місце, де зразки з довідника застосовує саме Go з +// тих самих міркувань — autoassign.go, firstGroup. +type compiledCondition struct { + field string + op string + re *regexp.Regexp + speed uint64 +} + +type compiledFilter struct { + any bool + conds []compiledCondition +} + +// filterCandidate — те, до чого умова прикладається. +// +// Інтерфейс, а не структура порту, з'явився разом із прототипами +// елементів шаблону (0059): рядок динамічної таблиці — диск, сенсор, +// член стека — фільтрують за тією самою потребою й тими самими +// зразками, що й порти, лише поля в нього інші. Другий примірник цієї +// самої механіки розійшовся б із першим на першому ж виправленні, і +// «not_matches» почав би означати різне в двох сусідніх вкладках однієї +// форми. +// +// Числових полів у рядка таблиці немає взагалі — тому numeric віддає +// ok=false, і компіляція просто не пропустить ge/le для такого набору +// полів. +type filterCandidate interface { + text(field string) string + numeric(field string) (uint64, bool) +} + +// fieldSet — які поля має цей вид кандидата. +// +// Перевіряється при КОМПІЛЯЦІЇ, а не при застосуванні: умова на поле, +// якого в кандидата немає, — це друкарська помилка людини, і сказати +// про неї треба у формі, поки вона ще на екрані. Мовчки не збігтись +// означало б фільтр, який ніколи нічого не пропускає, без жодної +// підказки чому. +type fieldSet map[string]bool + +var ifFields = fieldSet{ + IfFieldName: true, IfFieldAlias: true, IfFieldType: true, IfFieldSpeed: true, +} + +// compileInterfaceFilter перетворює опис на щось придатне до застосування. +func compileInterfaceFilter(f InterfaceFilter) (*compiledFilter, error) { + return compileFilter(f, ifFields) +} + +// compileFilter — спільне ядро для портів і для рядків таблиць. +// +// Зразок компілюється з (?i): імена портів у різних вендорів пишуться +// по-різному в тому самому сенсі — Huawei дає `Vlanif10`, інші `vlan10`. +// Фільтр, який пропустив порт через велику літеру, помиляється мовчки, а +// це рівно та помилка, від якої вся ця затія. Кому потрібен регістр — +// пише `(?-i)` на початку зразка. +func compileFilter(f InterfaceFilter, allowed fieldSet) (*compiledFilter, error) { + out := &compiledFilter{any: strings.EqualFold(f.Match, IfMatchAny)} + for i, c := range f.Conditions { + cc := compiledCondition{field: strings.TrimSpace(c.Field), op: strings.TrimSpace(c.Op)} + if !allowed[cc.field] { + return nil, fmt.Errorf("умова %d: невідоме поле «%s»", i+1, c.Field) + } + + switch cc.op { + case IfOpMatches, IfOpNotMatches: + if cc.field == IfFieldSpeed { + return nil, fmt.Errorf("умова %d: швидкість порівнюють числом, а не зразком", i+1) + } + if strings.TrimSpace(c.Value) == "" { + return nil, fmt.Errorf("умова %d: порожній зразок", i+1) + } + re, err := regexp.Compile("(?i)" + c.Value) + if err != nil { + // Текст помилки RE2 віддаємо як є: він називає + // позицію в зразку, а переказ своїми словами лише + // забрав би цю підказку. + return nil, fmt.Errorf("умова %d: зразок не читається: %s", i+1, err) + } + cc.re = re + + case IfOpGE, IfOpLE: + if cc.field != IfFieldSpeed || !allowed[IfFieldSpeed] { + return nil, fmt.Errorf("умова %d: порівняння «більше/менше» є лише для швидкості", i+1) + } + v, err := ParseSpeed(c.Value) + if err != nil { + return nil, fmt.Errorf("умова %d: %w", i+1, err) + } + cc.speed = v + + default: + return nil, fmt.Errorf("умова %d: невідома операція «%s»", i+1, c.Op) + } + + out.conds = append(out.conds, cc) + } + return out, nil +} + +// ValidateInterfaceFilter перевіряє фільтр, нічого не застосовуючи. +// +// Потрібна саме окремо: помилку в зразку треба показати тоді, коли її +// ще видно на екрані, а не через добу серед порожніх графіків. +func ValidateInterfaceFilter(f InterfaceFilter) error { + if f.Match != "" && !strings.EqualFold(f.Match, IfMatchAll) && !strings.EqualFold(f.Match, IfMatchAny) { + return fmt.Errorf("невідоме поєднання умов «%s»: буває «all» або «any»", f.Match) + } + _, err := compileInterfaceFilter(f) + return err +} + +// ParseSpeed читає швидкість із суфіксом. +// +// 10000000000 очима не читається й друкується з помилкою на один нуль +// приблизно завжди; «10G» читається. Множник десятковий, бо саме так +// його рахують у мережах: гігабітний порт — це 1 000 000 000 біт/с. +func ParseSpeed(s string) (uint64, error) { + t := strings.TrimSpace(s) + if t == "" { + return 0, fmt.Errorf("порожня швидкість") + } + mult := uint64(1) + switch last := t[len(t)-1]; last { + case 'k', 'K': + mult, t = 1_000, t[:len(t)-1] + case 'm', 'M': + mult, t = 1_000_000, t[:len(t)-1] + case 'g', 'G': + mult, t = 1_000_000_000, t[:len(t)-1] + case 't', 'T': + mult, t = 1_000_000_000_000, t[:len(t)-1] + } + v, err := strconv.ParseFloat(strings.TrimSpace(t), 64) + if err != nil || v < 0 { + return 0, fmt.Errorf("швидкість «%s» не читається: пишіть 10G, 100M або число біт/с", s) + } + return uint64(v * float64(mult)), nil +} + +// --------------------------------------------------------------------- +// Застосування +// --------------------------------------------------------------------- + +// text і numeric роблять порт придатним до спільного ядра фільтра. +// +// Невідоме поле сюди не доходить: компіляція його вже відсіяла. Тому +// тут немає гілки «інакше помилка» — вона мала б вигляд мовчазного +// порожнього рядка, тобто зразка, який не збігається ні з чим. +func (in InterfaceCandidate) text(field string) string { + switch field { + case IfFieldName: + return in.Name + case IfFieldAlias: + return in.Alias + case IfFieldType: + return in.Type + } + return "" +} + +func (in InterfaceCandidate) numeric(field string) (uint64, bool) { + if field == IfFieldSpeed { + return in.SpeedBps, true + } + return 0, false +} + +func (c compiledCondition) match(in filterCandidate) bool { + if v, ok := in.numeric(c.field); ok { + if c.op == IfOpGE { + return v >= c.speed + } + return v <= c.speed + } + + hit := c.re.MatchString(in.text(c.field)) + if c.op == IfOpNotMatches { + return !hit + } + return hit +} + +func (f *compiledFilter) match(in filterCandidate) bool { + if len(f.conds) == 0 { + return true + } + for _, c := range f.conds { + if f.any { + if c.match(in) { + return true + } + continue + } + if !c.match(in) { + return false + } + } + return !f.any +} + +// interfaceFilterSet — фільтри всіх шаблонів одного хоста. +// +// Порт проходить, якщо його пропустив ХОЧА Б ОДИН фільтр — тобто +// фільтри об'єднуються, а не перетинаються. +// +// Так, бо шаблони в цьому продукті лише ДОдають: автовиявлення чіпляє +// їх само, і другий шаблон не повинен мовчки відібрати порти, які +// просить перший. «OLT: gpon-порти» плюс «Магістраль: 10G» на одному +// ZTE має дати і те, і те; перетин дав би порожньо — і зламав би обидва +// шаблони одночасно, не назвавши винного. +// +// Шаблони без фільтра в наборі не беруть участі: відсутність думки не +// є думкою «усе». +type interfaceFilterSet struct { + // Імена шаблонів, які мають фільтр — для повідомлень про помилку. + names []string + filters []*compiledFilter +} + +func (s *interfaceFilterSet) empty() bool { return s == nil || len(s.filters) == 0 } + +func (s *interfaceFilterSet) match(in filterCandidate) bool { + if s.empty() { + return true + } + for _, f := range s.filters { + if f.match(in) { + return true + } + } + return false +} + +// parseInterfaceFilter розбирає те, що лежить у tpl.templates.if_filter. +// +// Порожньо, "{}" і зіпсований JSON дають фільтр без думки, а не помилку: +// колонка може бути заповнена чим завгодно старішими редакціями продукту +// або руками, і жодна з цих ситуацій не є підставою зупинити опитування +// портів на всьому парку. +func parseInterfaceFilter(raw string) InterfaceFilter { + var f InterfaceFilter + if strings.TrimSpace(raw) == "" { + return f + } + if err := json.Unmarshal([]byte(raw), &f); err != nil { + return InterfaceFilter{} + } + return f +} diff --git a/server/internal/store/iffilter_preview.go b/server/internal/store/iffilter_preview.go new file mode 100644 index 0000000..634ad14 --- /dev/null +++ b/server/internal/store/iffilter_preview.go @@ -0,0 +1,363 @@ +package store + +import ( + "context" + "fmt" + "sort" + "strings" + "time" + + "github.com/jackc/pgx/v5" +) + +// --------------------------------------------------------------------- +// Попередній перегляд +// --------------------------------------------------------------------- + +// InterfaceFilterPort — один порт очима фільтра. +// +// У переліку є й ті порти, які фільтр навіть не розглядав: без них +// відповідь «пройшло 12 зі 100» не сходиться, і людина шукає в зразку +// помилку, якої немає, — а насправді порт вимкнений руками або це +// loopback. +type InterfaceFilterPort struct { + ID string `json:"id"` + IfIndex int64 `json:"if_index"` + Name string `json:"name"` + Alias string `json:"alias,omitempty"` + Type string `json:"type,omitempty"` + SpeedBps uint64 `json:"speed_bps"` + // Пройшов базові правила (monitored, не loopback, не notPresent). + Eligible bool `json:"eligible"` + // Чому не пройшов базові правила — людською мовою. + Skip string `json:"skip,omitempty"` + // Пройшов сам фільтр. + Matched bool `json:"matched"` + // Коли цей порт уже виключили фільтром. Саме це поле відповідає на + // питання «чому графік обірвався третього числа». + FilteredOutAt *time.Time `json:"filtered_out_at,omitempty"` +} + +// InterfaceFilterPreview — що дасть фільтр на конкретному хості. +type InterfaceFilterPreview struct { + DeviceID string `json:"device_id"` + DeviceName string `json:"device_name"` + // Усього портів в інвентарі хоста. + Total int `json:"total"` + // З них пройшли базові правила. + Eligible int `json:"eligible"` + // З них пройшли фільтр. + Matched int `json:"matched"` + // Стеля розміру чека й чи вперлись у неї. + Limit int `json:"limit"` + Truncated bool `json:"truncated"` + // Інші шаблони цього хоста, які теж мають фільтр. Склад чека буде + // ОБ'ЄДНАННЯМ, тож без цієї підказки число нижче виглядало б + // остаточним, а воно не остаточне. + OtherTemplates []string `json:"other_templates,omitempty"` + Ports []InterfaceFilterPort `json:"ports"` +} + +// PreviewInterfaceFilter проганяє фільтр по справжньому хосту, нічого не +// зберігаючи. +// +// Навіщо: зразок, застосований до сотні пристроїв наосліп, або збирає +// не те, або не збирає нічого, і дізнаються про це через добу по +// порожніх графіках. Тут те саме рішення коштує один клік. +// +// templateID — шаблон, який зараз редагують; потрібен лише щоб не +// порахувати його самого серед «інших фільтрів цього хоста». +func (s *Store) PreviewInterfaceFilter(ctx context.Context, tenantID, deviceID, templateID string, f InterfaceFilter) (*InterfaceFilterPreview, error) { + cf, err := compileInterfaceFilter(f) + if err != nil { + return nil, fmt.Errorf("%w: %s", ErrInvalid, err) + } + + out := &InterfaceFilterPreview{ + DeviceID: deviceID, + Limit: MaxInterfacesPerCheck, + Ports: []InterfaceFilterPort{}, + } + + err = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + if err := tx.QueryRow(ctx, ` + SELECT name FROM inv.devices + WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL + `, deviceID, tenantID).Scan(&out.DeviceName); err != nil { + if isNoRows(err) { + return ErrNotFound + } + return err + } + + rows, err := tx.Query(ctx, ` + SELECT id::text, COALESCE(if_index, 0), name, COALESCE(alias,''), + COALESCE(type,''), COALESCE(speed_bps, 0), + monitored, if_index IS NULL, + COALESCE(type,'') = 'softwareLoopback', + oper_status = 'notPresent', + filtered_out_at + FROM inv.interfaces + WHERE device_id = $1 AND tenant_id = $2 + ORDER BY if_index NULLS LAST, name + LIMIT $3 + `, deviceID, tenantID, maxInterfaceScan) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var p InterfaceFilterPort + var monitored, noIndex, loop, gone bool + if err := rows.Scan(&p.ID, &p.IfIndex, &p.Name, &p.Alias, &p.Type, + &p.SpeedBps, &monitored, &noIndex, &loop, &gone, + &p.FilteredOutAt); err != nil { + return err + } + out.Total++ + + // Причина називається одна, найважливіша: перелік із трьох + // причин на одному рядку читають гірше, ніж не читають зовсім. + switch { + case !monitored: + p.Skip = "вимкнено на порту" + case noIndex: + p.Skip = "немає ifIndex" + case loop: + p.Skip = "loopback" + case gone: + p.Skip = "порту немає в шасі" + default: + p.Eligible = true + out.Eligible++ + p.Matched = cf.match(InterfaceCandidate{ + Name: p.Name, Alias: p.Alias, Type: p.Type, SpeedBps: p.SpeedBps, + }) + if p.Matched { + out.Matched++ + } + } + out.Ports = append(out.Ports, p) + } + if err := rows.Err(); err != nil { + return err + } + out.Truncated = out.Matched > MaxInterfacesPerCheck + + // Інші фільтри цього хоста. + orows, err := tx.Query(ctx, ` + SELECT t.name, t.if_filter::text + FROM tpl.device_templates dt + JOIN tpl.templates t ON t.id = dt.template_id + WHERE dt.device_id = $1 AND ($2 = '' OR t.id::text <> $2) + ORDER BY t.name + `, deviceID, templateID) + if err != nil { + return err + } + defer orows.Close() + for orows.Next() { + var name, raw string + if err := orows.Scan(&name, &raw); err != nil { + return err + } + if !parseInterfaceFilter(raw).IsEmpty() { + out.OtherTemplates = append(out.OtherTemplates, name) + } + } + return orows.Err() + }) + if err != nil { + return nil, err + } + return out, nil +} + +// --------------------------------------------------------------------- +// На чому перевіряти +// --------------------------------------------------------------------- + +// InterfaceFilterDevice — хост, придатний для перевірки зразка. +type InterfaceFilterDevice struct { + ID string `json:"id"` + Name string `json:"name"` + Vendor string `json:"vendor,omitempty"` + Model string `json:"model,omitempty"` + Ports int `json:"ports"` + Eligible int `json:"eligible"` + // Чи вже причеплений шаблон, який зараз редагують. + Bound bool `json:"bound"` +} + +// ListInterfaceFilterDevices — хости з портами, найбагатші зверху. +// +// Порядок не алфавітний навмисно: зразок перевіряють на найгіршому +// випадку, і саме стопортовий Juniper має бути першим у списку, а не +// губитись між тими, де портів чотири. +// +// Хости без портів не показуються взагалі: на них перевірити нічого, а +// в переліку вони лише ховають ті, на яких можна. +func (s *Store) ListInterfaceFilterDevices(ctx context.Context, tenantID, templateID string) ([]InterfaceFilterDevice, error) { + out := []InterfaceFilterDevice{} + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, ` + SELECT d.id::text, d.name, COALESCE(d.vendor,''), COALESCE(d.model,''), + count(i.id)::int, + count(i.id) FILTER (WHERE `+ifEligibleSQL+`)::int, + EXISTS (SELECT 1 FROM tpl.device_templates dt + WHERE dt.device_id = d.id AND $1 <> '' + AND dt.template_id::text = $1) + FROM inv.devices d + JOIN inv.interfaces i ON i.device_id = d.id + WHERE d.tenant_id = $2 AND d.deleted_at IS NULL + GROUP BY d.id, d.name, d.vendor, d.model + ORDER BY count(i.id) DESC, d.name + LIMIT 200 + `, templateID, tenantID) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var d InterfaceFilterDevice + if err := rows.Scan(&d.ID, &d.Name, &d.Vendor, &d.Model, + &d.Ports, &d.Eligible, &d.Bound); err != nil { + return err + } + out = append(out, d) + } + return rows.Err() + }) + return out, err +} + +// --------------------------------------------------------------------- +// Захист від фільтра, який не пропускає нічого +// --------------------------------------------------------------------- + +// checkFilterLeavesPorts не дає зберегти шаблон, який зануляє склад +// портів на вже прив'язаному хості. +// +// Питання ставиться не «чи знайшов щось цей фільтр», а «чи лишиться +// хоч один порт у хоста» — тобто рівно те саме об'єднання фільтрів +// усіх його шаблонів, яке потім побудує EnsureInterfaceChecks. +// Інакше два коректні шаблони («тільки gpon» на OLT і «тільки 10G» на +// магістралі) заважали б зберегти один одного. +// +// Хости без жодного придатного порту доказом не є: там і без фільтра +// збирати нічого. +func checkFilterLeavesPorts(ctx context.Context, tx pgx.Tx, templateID string) error { + var raw string + if err := tx.QueryRow(ctx, + `SELECT if_filter::text FROM tpl.templates WHERE id = $1`, templateID).Scan(&raw); err != nil { + return err + } + if parseInterfaceFilter(raw).IsEmpty() { + return nil + } + + // Фільтри всіх шаблонів усіх хостів, яким причеплений цей шаблон, — + // одним запитом. По запиту на хост означало б сотні запитів на одне + // натискання «Зберегти». + type devState struct { + name string + set *interfaceFilterSet + eligible int + matched int + } + devs := map[string]*devState{} + + frows, err := tx.Query(ctx, ` + SELECT dt.device_id::text, d.name, t.name, t.if_filter::text + FROM tpl.device_templates dt + JOIN inv.devices d ON d.id = dt.device_id AND d.deleted_at IS NULL + JOIN tpl.templates t ON t.id = dt.template_id + WHERE dt.device_id IN ( + SELECT device_id FROM tpl.device_templates WHERE template_id = $1 + ) + `, templateID) + if err != nil { + return err + } + for frows.Next() { + var devID, devName, tplName, tplFilter string + if err := frows.Scan(&devID, &devName, &tplName, &tplFilter); err != nil { + frows.Close() + return err + } + d := devs[devID] + if d == nil { + d = &devState{name: devName, set: &interfaceFilterSet{}} + devs[devID] = d + } + f := parseInterfaceFilter(tplFilter) + if f.IsEmpty() { + continue + } + cf, err := compileInterfaceFilter(f) + if err != nil { + frows.Close() + return fmt.Errorf("%w: фільтр шаблону «%s»: %s", ErrInvalid, tplName, err) + } + d.set.names = append(d.set.names, tplName) + d.set.filters = append(d.set.filters, cf) + } + frows.Close() + if err := frows.Err(); err != nil { + return err + } + if len(devs) == 0 { + return nil + } + + irows, err := tx.Query(ctx, ` + SELECT device_id::text, name, COALESCE(alias,''), COALESCE(type,''), + COALESCE(speed_bps, 0) + FROM inv.interfaces + WHERE device_id IN ( + SELECT device_id FROM tpl.device_templates WHERE template_id = $1 + ) + AND `+ifEligibleSQL, templateID) + if err != nil { + return err + } + for irows.Next() { + var devID string + var in InterfaceCandidate + if err := irows.Scan(&devID, &in.Name, &in.Alias, &in.Type, &in.SpeedBps); err != nil { + irows.Close() + return err + } + d := devs[devID] + if d == nil { + continue + } + d.eligible++ + if d.set.match(in) { + d.matched++ + } + } + irows.Close() + if err := irows.Err(); err != nil { + return err + } + + var dead []string + for _, d := range devs { + if !d.set.empty() && d.eligible > 0 && d.matched == 0 { + dead = append(dead, fmt.Sprintf("%s (0 з %d)", d.name, d.eligible)) + } + } + if len(dead) == 0 { + return nil + } + // Назви хостів у самому тексті: «фільтр нічого не пропустив» без + // відповіді «де саме» відправляє людину перебирати парк руками. + sort.Strings(dead) + if len(dead) > 5 { + dead = append(dead[:5], fmt.Sprintf("…та ще %d", len(dead)-5)) + } + return fmt.Errorf("%w на цих хостах: %s. Порожній snmp.if-чек ззовні не відрізняється від справного — виправте зразок або відчепіть шаблон від цих хостів", + ErrInterfaceFilterEmpty, strings.Join(dead, "; ")) +} diff --git a/server/internal/store/iffilter_test.go b/server/internal/store/iffilter_test.go new file mode 100644 index 0000000..ea1b72c --- /dev/null +++ b/server/internal/store/iffilter_test.go @@ -0,0 +1,304 @@ +package store + +import "testing" + +// Порти взято з живого стенду, а не вигадано. +// +// Це принципово: зразок, перевірений на «GigabitEthernet0/1», нічого не +// каже про `xe-0/0/16.0`, `802.1Q Encapsulation Tag 0101` і +// `gpon_1/2/2` — а саме вони становлять більшість того, що приносить +// автовиявлення з цього парку. +var ( + // JUN.QFX-Миронівка: 100 портів, з них по ділу — десяток. + junQFX = []InterfaceCandidate{ + {Name: "lsi", Type: "type150"}, + {Name: "dsc", Type: "type1"}, + {Name: "tap", Type: "type1"}, + {Name: "gre", Type: "tunnel"}, + {Name: "mtun", Type: "tunnel"}, + {Name: "em0", Type: "ethernetCsmacd"}, + {Name: "em0.0", Type: "propVirtual"}, + {Name: "bme0", Type: "ethernetCsmacd"}, + {Name: "irb", Type: "type1"}, + {Name: "vtep", Type: "type1"}, + {Name: "pfe-0/0/0", Type: "type1"}, + {Name: "pfh-0/0/0.16383", Type: "propVirtual"}, + {Name: "gr-0/0/0", Type: "tunnel", SpeedBps: 800_000_000}, + {Name: "xe-0/0/16", Alias: "NAT-NAS-1-1", Type: "ethernetCsmacd", SpeedBps: 10_000_000_000}, + {Name: "xe-0/0/16.0", Type: "propVirtual", SpeedBps: 10_000_000_000}, + {Name: "xe-0/0/10", Alias: "ae0.UPLINK.OMEGA", Type: "ethernetCsmacd", SpeedBps: 10_000_000_000}, + {Name: "xe-0/0/10.0", Type: "propVirtual", SpeedBps: 10_000_000_000}, + {Name: "xe-0/0/7", Alias: "ZTE.VLADISLAVKA", Type: "ethernetCsmacd", SpeedBps: 10_000_000_000}, + {Name: "ae0", Alias: "OMEGA-1250-1254-4051-4057-4061", Type: "ieee8023adLag", SpeedBps: 20_000_000_000}, + {Name: "ae0.0", Type: "propVirtual", SpeedBps: 20_000_000_000}, + {Name: "ae11", Type: "ieee8023adLag"}, + } + + // ПОЛЬОВЕ.7 (D-Link): 28 справжніх портів і 29 «портів», які + // насправді VLAN-и. + polove7 = []InterfaceCandidate{ + {Name: "1/1", Type: "ethernetCsmacd", SpeedBps: 100_000_000}, + {Name: "1/7", Type: "ethernetCsmacd"}, + {Name: "1/28", Type: "ethernetCsmacd", SpeedBps: 1_000_000_000}, + {Name: "802.1Q Encapsulation Tag 0001", Type: "l2vlan"}, + {Name: "802.1Q Encapsulation Tag 0126", Type: "l2vlan"}, + {Name: "System", Type: "type142"}, + } + + // ZTE Миронівка (OLT): дерево gpon плюс три ethernet-аплінки. + zteMyronivka = []InterfaceCandidate{ + {Name: "gpon_1/1/1", Type: "type250", SpeedBps: 2_500_000_000}, + {Name: "gpon_1/2/2", Alias: "Novoukrainka|segment-3|box-5|ZTEGC0DFFE60", Type: "type250", SpeedBps: 2_500_000_000}, + {Name: "gei_1/4/1", Type: "ethernetCsmacd"}, + {Name: "xgei_1/4/2", Type: "ethernetCsmacd", SpeedBps: 10_000_000_000}, + } + + // Леніна.21 (Huawei): опис заповнений у ВСІХ портів і не несе нічого. + lenina21 = []InterfaceCandidate{ + {Name: "NULL0", Alias: "HUAWEI, Quidway Series, NULL0 Interface", Type: "type1"}, + {Name: "Vlanif10", Alias: "HUAWEI, Quidway Series, Vlanif10 Interface", Type: "propVirtual", SpeedBps: 1_000_000_000}, + {Name: "Ethernet0/0/1", Alias: "HUAWEI, Quidway Series, Ethernet0/0/1 Interface", Type: "ethernetCsmacd", SpeedBps: 10_000_000}, + {Name: "GigabitEthernet0/0/1", Alias: "HUAWEI, Quidway Series, GigabitEthernet0/0/1 Interface", Type: "ethernetCsmacd", SpeedBps: 1_000_000_000}, + } +) + +func passed(t *testing.T, f InterfaceFilter, in []InterfaceCandidate) []string { + t.Helper() + cf, err := compileInterfaceFilter(f) + if err != nil { + t.Fatalf("фільтр не компілюється: %v", err) + } + var out []string + for _, c := range in { + if cf.match(c) { + out = append(out, c.Name) + } + } + return out +} + +func eq(t *testing.T, got, want []string) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("пройшло %v, очікували %v", got, want) + } + for i := range got { + if got[i] != want[i] { + t.Fatalf("пройшло %v, очікували %v", got, want) + } + } +} + +// Найпоширеніший випадок на Juniper: фізичні порти є, а їхніх +// `.0`-двійників у чеку бути не має. Лічильники на підінтерфейсі +// повторюють лічильники порту, тобто це рівно вдвічі більше OID заради +// того самого графіка. +func TestFilterJuniperPhysicalOnly(t *testing.T) { + f := InterfaceFilter{ + Match: IfMatchAll, + Conditions: []InterfaceCondition{ + {Field: IfFieldName, Op: IfOpMatches, Value: `^(xe|ge|et|ae)-?\d`}, + {Field: IfFieldName, Op: IfOpNotMatches, Value: `\.\d+$`}, + }, + } + // ae11 у списку не помилка, а знахідка: це заведений, але порожній + // LAG зі швидкістю 0. Зразок за іменем його не відрізняє, і саме + // тому попередній перегляд показує перелік портів, а не лише число: + // таке видно очима за секунду й не видно в жодному зразку. + eq(t, passed(t, f, junQFX), []string{ + "xe-0/0/16", "xe-0/0/10", "xe-0/0/7", "ae0", "ae11", + }) +} + +// «Усе, крім службового» — друге формулювання того самого наміру, і +// воно має давати той самий склад іншим шляхом. На D-Link це єдиний +// спосіб: імена портів там просто «1/1», і зачепитись у них нема за що — +// служебне видно лише за типом. +func TestFilterDLinkDropsVlanPseudoPorts(t *testing.T) { + f := InterfaceFilter{ + Conditions: []InterfaceCondition{ + {Field: IfFieldType, Op: IfOpNotMatches, Value: `^(l2vlan|propVirtual|softwareLoopback)$`}, + {Field: IfFieldName, Op: IfOpNotMatches, Value: `^System$`}, + }, + } + eq(t, passed(t, f, polove7), []string{"1/1", "1/7", "1/28"}) +} + +// Найнадійніша ознака — тип, а не ім'я: його задає RFC 2863, а не +// маркетинг вендора. На повних даних стенду ця одна умова дала +// Juniper 98 → 49, D-Link 57 → 28, Huawei 29 → 26 і ніде не дала нуля. +// +// Ціна теж видна: на OLT вона відсіює gpon-дерева разом зі службовим, +// тому в заготовках сказано, що там потрібна друга умова з «або». +func TestFilterPhysicalAndLagByType(t *testing.T) { + f := InterfaceFilter{ + Conditions: []InterfaceCondition{ + {Field: IfFieldType, Op: IfOpMatches, Value: `^(ethernetCsmacd|ieee8023adLag)$`}, + }, + } + eq(t, passed(t, f, polove7), []string{"1/1", "1/7", "1/28"}) + eq(t, passed(t, f, zteMyronivka), []string{"gei_1/4/1", "xgei_1/4/2"}) + eq(t, passed(t, f, junQFX), []string{ + "em0", "bme0", "xe-0/0/16", "xe-0/0/10", "xe-0/0/7", "ae0", "ae11", + }) +} + +// OLT: цікаві або дерева gpon, або аплінки — «або», а не «і». Із «і» +// той самий набір умов дав би нуль, і саме на цьому місці зразок +// зазвичай і псують. +func TestFilterOltAnyMatchesGponOrUplink(t *testing.T) { + f := InterfaceFilter{ + Match: IfMatchAny, + Conditions: []InterfaceCondition{ + {Field: IfFieldName, Op: IfOpMatches, Value: `^gpon_`}, + {Field: IfFieldSpeed, Op: IfOpGE, Value: "10G"}, + }, + } + eq(t, passed(t, f, zteMyronivka), []string{"gpon_1/1/1", "gpon_1/2/2", "xgei_1/4/2"}) + + all := InterfaceFilter{ + Match: IfMatchAll, + Conditions: f.Conditions, + } + if got := passed(t, all, zteMyronivka); len(got) != 0 { + t.Fatalf("з «і» очікували порожньо, отримали %v", got) + } +} + +// Регістр не має вирішувати. Huawei пише `Vlanif10`, інші вендори — +// `vlan10`; фільтр, який промахнувся через велику літеру, помиляється +// мовчки. +func TestFilterIgnoresCase(t *testing.T) { + f := InterfaceFilter{ + Conditions: []InterfaceCondition{ + {Field: IfFieldName, Op: IfOpNotMatches, Value: `^(vlanif|null0)`}, + }, + } + eq(t, passed(t, f, lenina21), []string{"Ethernet0/0/1", "GigabitEthernet0/0/1"}) + + // Кому потрібен регістр — має спосіб його повернути. + strict := InterfaceFilter{ + Conditions: []InterfaceCondition{ + {Field: IfFieldName, Op: IfOpNotMatches, Value: `(?-i)^(vlanif|null0)`}, + }, + } + if got := passed(t, strict, lenina21); len(got) != 4 { + t.Fatalf("з (?-i) мали пройти всі чотири, пройшло %v", got) + } +} + +// Опис як ознака працює не скрізь: на Huawei його заповнює сама +// прошивка однаковим текстом для всіх портів, тож «тільки з описом» +// там не відсіює нічого. Тест фіксує саме це — щоб ніхто не зробив +// «має опис» типовим фільтром. +func TestFilterAliasUselessOnHuawei(t *testing.T) { + f := InterfaceFilter{ + Conditions: []InterfaceCondition{ + {Field: IfFieldAlias, Op: IfOpMatches, Value: `\S`}, + }, + } + if got := passed(t, f, lenina21); len(got) != len(lenina21) { + t.Fatalf("на Huawei опис має всюди, пройшло %v", got) + } + // А на Juniper це осмислена ознака: опис ставить інженер руками. + if got := passed(t, f, junQFX); len(got) != 4 { + t.Fatalf("на Juniper опис мають чотири порти, пройшло %v", got) + } +} + +// Порожній фільтр — «думки немає», а не «нічого не пропускати». +func TestEmptyFilterPassesEverything(t *testing.T) { + var f InterfaceFilter + if !f.IsEmpty() { + t.Fatal("фільтр без умов має вважатись порожнім") + } + if got := passed(t, f, junQFX); len(got) != len(junQFX) { + t.Fatalf("порожній фільтр відсіяв: пройшло %d із %d", len(got), len(junQFX)) + } +} + +// Фільтри різних шаблонів ОБ'ЄДНУЮТЬСЯ: другий шаблон не має права +// мовчки відібрати порти, які просить перший. +func TestFilterSetUnion(t *testing.T) { + gpon, err := compileInterfaceFilter(InterfaceFilter{ + Conditions: []InterfaceCondition{{Field: IfFieldName, Op: IfOpMatches, Value: `^gpon_`}}, + }) + if err != nil { + t.Fatal(err) + } + uplink, err := compileInterfaceFilter(InterfaceFilter{ + Conditions: []InterfaceCondition{{Field: IfFieldSpeed, Op: IfOpGE, Value: "10G"}}, + }) + if err != nil { + t.Fatal(err) + } + set := &interfaceFilterSet{ + names: []string{"OLT", "Магістраль"}, + filters: []*compiledFilter{gpon, uplink}, + } + + var got []string + for _, c := range zteMyronivka { + if set.match(c) { + got = append(got, c.Name) + } + } + eq(t, got, []string{"gpon_1/1/1", "gpon_1/2/2", "xgei_1/4/2"}) + + // Набір без жодного фільтра пропускає все. + if !(&interfaceFilterSet{}).match(InterfaceCandidate{Name: "будь-що"}) { + t.Fatal("порожній набір фільтрів мусить пропускати все") + } +} + +// Криво написаний зразок мусить назвати себе тут, а не через добу серед +// порожніх графіків. +func TestBrokenPatternRejected(t *testing.T) { + err := ValidateInterfaceFilter(InterfaceFilter{ + Conditions: []InterfaceCondition{{Field: IfFieldName, Op: IfOpMatches, Value: `xe-0/0/[1-`}}, + }) + if err == nil { + t.Fatal("незакрита дужка мала дати помилку") + } + + // І решта помилок форми — теж, кожна своїм текстом. + for _, bad := range []InterfaceFilter{ + {Conditions: []InterfaceCondition{{Field: "colour", Op: IfOpMatches, Value: "x"}}}, + {Conditions: []InterfaceCondition{{Field: IfFieldName, Op: "contains", Value: "x"}}}, + {Conditions: []InterfaceCondition{{Field: IfFieldSpeed, Op: IfOpMatches, Value: "10G"}}}, + {Conditions: []InterfaceCondition{{Field: IfFieldName, Op: IfOpGE, Value: "10G"}}}, + {Conditions: []InterfaceCondition{{Field: IfFieldName, Op: IfOpMatches, Value: " "}}}, + {Conditions: []InterfaceCondition{{Field: IfFieldSpeed, Op: IfOpGE, Value: "швидко"}}}, + {Match: "обидва", Conditions: []InterfaceCondition{{Field: IfFieldName, Op: IfOpMatches, Value: "x"}}}, + } { + if err := ValidateInterfaceFilter(bad); err == nil { + t.Fatalf("прийнято неприйнятне: %+v", bad) + } + } +} + +// Швидкість пишуть суфіксом, бо 10000000000 очима не читається. +func TestParseSpeed(t *testing.T) { + cases := map[string]uint64{ + "10G": 10_000_000_000, + "1g": 1_000_000_000, + "100M": 100_000_000, + "2.5G": 2_500_000_000, + "1000K": 1_000_000, + " 1000000000 ": 1_000_000_000, + } + for in, want := range cases { + got, err := ParseSpeed(in) + if err != nil { + t.Fatalf("%q: %v", in, err) + } + if got != want { + t.Fatalf("%q дало %d, очікували %d", in, got, want) + } + } + for _, bad := range []string{"", "G", "-1G", "десять"} { + if _, err := ParseSpeed(bad); err == nil { + t.Fatalf("%q мало відхилитись", bad) + } + } +} diff --git a/server/internal/store/inventory.go b/server/internal/store/inventory.go index f3f754b..b410714 100644 --- a/server/internal/store/inventory.go +++ b/server/internal/store/inventory.go @@ -2,51 +2,99 @@ package store import ( "context" + "errors" "time" "github.com/jackc/pgx/v5" ) type DeviceSummary struct { - ID string `json:"id"` - Name string `json:"name"` - Address string `json:"address,omitempty"` - Kind string `json:"kind"` - Vendor string `json:"vendor,omitempty"` - Model string `json:"model,omitempty"` - SiteName string `json:"site_name,omitempty"` - Status string `json:"status"` - Enabled bool `json:"enabled"` - LastSeenAt *time.Time `json:"last_seen_at,omitempty"` - AgentID string `json:"agent_id,omitempty"` - IfaceCount int `json:"interface_count"` - GroupIDs []string `json:"group_ids"` + ID string `json:"id"` + Name string `json:"name"` + Address string `json:"address,omitempty"` + Kind string `json:"kind"` + Vendor string `json:"vendor,omitempty"` + Model string `json:"model,omitempty"` + OSVersion string `json:"os_version,omitempty"` + Serial string `json:"serial_number,omitempty"` + // Те, що пристрій сказав про себе сам. На цьому тримається підбір + // шаблонів і профілю збору конфігів, тож людина має бачити, чи + // взагалі є за чим підбирати. + SysObjectID string `json:"sys_object_id,omitempty"` + SysDescr string `json:"sys_descr,omitempty"` + SiteName string `json:"site_name,omitempty"` + Status string `json:"status"` + Enabled bool `json:"enabled"` + LastSeenAt *time.Time `json:"last_seen_at,omitempty"` + AgentID string `json:"agent_id,omitempty"` + IfaceCount int `json:"interface_count"` + GroupIDs []string `json:"group_ids"` // Writable каже інтерфейсу, чи показувати дії. Рахується з груп // користувача, а не з ролі: роль дозволяє редагувати пристрої взагалі, // групи — саме цей. Writable bool `json:"writable"` + // DeletedAt — коли хост прибрали з переліку. Заповнене лише в + // переліку архіву: у звичайному переліку прибраних хостів немає, і + // поле там завжди порожнє. + DeletedAt *time.Time `json:"deleted_at,omitempty"` } +// DeviceView — які саме хости віддавати переліком. +// +// Окремий тип, а не булевий прапорець «показати видалених», і не +// значення за замовчуванням у фільтрі. Причина одна: `deleted_at IS +// NULL` стоїть у півсотні запитів по всьому продукту — у плані +// колектора, у рушії алертів, у мапах, у доборі цілей команд, — і +// жоден із них не має права почати бачити прибрані хости через те, що +// хтось додав параметр із «зручним» типовим значенням. Тому параметр +// звужений до одного місця (перелік хостів), а його нульове значення — +// це рівно колишня поведінка. +type DeviceView string + +const ( + // DeviceViewLive — те, що працює. Порожнє значення означає саме це. + DeviceViewLive DeviceView = "" + // DeviceViewArchived — ЛИШЕ прибрані хости, і нічого крім них. + // + // Не «живі плюс прибрані»: змішаний перелік — це спосіб зробити + // щось не з тим хостом. Архів — окремий екран з окремими діями + // (повернути, добити), і жодна дія над живим хостом там не + // пропонується. + DeviceViewArchived DeviceView = "archived" +) + // ListDevices — інвентар для таблиці в UI. // // Фільтр видимості накладається в самому запиті, а не після вибірки: // відсіювати вже прочитане означало б тягнути з БД чужі рядки й // покладатися на те, що жоден із них не проскочить у відповідь. -func (s *Store) ListDevices(ctx context.Context, tenantID string, sc Scope) ([]DeviceSummary, error) { +func (s *Store) ListDevices(ctx context.Context, tenantID string, sc Scope, + view DeviceView) ([]DeviceSummary, error) { + var out []DeviceSummary + archived := view == DeviceViewArchived err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { rows, err := tx.Query(ctx, ` SELECT d.id::text, d.name, COALESCE(host(d.address), ''), d.kind::text, - COALESCE(d.vendor,''), COALESCE(d.model,''), COALESCE(st.name,''), + COALESCE(d.vendor,''), COALESCE(d.model,''), + COALESCE(d.os_version,''), COALESCE(d.serial_number,''), + COALESCE(d.sys_object_id,''), COALESCE(d.sys_descr,''), + COALESCE(st.name,''), d.status::text, d.enabled, d.last_seen_at, COALESCE(d.agent_id::text,''), - (SELECT count(*) FROM inv.interfaces i WHERE i.device_id = d.id) + (SELECT count(*) FROM inv.interfaces i WHERE i.device_id = d.id), + d.deleted_at FROM inv.devices d LEFT JOIN inv.sites st ON st.id = d.site_id - WHERE d.tenant_id = $1 AND d.deleted_at IS NULL + -- Умова написана так, що при $4 = false вона тотожна + -- колишній "d.deleted_at IS NULL": параметр не розширює + -- вибірку, а перемикає її на другу половину. + WHERE d.tenant_id = $1 + AND (CASE WHEN $4::boolean THEN d.deleted_at IS NOT NULL + ELSE d.deleted_at IS NULL END) AND ($2::boolean OR d.id = ANY($3::uuid[])) ORDER BY d.name - `, tenantID, sc.Unrestricted, nonNilIDs(sc.Readable)) + `, tenantID, sc.Unrestricted, nonNilIDs(sc.Readable), archived) if err != nil { return err } @@ -55,8 +103,9 @@ func (s *Store) ListDevices(ctx context.Context, tenantID string, sc Scope) ([]D for rows.Next() { var d DeviceSummary if err := rows.Scan(&d.ID, &d.Name, &d.Address, &d.Kind, &d.Vendor, &d.Model, + &d.OSVersion, &d.Serial, &d.SysObjectID, &d.SysDescr, &d.SiteName, &d.Status, &d.Enabled, &d.LastSeenAt, &d.AgentID, - &d.IfaceCount); err != nil { + &d.IfaceCount, &d.DeletedAt); err != nil { return err } d.Writable = sc.CanWrite(d.ID) @@ -83,6 +132,48 @@ func (s *Store) ListDevices(ctx context.Context, tenantID string, sc Scope) ([]D return out, nil } +// GetDevice — один хост. +// +// Окремо від ListDevices, бо переліком користуються там, де потрібен +// один рядок: після дії, яка щось у ньому змінила. Тягти весь інвентар +// заради одного поля означало б класти на базу тим більше, чим більший +// у клієнта парк — рівно навпаки до того, як воно має поводитись. +// +// Групи не добираємо: єдиний споживач — оновлення картки після дії, і +// склад груп нею не змінюється. +func (s *Store) GetDevice(ctx context.Context, tenantID, deviceID string, sc Scope) (DeviceSummary, error) { + var d DeviceSummary + + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + return tx.QueryRow(ctx, ` + SELECT d.id::text, d.name, COALESCE(host(d.address), ''), d.kind::text, + COALESCE(d.vendor,''), COALESCE(d.model,''), + COALESCE(d.os_version,''), COALESCE(d.serial_number,''), + COALESCE(d.sys_object_id,''), COALESCE(d.sys_descr,''), + COALESCE(st.name,''), + d.status::text, d.enabled, d.last_seen_at, COALESCE(d.agent_id::text,''), + (SELECT count(*) FROM inv.interfaces i WHERE i.device_id = d.id) + FROM inv.devices d + LEFT JOIN inv.sites st ON st.id = d.site_id + WHERE d.tenant_id = $1 AND d.id = $2 AND d.deleted_at IS NULL + AND ($3::boolean OR d.id = ANY($4::uuid[])) + `, tenantID, deviceID, sc.Unrestricted, nonNilIDs(sc.Readable)).Scan( + &d.ID, &d.Name, &d.Address, &d.Kind, &d.Vendor, &d.Model, + &d.OSVersion, &d.Serial, &d.SysObjectID, &d.SysDescr, + &d.SiteName, &d.Status, &d.Enabled, + &d.LastSeenAt, &d.AgentID, &d.IfaceCount) + }) + if errors.Is(err, pgx.ErrNoRows) { + return d, ErrNotFound + } + if err != nil { + return d, err + } + d.Writable = sc.CanWrite(d.ID) + d.GroupIDs = []string{} + return d, nil +} + type AgentSummary struct { ID string `json:"id"` Name string `json:"name"` @@ -154,11 +245,18 @@ func (s *Store) ListAgents(ctx context.Context, tenantID string) ([]AgentSummary // підписатись на чужу мапу за вгаданим id. func (s *Store) MapExists(ctx context.Context, tenantID, mapID string) (bool, error) { var exists bool - err := s.pool.QueryRow(ctx, ` - SELECT EXISTS ( - SELECT 1 FROM topo.maps - WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL - ) - `, mapID, tenantID).Scan(&exists) + // Через InTenantTx, хоча предикат tenant_id у запиті вже є: під + // netpulse_app topo.maps закрита політикою, і без app.tenant_id + // EXISTS повертав би false на власну мапу кабінету. Тобто підписка + // на мапу зникла б для всіх однаково — це той випадок, коли забутий + // контекст ламає доступ, а не відкриває його. + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + return tx.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM topo.maps + WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL + ) + `, mapID, tenantID).Scan(&exists) + }) return exists, err } diff --git a/server/internal/store/maps.go b/server/internal/store/maps.go index 6540ca0..0e6f343 100644 --- a/server/internal/store/maps.go +++ b/server/internal/store/maps.go @@ -81,11 +81,24 @@ type MapNode struct { Collapsed bool `json:"collapsed"` Locked bool `json:"locked"` + // Адреса хоста. Не для запитів — для підпису на схемі: вузол з + // іменем «ЦЕХ-2» нічого не каже тому, хто прийшов лагодити, а + // 10.0.3.7 під іменем економить перехід у перелік хостів. + Address string `json:"address,omitempty"` + // Живий стан пристрою. Саме це фарбує вузол. Status string `json:"status,omitempty"` LastSeenAt *time.Time `json:"last_seen_at,omitempty"` RttMs *float32 `json:"rtt_ms,omitempty"` LossPct *float32 `json:"loss_pct,omitempty"` + + // Хост видалено (м'яко), а вузол лишився стояти. + // + // Вузол не ховаємо: людина розводила схему руками, і мовчазне + // зникнення вузла разом із лініями, які до нього вели, виглядає як + // поламана мапа. Чесніше показати стан і дати прибрати його одним + // рухом — рішення, що робити зі схемою, за людиною. + DeviceDeleted bool `json:"device_deleted,omitempty"` } type MapEdge struct { @@ -94,6 +107,10 @@ type MapEdge struct { TargetNodeID string `json:"target_node_id"` SourcePort string `json:"source_port,omitempty"` TargetPort string `json:"target_port,omitempty"` + // Порти, задані руками або автопобудовою. Ідентифікатори потрібні + // самій формі: без них вибір порту нічим показати як обраний. + SourceInterfaceID string `json:"source_interface_id,omitempty"` + TargetInterfaceID string `json:"target_interface_id,omitempty"` // До якого боку вузла кріпиться лінія. Без цього після // перезавантаження всі зв'язки перескакують на типові порти, і // розкладка, яку людина розводила руками, розсипається. @@ -109,11 +126,38 @@ type MapEdge struct { Animation json.RawMessage `json:"animation"` Thresholds json.RawMessage `json:"thresholds"` ShowMetrics bool `json:"show_metrics"` + // Подання: що писати на лінії, де саме й чи ставити стрілку. + // Порожньо — рішення за клієнтом (див. 0049_map_edge_view.sql). + View json.RawMessage `json:"view,omitempty"` // Живе завантаження — джерело швидкості анімації. LinkStatus string `json:"link_status,omitempty"` UtilPct *float64 `json:"util_pct,omitempty"` CapacityBps *int64 `json:"capacity_bps,omitempty"` + + // Звірка намальованого з тим, що видно в мережі. + // + // Ребро без link_id — не обов'язково помилка: його могли намалювати + // раніше, ніж зонд обійшов мережу. Але «не привʼязане» і + // «суперечить виявленому» — різні стани, і плутати їх не можна: + // перший лікується прив'язкою, другий — виправленням схеми. + Discovered *DiscoveredLink `json:"discovered,omitempty"` + // Обидва кінці вже мають виявлених сусідів. Разом із порожнім + // Discovered це означає: мережа каже, що ці двоє не з'єднані. + PeersDiscovered bool `json:"peers_discovered,omitempty"` +} + +// DiscoveredLink — виявлений зв'язок, який відповідає намальованій лінії. +// +// Порти вже повернуті в бік ребра (A — це його source), щоб клієнт не +// вгадував орієнтацію лінка: у topo.links сторони нормалізовані за +// ідентифікаторами пристроїв, а не за тим, як людина малювала. +type DiscoveredLink struct { + LinkID string `json:"link_id"` + SourceInterfaceID string `json:"source_interface_id,omitempty"` + TargetInterfaceID string `json:"target_interface_id,omitempty"` + SourcePort string `json:"source_port,omitempty"` + TargetPort string `json:"target_port,omitempty"` } var ErrNotFound = errors.New("не знайдено") @@ -256,8 +300,16 @@ func mapNodes(ctx context.Context, tx pgx.Tx, tenantID, mapID string) ([]MapNode COALESCE(n.device_id::text,''), COALESCE(n.parent_node_id::text,''), n.x, n.y, n.width, n.height, n.z_index, n.style::text, n.data::text, n.collapsed, n.locked, - COALESCE(d.status::text,''), d.last_seen_at, - i.rtt_avg_ms, i.loss_pct + COALESCE(host(d.address), ''), + -- Стан видаленого хоста — «невідомо», а не останній + -- відомий: опитувати його вже нікому, і зелена крапка + -- на схемі означала б живий пристрій, якого немає. + CASE WHEN d.deleted_at IS NOT NULL THEN '' + ELSE COALESCE(d.status::text,'') END, + CASE WHEN d.deleted_at IS NOT NULL THEN NULL ELSE d.last_seen_at END, + CASE WHEN d.deleted_at IS NOT NULL THEN NULL ELSE i.rtt_avg_ms END, + CASE WHEN d.deleted_at IS NOT NULL THEN NULL ELSE i.loss_pct END, + d.deleted_at IS NOT NULL FROM topo.map_nodes n LEFT JOIN inv.devices d ON d.id = n.device_id AND d.tenant_id = $1 LEFT JOIN ts.device_last_icmp i ON i.device_id = n.device_id @@ -274,8 +326,9 @@ func mapNodes(ctx context.Context, tx pgx.Tx, tenantID, mapID string) ([]MapNode var n MapNode if err := rows.Scan(&n.ID, &n.Kind, &n.Label, &n.DeviceID, &n.ParentID, &n.X, &n.Y, &n.Width, &n.Height, &n.ZIndex, - &n.Style, &n.Data, &n.Collapsed, &n.Locked, - &n.Status, &n.LastSeenAt, &n.RttMs, &n.LossPct); err != nil { + &n.Style, &n.Data, &n.Collapsed, &n.Locked, &n.Address, + &n.Status, &n.LastSeenAt, &n.RttMs, &n.LossPct, + &n.DeviceDeleted); err != nil { return nil, err } out = append(out, n) @@ -304,17 +357,66 @@ const linkStatusExpr = ` ELSE 'up' END` +// discoveredMatchExpr — виявлений зв'язок для пари вузлів ребра. +// +// Пара шукається тим самим виразом LEAST/GREATEST, що й унікальний +// індекс links_pair_uniq: у topo.links сторони нормалізовані за +// ідентифікаторами пристроїв, і A→B та B→A — це один рядок. Порти +// одразу розвертаються в бік ребра, щоб клієнту не доводилось вгадувати +// орієнтацію. +// +// Перевірка на NULL обов'язкова: LEAST(NULL, x) у Postgres дорівнює x, +// тож без неї ребро між двома текстовими анотаціями «знайшло» б +// випадковий лінк. +const discoveredMatchExpr = ` + LEFT JOIN LATERAL ( + SELECT l2.id::text AS id, + CASE WHEN l2.a_device_id = ns.device_id + THEN l2.a_interface_id ELSE l2.b_interface_id END AS src_if, + CASE WHEN l2.a_device_id = ns.device_id + THEN l2.b_interface_id ELSE l2.a_interface_id END AS dst_if + FROM topo.links l2 + WHERE l2.tenant_id = $1 + AND ns.device_id IS NOT NULL AND nt.device_id IS NOT NULL + AND LEAST(l2.a_device_id, l2.b_device_id) = LEAST(ns.device_id, nt.device_id) + AND GREATEST(l2.a_device_id, l2.b_device_id) = GREATEST(ns.device_id, nt.device_id) + ORDER BY l2.confidence DESC, l2.last_seen_at DESC + LIMIT 1 + ) dl ON e.link_id IS NULL` + func mapEdges(ctx context.Context, tx pgx.Tx, tenantID, mapID string) ([]MapEdge, error) { rows, err := tx.Query(ctx, ` SELECT e.id::text, e.source_node_id::text, e.target_node_id::text, COALESCE(si.name,''), COALESCE(ti.name,''), + COALESCE(e.source_interface_id::text,''), COALESCE(e.target_interface_id::text,''), COALESCE(e.source_handle,''), COALESCE(e.target_handle,''), COALESCE(e.link_id::text,''), COALESCE(e.label,''), e.style::text, e.dash::text, COALESCE(e.color,''), e.width_px, e.waypoints::text, e.animation::text, e.thresholds::text, e.show_metrics, + COALESCE(e.view::text,''), CASE WHEN l.id IS NULL THEN '' ELSE `+linkStatusExpr+` END, - lv.util_pct, l.capacity_bps + lv.util_pct, + -- Без лінка швидкість каналу все одно відома: її дає + -- повільніший з двох обраних портів. Інакше лінія з + -- руками зіставленими портами лишалась би без підпису, + -- хоча все потрібне для нього вже є в інвентарі. + COALESCE(l.capacity_bps, LEAST(si.speed_bps, ti.speed_bps)), + COALESCE(dl.id,''), COALESCE(dl.src_if::text,''), COALESCE(dl.dst_if::text,''), + COALESCE(dsi.name,''), COALESCE(dti.name,''), + EXISTS ( + SELECT 1 FROM topo.links lp + WHERE lp.tenant_id = $1 AND lp.discovered_by <> 'manual' + AND ns.device_id IS NOT NULL + AND (lp.a_device_id = ns.device_id OR lp.b_device_id = ns.device_id) + ) AND EXISTS ( + SELECT 1 FROM topo.links lp + WHERE lp.tenant_id = $1 AND lp.discovered_by <> 'manual' + AND nt.device_id IS NOT NULL + AND (lp.a_device_id = nt.device_id OR lp.b_device_id = nt.device_id) + ) FROM topo.map_edges e + JOIN topo.map_nodes ns ON ns.id = e.source_node_id + JOIN topo.map_nodes nt ON nt.id = e.target_node_id LEFT JOIN inv.interfaces si ON si.id = e.source_interface_id LEFT JOIN inv.interfaces ti ON ti.id = e.target_interface_id LEFT JOIN topo.links l ON l.id = e.link_id @@ -322,7 +424,10 @@ func mapEdges(ctx context.Context, tx pgx.Tx, tenantID, mapID string) ([]MapEdge LEFT JOIN inv.devices db ON db.id = l.b_device_id LEFT JOIN inv.interfaces ia ON ia.id = l.a_interface_id LEFT JOIN inv.interfaces ib ON ib.id = l.b_interface_id - LEFT JOIN topo.link_live lv ON lv.link_id = e.link_id + LEFT JOIN topo.link_live lv ON lv.link_id = e.link_id`+ + discoveredMatchExpr+` + LEFT JOIN inv.interfaces dsi ON dsi.id = dl.src_if + LEFT JOIN inv.interfaces dti ON dti.id = dl.dst_if WHERE e.map_id = $2 AND e.tenant_id = $1 AND NOT e.hidden ORDER BY e.z_index, e.id `, tenantID, mapID) @@ -333,15 +438,35 @@ func mapEdges(ctx context.Context, tx pgx.Tx, tenantID, mapID string) ([]MapEdge out := []MapEdge{} for rows.Next() { - var e MapEdge + var ( + e MapEdge + view string + dLink string + dSrcIf, dDstIf string + dSrcPort, dDstPort string + ) if err := rows.Scan(&e.ID, &e.SourceNodeID, &e.TargetNodeID, - &e.SourcePort, &e.TargetPort, &e.SourceHandle, &e.TargetHandle, + &e.SourcePort, &e.TargetPort, + &e.SourceInterfaceID, &e.TargetInterfaceID, + &e.SourceHandle, &e.TargetHandle, &e.LinkID, &e.Label, &e.Style, &e.Dash, &e.Color, &e.WidthPx, - &e.Waypoints, &e.Animation, &e.Thresholds, &e.ShowMetrics, - &e.LinkStatus, &e.UtilPct, &e.CapacityBps); err != nil { + &e.Waypoints, &e.Animation, &e.Thresholds, &e.ShowMetrics, &view, + &e.LinkStatus, &e.UtilPct, &e.CapacityBps, + &dLink, &dSrcIf, &dDstIf, &dSrcPort, &dDstPort, + &e.PeersDiscovered); err != nil { return nil, err } + e.View = rawOrNil(view) + if dLink != "" { + e.Discovered = &DiscoveredLink{ + LinkID: dLink, + SourceInterfaceID: dSrcIf, + TargetInterfaceID: dDstIf, + SourcePort: dSrcPort, + TargetPort: dDstPort, + } + } out = append(out, e) } return out, rows.Err() diff --git a/server/internal/store/maps_topology.go b/server/internal/store/maps_topology.go new file mode 100644 index 0000000..29e6160 --- /dev/null +++ b/server/internal/store/maps_topology.go @@ -0,0 +1,272 @@ +package store + +import ( + "context" + "errors" + "time" + + "github.com/jackc/pgx/v5" +) + +// Тут живе те, чого мапі бракувало, щоб перестати бути малюнком: +// перелік портів для ручного зіставлення й прохання зібрати топологію +// з хостів, які на цій мапі стоять. + +// DiscoverCheckType — повне автовиявлення: сусіди плюс інвентар портів. +const DiscoverCheckType = "topology.discover" + +// Параметри повного обходу. +// +// Лише LLDP і CDP, хоч модуль уміє ще ARP і FDB. Причина не в трафіку: +// ARP-таблиця маршрутизатора містить усі хости підмережі, і кожен +// резолвлений сусід став би «лінком» — на мапі це десятки ліній, яких +// фізично немає. ARP і FDB корисні як доповнення там, де LLDP вимкнено, +// і вмикати їх має людина свідомо, у формі перевірок хоста. +const discoverCheckParams = `{"protos":["lldp","cdp"],"collect_interfaces":true}` + +// Раз на годину. Топологія змінюється рідко, а обхід ifTable на +// комутаторі з сотнями портів — тисячі PDU: частіше означало б +// вантажити обладнання заради даних, які й так не змінились. +const discoverCheckInterval = time.Hour + +// Обхід усієї таблиці інтерфейсів не вкладається в типові три секунди. +const discoverCheckTimeoutMs = 30000 + +// MapInterface — порт хоста, який стоїть на мапі. +type MapInterface struct { + ID string `json:"id"` + DeviceID string `json:"device_id"` + Name string `json:"name"` + Alias string `json:"alias,omitempty"` + SpeedBps *int64 `json:"speed_bps,omitempty"` + OperStatus string `json:"oper_status,omitempty"` +} + +// MapInterfaces віддає порти всіх пристроїв мапи одним запитом. +// +// Одним, а не по вузлу: інспектор ребра відкривають кліком, і +// дозапитувати два хости на кожен клік означало б показувати порожні +// списки рівно ту секунду, поки людина в них дивиться. +func (s *Store) MapInterfaces(ctx context.Context, tenantID, mapID string) ([]MapInterface, error) { + var out []MapInterface + + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, ` + SELECT i.id::text, i.device_id::text, i.name, COALESCE(i.alias,''), + i.speed_bps, i.oper_status::text + FROM inv.interfaces i + WHERE i.tenant_id = $1 + AND i.device_id IN ( + SELECT n.device_id FROM topo.map_nodes n + WHERE n.map_id = $2 AND n.tenant_id = $1 AND n.device_id IS NOT NULL + ) + ORDER BY i.device_id, i.if_index NULLS LAST, i.name + `, tenantID, mapID) + if err != nil { + return err + } + defer rows.Close() + + out = []MapInterface{} + for rows.Next() { + var i MapInterface + if err := rows.Scan(&i.ID, &i.DeviceID, &i.Name, &i.Alias, + &i.SpeedBps, &i.OperStatus); err != nil { + return err + } + out = append(out, i) + } + return rows.Err() + }) + + return out, err +} + +// MapDiscoveryResult — підсумок «зібрати топологію». +type MapDiscoveryResult struct { + // Скільки хостів на мапі взагалі. + Devices int `json:"devices"` + // Скільком поставлено прохання опитати зараз. + Queued int `json:"queued"` + // Скільком автовиявлення завелося щойно (раніше його не було). + Enabled int `json:"enabled"` + // Зонд не на зв'язку: прохання лишається й спрацює, коли він + // повернеться. + Waiting int `json:"waiting"` + // Кого пропустили й чому — готовим для показу текстом. + Skipped []MapDiscoverySkip `json:"skipped,omitempty"` +} + +// MapDiscoverySkip — хост, якому автовиявлення не завести. +type MapDiscoverySkip struct { + Device string `json:"device"` + Reason string `json:"reason"` +} + +// RequestMapDiscovery заводить і зрушує повне автовиявлення на хостах мапи. +// +// Навіщо окрема дія. Мапа приречена лишатись малюнком, доки +// `topology.discover` не заведений на кожному хості руками: без нього в +// системі немає ні сусідів, ні інвентарю портів, а отже ні лінків, ні +// підписів на лініях. Місце для цієї дії — сторінка мапи: саме там +// людина бачить, що зв'язків немає, і саме там питає «чому». +// +// Механізм — той самий, яким ходить «Розпізнати зараз» (див. +// RequestIdentify): REST лишає намір у базі, диспетчер колектора його +// забирає й надсилає живій сесії DiscoveryRequest. Зонд зрушує задачі з +// власного розкладу за префіксом `topology.`, тому чек мусить існувати +// ДО поштовху — інакше прохання не влучає нікуди. +// +// Розпізнавання підвищується до повного обходу, а не додається поруч: +// обидва чеки знімають ту саму системну групу, і два чеки одного модуля +// лише подвоїли б трафік. Зворотної дії тут немає навмисно — прибрати +// автовиявлення можна у формі перевірок хоста, де видно всі його чеки. +func (s *Store) RequestMapDiscovery(ctx context.Context, tenantID, mapID string, sc Scope) (*MapDiscoveryResult, error) { + res := &MapDiscoveryResult{} + + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + var exists bool + if err := tx.QueryRow(ctx, ` + SELECT true FROM topo.maps + WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL + `, mapID, tenantID).Scan(&exists); errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + + type candidate struct { + id string + name string + hasAgent bool + agentOnline bool + hasSNMP bool + } + + rows, err := tx.Query(ctx, ` + SELECT DISTINCT d.id::text, d.name, + d.agent_id IS NOT NULL, + COALESCE(a.status::text = 'online', false), + EXISTS ( + SELECT 1 FROM inv.device_credentials dc + JOIN inv.credentials c ON c.id = dc.credential_id + WHERE dc.device_id = d.id + AND c.tenant_id = d.tenant_id + AND c.proto IN ('snmp_v2c','snmp_v3') + ) + FROM topo.map_nodes n + JOIN inv.devices d ON d.id = n.device_id + LEFT JOIN core.agents a ON a.id = d.agent_id + WHERE n.map_id = $1 AND n.tenant_id = $2 + AND d.tenant_id = $2 AND d.deleted_at IS NULL AND d.enabled + ORDER BY d.name + `, mapID, tenantID) + if err != nil { + return err + } + + var cands []candidate + for rows.Next() { + var c candidate + if err := rows.Scan(&c.id, &c.name, &c.hasAgent, &c.agentOnline, &c.hasSNMP); err != nil { + rows.Close() + return err + } + cands = append(cands, c) + } + rows.Close() + if err := rows.Err(); err != nil { + return err + } + + res.Devices = len(cands) + + for _, c := range cands { + switch { + case !sc.CanWrite(c.id): + res.Skipped = append(res.Skipped, MapDiscoverySkip{c.name, + "немає доступу на запис до цього хоста"}) + continue + case !c.hasSNMP: + res.Skipped = append(res.Skipped, MapDiscoverySkip{c.name, + "немає SNMP-доступу — сусідів нема чим питати"}) + continue + case !c.hasAgent: + res.Skipped = append(res.Skipped, MapDiscoverySkip{c.name, + "не прив'язаний до зонда — нікому його опитати"}) + continue + } + + // Спершу підвищуємо розпізнавання: воно вже є майже в + // кожного хоста зі SNMP, і другий чек того самого модуля + // був би подвоєним трафіком. + tag, err := tx.Exec(ctx, ` + UPDATE core.checks ch + SET check_type = $3, + params = $4::jsonb, + interval_sec = $5, + timeout_ms = GREATEST(ch.timeout_ms, $6), + enabled = true, + updated_at = now() + WHERE ch.tenant_id = $2 AND ch.device_id = $1 + AND ch.check_type = $7 + AND NOT EXISTS ( + SELECT 1 FROM core.checks c2 + WHERE c2.tenant_id = $2 AND c2.device_id = $1 + AND c2.check_type = $3 + ) + `, c.id, tenantID, DiscoverCheckType, discoverCheckParams, + int32(discoverCheckInterval/time.Second), int32(discoverCheckTimeoutMs), + IdentifyCheckType) + if err != nil { + return err + } + made := tag.RowsAffected() + + // Хоста без розпізнавання (наприклад, заведеного до появи + // автоматики) заводимо з нуля. + tag, err = tx.Exec(ctx, ` + INSERT INTO core.checks + (tenant_id, device_id, check_type, params, interval_sec, timeout_ms, retries) + SELECT $2, $1, $3, $4::jsonb, $5, $6, 1 + WHERE NOT EXISTS ( + SELECT 1 FROM core.checks ch + WHERE ch.tenant_id = $2 AND ch.device_id = $1 + AND ch.check_type = $3 + ) + `, c.id, tenantID, DiscoverCheckType, discoverCheckParams, + int32(discoverCheckInterval/time.Second), int32(discoverCheckTimeoutMs)) + if err != nil { + return err + } + made += tag.RowsAffected() + if made > 0 { + res.Enabled++ + } + + // Поштовх. Колонка називається identify_requested_at, але + // несе саме «опитай цей хост зараз»: диспетчер шле + // DiscoveryRequest, а зонд зрушує за ним усі задачі модуля + // topology — і розпізнавання, і повний обхід. + if _, err := tx.Exec(ctx, ` + UPDATE inv.devices + SET identify_requested_at = now(), updated_at = now() + WHERE id = $1 AND tenant_id = $2 + `, c.id, tenantID); err != nil { + return err + } + + res.Queued++ + if !c.agentOnline { + res.Waiting++ + } + } + + return nil + }) + + if err != nil { + return nil, err + } + return res, nil +} diff --git a/server/internal/store/maps_write.go b/server/internal/store/maps_write.go index c1623cf..ecb8509 100644 --- a/server/internal/store/maps_write.go +++ b/server/internal/store/maps_write.go @@ -105,9 +105,13 @@ type EdgeInput struct { Animation json.RawMessage `json:"animation,omitempty"` Thresholds json.RawMessage `json:"thresholds,omitempty"` ShowMetrics *bool `json:"show_metrics,omitempty"` - ZIndex *int `json:"z_index,omitempty"` - Locked *bool `json:"locked,omitempty"` - Hidden *bool `json:"hidden,omitempty"` + // Подання лінії. Приходить цілим об'єктом, як і style у вузла: + // зливати частковий jsonb на боці БД означало б завести другий, + // невидимий у типах спосіб задати те саме поле. + View json.RawMessage `json:"view,omitempty"` + ZIndex *int `json:"z_index,omitempty"` + Locked *bool `json:"locked,omitempty"` + Hidden *bool `json:"hidden,omitempty"` } type BackgroundPatch struct { @@ -366,7 +370,7 @@ func applyEdges(ctx context.Context, tx pgx.Tx, tenantID, mapID string, p *EdgeP source_interface_id, target_interface_id, link_id, label, style, dash, color, width_px, waypoints, animation, thresholds, show_metrics, z_index, locked, hidden, - source_handle, target_handle) + source_handle, target_handle, view) VALUES ($1,$2,$3,$4,$5,$6,$7,$8, COALESCE($9::topo.edge_style,'smoothstep'), COALESCE($10::topo.edge_dash,'solid'), @@ -376,14 +380,14 @@ func applyEdges(ctx context.Context, tx pgx.Tx, tenantID, mapID string, p *EdgeP COALESCE($15::jsonb,'{}'::jsonb), COALESCE($16,true), COALESCE($17,0), COALESCE($18,false), COALESCE($19,false), - NULLIF($20,''), NULLIF($21,'')) + NULLIF($20,''), NULLIF($21,''), $22::jsonb) RETURNING id::text `, tenantID, mapID, src, dst, - nullUUID(e.SourceInterfaceID), nullUUID(e.TargetInterfaceID), - nullUUID(e.LinkID), e.Label, e.Style, e.Dash, e.Color, e.WidthPx, + clearableUUID(e.SourceInterfaceID), clearableUUID(e.TargetInterfaceID), + clearableUUID(e.LinkID), e.Label, e.Style, e.Dash, e.Color, e.WidthPx, rawArg(e.Waypoints), rawArg(e.Animation), rawArg(e.Thresholds), e.ShowMetrics, e.ZIndex, e.Locked, e.Hidden, - e.SourceHandle, e.TargetHandle).Scan(&id); err != nil { + e.SourceHandle, e.TargetHandle, rawArg(e.View)).Scan(&id); err != nil { return err } if e.ClientID != "" { @@ -406,9 +410,23 @@ func applyEdges(ctx context.Context, tx pgx.Tx, tenantID, mapID string, p *EdgeP z_index = COALESCE($13, z_index), locked = COALESCE($14, locked), hidden = COALESCE($15, hidden), - source_interface_id = COALESCE($16, source_interface_id), - target_interface_id = COALESCE($17, target_interface_id), - link_id = COALESCE($18, link_id), + -- Порти й прив'язка до лінка міняються трьома станами, + -- як і бік підключення: не передали — не чіпати, + -- 'none' — зняти, uuid — поставити. Без зняття + -- помилково обраний порт лишався б на ребрі назавжди: + -- COALESCE не відрізняє «прибери» від «не передавали». + -- + -- Ознака «зняти» їде окремим булевим параметром, а не + -- рядком 'none' у тому ж полі. Причина в планувальнику: + -- на CASE $16 WHEN '' ... ELSE $16::uuid він згортає + -- константи ще до виконання й падає на порожньому рядку + -- в гілці, до якої черга не дійшла б. + source_interface_id = CASE WHEN $21 THEN NULL + ELSE COALESCE($16, source_interface_id) END, + target_interface_id = CASE WHEN $22 THEN NULL + ELSE COALESCE($17, target_interface_id) END, + link_id = CASE WHEN $23 THEN NULL + ELSE COALESCE($18, link_id) END, -- Бік підключення міняється трьома станами, а не двома: -- порожньо — не чіпати, 'auto' — зняти привʼязку й -- віддати вибік боку полотну, решта — конкретний бік. @@ -418,13 +436,17 @@ func applyEdges(ctx context.Context, tx pgx.Tx, tenantID, mapID string, p *EdgeP WHEN 'auto' THEN NULL ELSE $19 END, target_handle = CASE $20 WHEN '' THEN target_handle WHEN 'auto' THEN NULL ELSE $20 END, + view = COALESCE($24::jsonb, view), updated_at = now() WHERE id = $1 AND map_id = $2 AND tenant_id = $3 `, e.ID, mapID, tenantID, e.Label, e.Style, e.Dash, e.Color, e.WidthPx, rawArg(e.Waypoints), rawArg(e.Animation), rawArg(e.Thresholds), e.ShowMetrics, e.ZIndex, e.Locked, e.Hidden, - nullUUID(e.SourceInterfaceID), nullUUID(e.TargetInterfaceID), nullUUID(e.LinkID), - e.SourceHandle, e.TargetHandle) + clearableUUID(e.SourceInterfaceID), clearableUUID(e.TargetInterfaceID), + clearableUUID(e.LinkID), + e.SourceHandle, e.TargetHandle, + e.SourceInterfaceID == clearRef, e.TargetInterfaceID == clearRef, + e.LinkID == clearRef, rawArg(e.View)) if err != nil { return err } @@ -535,9 +557,13 @@ func snapshotMap(ctx context.Context, tx pgx.Tx, tenantID, mapID string, revisio // --------------------------------------------------------------------- type BuildStats struct { - NodesAdded int `json:"nodes_added"` - EdgesAdded int `json:"edges_added"` - Revision int64 `json:"revision"` + NodesAdded int `json:"nodes_added"` + EdgesAdded int `json:"edges_added"` + // Скільки намальованих руками ліній привʼязалось до виявлених + // лінків. Окремо від доданих: для людини це різні події — «зʼявилось + // нове» і «те, що я малював, нарешті звірено з мережею». + EdgesLinked int `json:"edges_linked"` + Revision int64 `json:"revision"` } // BuildFromTopology доповнює мапу тим, що виявив агент. @@ -597,6 +623,55 @@ func (s *Store) BuildFromTopology(ctx context.Context, tenantID, mapID, authorID } st.NodesAdded = int(tag.RowsAffected()) + // Спершу привʼязуємо те, що вже намальовано руками. + // + // Без цього кроку автопобудова клала поверх ручної лінії другу, + // свою: між тими самими вузлами зʼявлялись дві паралельні + // лінії, з яких «жива» лише одна. Людина малювала звʼязок ще до + // того, як зонд його побачив, — і за це отримувала дубль. + // + // DISTINCT ON: між парою пристроїв може бути кілька лінків + // (LAG, друге волокно). Ручній лінії дістається найвпевненіший, + // решта поїде окремими ребрами нижче. + tag, err = tx.Exec(ctx, ` + WITH matched AS ( + SELECT DISTINCT ON (e.id) + e.id AS edge_id, l.id AS link_id, + CASE WHEN l.a_device_id = ns.device_id + THEN l.a_interface_id ELSE l.b_interface_id END AS src_if, + CASE WHEN l.a_device_id = ns.device_id + THEN l.b_interface_id ELSE l.a_interface_id END AS dst_if + FROM topo.map_edges e + JOIN topo.map_nodes ns ON ns.id = e.source_node_id + JOIN topo.map_nodes nt ON nt.id = e.target_node_id + JOIN topo.links l ON l.tenant_id = $2 + AND ns.device_id IS NOT NULL AND nt.device_id IS NOT NULL + AND LEAST(l.a_device_id, l.b_device_id) + = LEAST(ns.device_id, nt.device_id) + AND GREATEST(l.a_device_id, l.b_device_id) + = GREATEST(ns.device_id, nt.device_id) + WHERE e.map_id = $1 AND e.tenant_id = $2 AND e.link_id IS NULL + AND NOT EXISTS ( + SELECT 1 FROM topo.map_edges e2 + WHERE e2.map_id = $1 AND e2.link_id = l.id + ) + ORDER BY e.id, l.confidence DESC, l.last_seen_at DESC + ) + UPDATE topo.map_edges e + SET link_id = m.link_id, + -- Обраний людиною порт не чіпаємо: вона могла знати + -- більше за LLDP. + source_interface_id = COALESCE(e.source_interface_id, m.src_if), + target_interface_id = COALESCE(e.target_interface_id, m.dst_if), + updated_at = now() + FROM matched m + WHERE e.id = m.edge_id + `, mapID, tenantID) + if err != nil { + return err + } + st.EdgesLinked = int(tag.RowsAffected()) + // Ребра для лінків, обидва кінці яких уже на мапі. tag, err = tx.Exec(ctx, ` INSERT INTO topo.map_edges @@ -620,7 +695,7 @@ func (s *Store) BuildFromTopology(ctx context.Context, tenantID, mapID, authorID } st.EdgesAdded = int(tag.RowsAffected()) - if st.NodesAdded == 0 && st.EdgesAdded == 0 { + if st.NodesAdded == 0 && st.EdgesAdded == 0 && st.EdgesLinked == 0 { st.Revision = revision return nil } @@ -634,7 +709,8 @@ func (s *Store) BuildFromTopology(ctx context.Context, tenantID, mapID, authorID } return snapshotMap(ctx, tx, tenantID, mapID, st.Revision, authorID, - fmt.Sprintf("автопобудова: +%d вузлів, +%d ребер", st.NodesAdded, st.EdgesAdded)) + fmt.Sprintf("автопобудова: +%d вузлів, +%d ребер, звірено %d", + st.NodesAdded, st.EdgesAdded, st.EdgesLinked)) }) if err != nil { @@ -645,6 +721,19 @@ func (s *Store) BuildFromTopology(ctx context.Context, tenantID, mapID, authorID // --------------------------------------------------------------------- +// clearRef — «зняти привʼязку» в полях, де порожнє значення вже +// означає «не чіпати». +const clearRef = "none" + +// clearableUUID прибирає службове 'none' зі значення, яке їде в колонку +// uuid: сам намір «зняти» передається окремим прапорцем. +func clearableUUID(v string) any { + if v == clearRef { + return nil + } + return nullUUID(v) +} + // rawArg перетворює порожній json.RawMessage на NULL, щоб COALESCE у // запиті лишив старе значення. func rawArg(m json.RawMessage) any { diff --git a/server/internal/store/metrics_devices.go b/server/internal/store/metrics_devices.go new file mode 100644 index 0000000..54a2ac1 --- /dev/null +++ b/server/internal/store/metrics_devices.go @@ -0,0 +1,172 @@ +package store + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +// Добір хостів для сторінки «Метрики». +// +// Навіщо окремий добір. Сторінка досі вивантажувала весь інвентар і +// клала його в один ` setKeepVersions(e.target.value)} + /> + + + setKeepDays(e.target.value)} + /> + + + + {ret && ( +
    +

    + Зараз у сховищі {ret.total_versions} {plural(ret.total_versions, ['версія', 'версії', 'версій'])} на{' '} + {fmtBytes(ret.total_bytes)}.{' '} + {ret.protected_latest > 0 && ( + <> + З них {ret.protected_latest} {plural(ret.protected_latest, ['версія', 'версії', 'версій'])} захищено як + останні — вони не видаляються ніколи. + + )} +

    + {/* Обіцянка «звільниться стільки-то» цінна саме ДО + натискання. Рахує її сервер тими самими умовами, що + й нічний прохід, — щоб форма й прибиральник не + розповідали дві різні історії. */} +

    0 ? 'text-amber-300' : ''}> + За чинними числами під видалення підпадає{' '} + {ret.candidates} {plural(ret.candidates, ['версія', 'версії', 'версій'])} ({fmtBytes(ret.candidate_bytes)}). + {ret.candidates > 0 && ' Перерахується після збереження.'} +

    + {ret.last_run_at && ( +

    + Останній прохід: {new Date(ret.last_run_at).toLocaleString('uk-UA')}, прибрано{' '} + {ret.last_deleted}. +

    + )} +
    + )} + + {/* Межа, про яку треба сказати вголос: тіла лежать і в Git, + і прибирання їх звідти не чіпає. Людина, яка вмикає + очистку заради місця на диску бази, має розуміти, що + репозиторій продовжить рости. */} +

    + Очистка звільняє місце в базі. Історія в Git-репозиторії лишається недоторканою — саме + для того її й заводили. +

    + + {err}
    @@ -152,6 +252,17 @@ export function BackupDefaultsForm({ credential_id: credID, apply_to_all: applyToAll, }) + // Політика зберігається другим запитом і ПІСЛЯ + // розкладу: якщо не пройде вона, розклад уже + // збережено, і людині лишається виправити лише числа. + // Зворотний порядок лишав би ввімкнену очистку при + // незбереженому розкладі — гірший із двох половинчастих + // станів. + await api.setRetention({ + enabled: retOn, + versions: Number(keepVersions) || 20, + days: Number(keepDays) || 180, + }) onSaved() } catch (e) { setErr(e instanceof Error ? e.message : String(e)) @@ -168,3 +279,16 @@ export function BackupDefaultsForm({ ) } + +/** + * Розмір людськими одиницями. + * + * Двійкові кратні (1024), бо йдеться про місце в базі, а не про + * маркетингову цифру на коробці диска. + */ +export function fmtBytes(n: number): string { + if (n < 1024) return `${n} Б` + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} КБ` + if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} МБ` + return `${(n / 1024 / 1024 / 1024).toFixed(2)} ГБ` +} diff --git a/web/src/components/BackupPolicyForm.tsx b/web/src/components/BackupPolicyForm.tsx index 63f8753..e0d294d 100644 --- a/web/src/components/BackupPolicyForm.tsx +++ b/web/src/components/BackupPolicyForm.tsx @@ -1,14 +1,40 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' +import type { MutableRefObject } from 'react' import { api } from '../api/client' import { Button, ErrorNote, Field, Modal, Toggle, inputClass } from './ui' import { CronPicker } from './CronPicker' import type { BackupPolicy, Credential, DeviceSummary, NcmProfile } from '../types' +const DEFAULT_CRON = '0 3 * * *' + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) + +/** + * Пояснення до «— автоматично —». + * + * Підказка мусить казати не «як воно працює взагалі», а що буде саме + * з цим хостом. Різниця між «підберу за SNMP» і «підбирати нема за + * чим» — це різниця між налаштованим хостом і мовчазною помилкою за + * добу, коли спрацює розклад. + */ +function autoHint(d: DeviceSummary): string { + if (d.sys_object_id) { + const first = (d.sys_descr ?? '').split(/\r?\n/)[0].trim() + const what = first !== '' ? first.slice(0, 60) : d.sys_object_id + return `Розпізнано по SNMP: ${what} — профіль підбереться сам` + } + if (d.vendor) { + return `Хост ще не опитано по SNMP — профіль підбереться за виробником «${d.vendor}»` + } + return 'Хост не розпізнано і виробник не заданий — дайте хосту SNMP-доступ або оберіть профіль тут' +} + /** * Розклад резервного копіювання хоста. * - * Профіль можна не задавати: сервер підбере його за виробником. Явно - * заданий завжди виграє — це шлях для випадків, коли прошивка + * Профіль можна не задавати: сервер підбере його за тим, що пристрій + * сказав про себе по SNMP, а якщо SNMP немає — за виробником. Явно + * заданий завжди виграє: це шлях для випадків, коли прошивка * поводиться не так, як решта родини. */ export function BackupPolicyForm({ @@ -16,17 +42,29 @@ export function BackupPolicyForm({ credentials, onClose, inline = false, + saveRef, }: { device: DeviceSummary credentials: Credential[] onClose?: () => void /** Всередині чужої форми: без власного вікна й без кнопки «Скасувати». */ inline?: boolean + /** + * Куди покласти збереження, коли форма живе всередині чужої. + * + * Дві кнопки «Зберегти» в одному вікні — це питання «а що саме + * збережеться?» на кожен клік. Тому в inline-режимі власної кнопки + * немає: сюди кладеться функція, яку картка хоста викликає зі своєї + * єдиної кнопки. Виклик дешевий — якщо розклад не чіпали, функція + * нічого не шле; помилку вона показує тут-таки й кидає далі, щоб + * батько знав, що збереження не пройшло. + */ + saveRef?: MutableRefObject<(() => Promise) | null> }) { const [profiles, setProfiles] = useState([]) const [policy, setPolicy] = useState(null) const [enabled, setEnabled] = useState(true) - const [cron, setCron] = useState('0 3 * * *') + const [cron, setCron] = useState(DEFAULT_CRON) // Чи слідує хост глобальному розкладу. Знімається щойно людина // задає розклад тут: інакше найближча зміна глобального затерла б те, // що вона щойно налаштувала руками. @@ -37,32 +75,149 @@ export function BackupPolicyForm({ const [busy, setBusy] = useState(false) const [err, setErr] = useState(null) + // Знімок полів на момент завантаження. Потрібен, щоб єдина кнопка + // картки хоста не слала розклад, якого ніхто не чіпав: таке + // «збереження» мовчки зняло б хост зі спільного розкладу. + const [baseline, setBaseline] = useState('') + + // Свіжий стан хоста після розпізнавання. DeviceSummary приходить + // пропом і сам не оновлюється, а підказка про профіль має показати + // результат опитування, а не те, що було до нього. + const [fresh, setFresh] = useState(null) + const [identBusy, setIdentBusy] = useState(false) + const [identNote, setIdentNote] = useState(null) + + // Розпізнавання чекає на відповідь пристрою секундами; за цей час + // вікно встигають закрити, і писати в стан уже нікуди. + const alive = useRef(true) useEffect(() => { - let alive = true + alive.current = true + return () => { + alive.current = false + } + }, []) + + useEffect(() => { + let ok = true + setFresh(null) + setIdentNote(null) Promise.all([ api.listNcmProfiles().catch(() => [] as NcmProfile[]), api.getBackupPolicy(device.id).catch(() => null), ]) .then(([pr, pol]) => { - if (!alive) return + if (!ok) return setProfiles(pr) setPolicy(pol) - if (pol) { - setEnabled(pol.enabled) - setCron(pol.cron) - setFollowsDefault(pol.follows_default) - setProfileID(pol.profile_id ?? '') - setCredID(pol.credential_id ?? '') + const init = { + enabled: pol ? pol.enabled : true, + cron: pol ? pol.cron : DEFAULT_CRON, + profileID: pol?.profile_id ?? '', + credID: pol?.credential_id ?? '', } + setEnabled(init.enabled) + setCron(init.cron) + setFollowsDefault(pol?.follows_default ?? false) + setProfileID(init.profileID) + setCredID(init.credID) + setBaseline(JSON.stringify([init.enabled, init.cron.trim(), init.profileID, init.credID])) }) - .finally(() => alive && setLoading(false)) + .finally(() => ok && setLoading(false)) return () => { - alive = false + ok = false } }, [device.id]) const cliCreds = credentials.filter((c) => c.proto === 'ssh' || c.proto === 'telnet') + // Хост, за яким малюємо підказку: після розпізнавання — свіжий. + const shown = fresh ?? device + + const snapshot = JSON.stringify([enabled, cron.trim(), profileID, credID]) + const dirty = !loading && snapshot !== baseline + + async function save() { + // Вкладку відкрили й нічого не чіпали — запиту бути не повинно: + // збереження зняло б хост зі спільного розкладу за спиною людини, + // яка натискала «Зберегти» заради полів самого хоста. В окремому + // вікні навпаки: там натискання кнопки — і є те саме «хочу свій + // розклад», навіть якщо жодне поле не змінилось. + if (inline && !dirty) return + if (cron.trim() === '') { + setErr('Розклад збору конфігів не заданий') + throw new Error('Розклад збору конфігів не заданий') + } + setBusy(true) + setErr(null) + try { + await api.setBackupPolicy(device.id, { + profile_id: profileID, + credential_id: credID, + enabled, + cron: cron.trim(), + // Збереження цієї форми означає «в мене свій розклад». + follows_default: false, + }) + setBaseline(snapshot) + setFollowsDefault(false) + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)) + throw e + } finally { + setBusy(false) + } + } + + // Посилання оновлюємо на кожному рендері: усередині save() замкнені + // поточні значення полів, і застаріле замикання зберегло б те, що + // людина бачила два натискання тому. + useEffect(() => { + if (!saveRef) return + saveRef.current = save + return () => { + saveRef.current = null + } + }) + + /** + * Опитати хост по SNMP просто зараз. + * + * Чек розпізнавання хост отримує й сам, але наступним тактом — до + * двох хвилин. Людина, яка щойно завела хост, стільки не чекає. + * Відповідь від пристрою приходить не у відповіді на цей запит, а + * окремим шляхом через зонд, тому після постановки в чергу + * перечитуємо стан хоста кілька разів: інакше підказка лишиться + * старою й кнопку натиснуть удруге. + */ + async function identify() { + setIdentBusy(true) + setIdentNote(null) + try { + const r = await api.identifyDevice(device.id) + if (!alive.current) return + if (!r.queued) { + // reason — готовий текст від сервера, показуємо як є. + setIdentNote(r.reason ?? 'Не вдалося зрушити опитування') + return + } + for (let i = 0; i < 6; i++) { + await sleep(2500) + if (!alive.current) return + const found = await api.getDevice(device.id).catch(() => null) + if (!alive.current) return + if (found?.sys_object_id) { + setFresh(found) + return + } + } + setIdentNote('Хост поки не відповів по SNMP — спробуйте ще раз за хвилину') + } catch (e) { + if (alive.current) setIdentNote(e instanceof Error ? e.message : String(e)) + } finally { + if (alive.current) setIdentBusy(false) + } + } + const body = ( loading ? (

    Завантаження…

    @@ -77,28 +232,55 @@ export function BackupPolicyForm({ {followsDefault && (

    - Зараз хост слідує спільному розкладу. Щойно ви збережете цю форму, він отримає - власний — і зміни спільного його більше не чіпатимуть. + Зараз хост слідує спільному розкладу. Щойно ви збережете{' '} + {inline ? 'хост' : 'цю форму'}, він отримає власний — і зміни спільного його більше + не чіпатимуть.

    )} - - - + {/* Профіль і стан розпізнавання — один блок: підказка пояснює + саме те, що станеться при «— автоматично —», а кнопка + дозволяє це «автоматично» отримати негайно. Кнопка навмисно + поза Field: той загорнутий у label, і клік по ній + перекидався б у select. */} +
    + + + + +
    + + {autoHint(shown)} + + {/* Розпізнаному хосту опитування вже нічого не додасть. */} + {!shown.sys_object_id && ( + + )} +
    + + {identNote && ( +

    + {identNote} +

    + )} +
    {err} -
    - {!inline && } - -
    + {inline ? ( + // Кнопки тут немає навмисно — зберігає картка хоста. Але + // мовчати теж не можна: людина, яка змінила розклад, має + // бачити, що зміна ще не поїхала на сервер. + dirty && ( +

    + Розклад змінено — збережеться разом з хостом по кнопці «Зберегти». +

    + ) + ) : ( +
    + + +
    + )}
    ) ) diff --git a/web/src/components/BulkDeviceForm.tsx b/web/src/components/BulkDeviceForm.tsx new file mode 100644 index 0000000..41bac22 --- /dev/null +++ b/web/src/components/BulkDeviceForm.tsx @@ -0,0 +1,1639 @@ +import { useEffect, useMemo, useState } from 'react' +import { api } from '../api/client' +import type { BulkChecksResult, BulkDeviceChanges, BulkListOp } from '../api/client' +import { + Button, + ErrorNote, + Field, + IntervalInput, + Modal, + Spinner, + Toggle, + humanInterval, + inputClass, + plural, +} from './ui' +import { fmtBytes } from './BackupDefaultsForm' +import { Picker } from './Picker' +import { TemplatePicker } from './TemplatePicker' +import { kindLabel } from './DeviceForm' +import { DEFAULT_INTERVAL, ParamFields, defaultsFromSchema } from './ChecksEditor' +import type { + AgentSummary, + BulkDeviceTarget, + BulkTargetMode, + DeleteMode, + PurgeResult, + RestoreResult, + CheckType, + Credential, + DeviceFilter, + DeviceGroup, + DeviceSummary, + NcmProfile, + Template, +} from '../types' + +/** + * Масова правка й масове видалення хостів. + * + * Одне вікно на дві дії, бо в них однаковий і найважливіший крок: + * показати людині ТОЧНИЙ перелік того, що вона зачепить. Перелік + * приходить із сервера, а не збирається тут із того, що лежить у + * пам'яті сторінки: між тим, як людина відмітила рядки, і тим, як + * натиснула, хост могли видалити, забрати з її груп доступу або + * вимкнути. Те, що відпало, показується окремо — мовчазна різниця між + * «обрано 50» і «змінено 43» неприпустима. + * + * Головне правило форми правки: НЕ ЧІПАТИ — ЦЕ НЕ СТЕРТИ. + * + * Порожнє поле в масовій формі не означає «зітри це в усіх». У + * п'ятдесяти хостів немає спільного поточного значення, яке можна було + * б показати в полі, тож порожнє поле означає рівно одне: «я про це не + * говорив». Через це кожне поле має явний перемикач на три стани — + * «не чіпати», «задати», «очистити», — і жоден із них не вгадується з + * вмісту поля. + */ + +type Tri = 'keep' | 'set' | 'clear' +type ListMode = 'keep' | 'add' | 'remove' | 'replace' +/** + * Дії над ручними перевірками. + * + * «Замінити» тут немає, на відміну від груп, і це не забутий випадок. + * Групи видно в переліку хостів — людина, яка натискає «замінити», + * бачить, що стирає. Набори перевірок не видно ніде, тож те саме + * натискання зітерло б невідомо що й помітилось би тоді, коли перестане + * приходити алерт. + */ +type ChecksMode = 'keep' | 'add' | 'remove' | 'tune' + +export function BulkDeviceForm({ + mode, + devices, + filter, + archived = false, + groups, + agents, + credentials, + checkTypes, + onClose, + onDone, +}: { + mode: 'edit' | 'delete' | 'restore' + /** Те, що людина відмітила в переліку. Джерело імен для відсіяних. */ + devices: DeviceSummary[] + /** Фільтр, яким вона це відбирала. Сервер бере перетин із переліком. */ + filter: DeviceFilter + /** + * Набір узято з архіву. + * + * Змінює не оформлення, а перелік доступних дій: над прибраним + * хостом архівне видалення не має сенсу (він уже прибраний), тож + * вибору між двома видаленнями там немає — лишається одне, повне. + */ + archived?: boolean + groups: DeviceGroup[] + agents: AgentSummary[] + credentials: Credential[] + /** Довідник типів перевірок — той самий, що й у картці хоста. */ + checkTypes: CheckType[] + onClose: () => void + onDone: () => Promise | void +}) { + const ids = useMemo(() => devices.map((d) => d.id), [devices]) + + const [targets, setTargets] = useState(null) + const [dropped, setDropped] = useState([]) + const [templates, setTemplates] = useState([]) + const [profiles, setProfiles] = useState([]) + const [busy, setBusy] = useState(false) + const [err, setErr] = useState(null) + + // --- поля правки: стан перемикача + значення --- + const [agentMode, setAgentMode] = useState('keep') + const [agentID, setAgentID] = useState('') + const [kindMode, setKindMode] = useState('keep') + const [kind, setKind] = useState('switch') + const [vendorMode, setVendorMode] = useState('keep') + const [vendor, setVendor] = useState('') + const [enabledMode, setEnabledMode] = useState('keep') + const [enabled, setEnabled] = useState(true) + + const [groupMode, setGroupMode] = useState('keep') + const [groupIDs, setGroupIDs] = useState([]) + const [tplMode, setTplMode] = useState('keep') + const [tplIDs, setTplIDs] = useState([]) + const [credMode, setCredMode] = useState('keep') + const [credIDs, setCredIDs] = useState([]) + + const [backupMode, setBackupMode] = useState('keep') + const [bpEnabled, setBpEnabled] = useState(true) + const [bpCron, setBpCron] = useState('0 3 * * *') + const [bpProfile, setBpProfile] = useState('') + const [bpCred, setBpCred] = useState('') + const [bpSyslog, setBpSyslog] = useState(false) + + // --- ручні перевірки --- + const [chkMode, setChkMode] = useState('keep') + const [chkType, setChkType] = useState('') + const [chkParams, setChkParams] = useState>({}) + const [chkInterval, setChkInterval] = useState(60) + const [chkEnabled, setChkEnabled] = useState(true) + // Для «змінити наявні» кожне поле саме по собі тристанове: галочка + // знята — не чіпати. Той самий принцип, що й у решті форми, лише + // всередині однієї дії. + const [tuneIntervalOn, setTuneIntervalOn] = useState(false) + const [tuneInterval, setTuneInterval] = useState(60) + const [tuneTimeoutOn, setTuneTimeoutOn] = useState(false) + const [tuneTimeout, setTuneTimeout] = useState(3000) + const [tuneRetriesOn, setTuneRetriesOn] = useState(false) + const [tuneRetries, setTuneRetries] = useState(2) + const [tuneEnabled, setTuneEnabled] = useState<'keep' | 'on' | 'off'>('keep') + + // Наслідок дії над перевірками. Вікно після неї не закривається: + // «додано 12, уже було 25, не вдалося 3» — це і є відповідь, заради + // якої дію робили, і показати її ніде більше. + const [chkResult, setChkResult] = useState(null) + + // --- видалення --- + // + // Типово — ПОВНЕ, і це рішення, а не недогляд. «Видалити» в + // інтерфейсі означає видалити; архів, який лишається після м'якого + // видалення, ніде в продукті не показується й нізвідки не + // відновлюється, тож людина, яка не помітила вибору, отримує саме те, + // чого просила. Архівне лишається окремим свідомим вибором для тих, + // хто тримає історію конфігів і алертів навмисно. + // + // Для набору з архіву вибору немає взагалі: архівувати вже + // заархівоване — дія, яка нічого не робить, і пропонувати її означало + // б поставити поруч кнопку без наслідку. + const [delMode, setDelMode] = useState('purge') + // Галочка «розумію» для повного видалення — лише коли є що втрачати. + const [understood, setUnderstood] = useState(false) + // Підсумок повного видалення. Вікно після нього не закривається: це + // єдине місце, де видно, що саме зникло, і після закриття цих чисел + // не знайти вже ніде, крім журналу аудиту. + const [purged, setPurged] = useState(null) + // Підсумок повернення. Вікно так само лишається відкритим: «хост на + // місці, але вимкнений» і «прив'язку до зонда знято» — це те, про що + // спитають одразу після дії, і показати це більше ніде. + const [restored, setRestored] = useState(null) + + // Набір звіряється з сервером щоразу при відкритті вікна, а не + // береться зі списку на сторінці: список міг постаріти, а тут із + // нього робиться підтвердження. + useEffect(() => { + let alive = true + // Режим потрібен доборові, а не оформленню: цілі для повного + // видалення й для повернення шукаються разом із прибраними + // хостами, для решти дій — лише серед живих. + const targetMode: BulkTargetMode | undefined = + mode === 'restore' ? 'restore' : mode === 'delete' ? 'purge' : undefined + api + .bulkDeviceTargets(filter, ids, targetMode) + .then((r) => { + if (!alive) return + setTargets(r.devices ?? []) + setDropped(r.dropped ?? []) + }) + .catch((e) => { + if (alive) setErr(e instanceof Error ? e.message : String(e)) + }) + return () => { + alive = false + } + // Набір фіксується на момент відкриття вікна: перезапитувати його + // при кожній зміні фільтра означало б міняти те, що людина вже + // читає як підтвердження. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + useEffect(() => { + if (mode !== 'edit') return + let alive = true + Promise.all([ + api.listTemplates().catch(() => [] as Template[]), + api.listNcmProfiles().catch(() => [] as NcmProfile[]), + ]).then(([t, p]) => { + if (!alive) return + setTemplates(t) + setProfiles(p) + }) + return () => { + alive = false + } + }, [mode]) + + const nameByID = useMemo(() => { + const m = new Map() + for (const d of devices) m.set(d.id, d.name) + return m + }, [devices]) + + const changes = useMemo(() => { + const c: BulkDeviceChanges = {} + if (agentMode === 'set') c.agent_id = { op: 'set', value: agentID } + else if (agentMode === 'clear') c.agent_id = { op: 'clear' } + + if (kindMode === 'set') c.kind = { op: 'set', value: kind } + + if (vendorMode === 'set') c.vendor = { op: 'set', value: vendor.trim() } + else if (vendorMode === 'clear') c.vendor = { op: 'clear' } + + if (enabledMode === 'set') c.enabled = { op: 'set', value: enabled } + + const list = (m: ListMode, v: string[]): BulkListOp | undefined => + m === 'keep' ? undefined : { op: m, ids: v } + c.groups = list(groupMode, groupIDs) + c.templates = list(tplMode, tplIDs) + c.credentials = list(credMode, credIDs) + + if (backupMode === 'set') { + c.backup = { + op: 'set', + enabled: bpEnabled, + cron: bpCron.trim(), + profile_id: bpProfile, + credential_id: bpCred, + on_syslog: bpSyslog, + // Масово заданий розклад — це власний розклад хостів, а не + // повернення їх під глобальний: інакше найближча зміна + // глобального мовчки затерла б те, що людина щойно поставила. + follows_default: false, + } + } else if (backupMode === 'clear') { + c.backup = { op: 'clear' } + } + + if (chkMode === 'add' && chkType) { + // Таймаут і повтори тут не питаємо навмисно: у картці хоста їх + // теж не питають при додаванні, і два різні набори «типового» для + // однієї й тієї ж перевірки означали б, що чек, заведений масово, + // відрізняється від заведеного руками невидимо для ока. + c.checks = { + op: 'add', + check_type: chkType, + params: chkParams, + interval_sec: chkInterval, + enabled: chkEnabled, + } + } else if (chkMode === 'remove' && chkType) { + c.checks = { op: 'remove', check_type: chkType } + } else if (chkMode === 'tune' && chkType) { + c.checks = { + op: 'tune', + check_type: chkType, + interval_sec: tuneIntervalOn ? tuneInterval : undefined, + timeout_ms: tuneTimeoutOn ? tuneTimeout : undefined, + retries: tuneRetriesOn ? tuneRetries : undefined, + enabled: tuneEnabled === 'keep' ? undefined : tuneEnabled === 'on', + } + } + + // Ключі зі значенням undefined у JSON не потраплять — рівно те, що + // сервер читає як «поля немає, не чіпати». + return c + }, [ + agentMode, agentID, kindMode, kind, vendorMode, vendor, enabledMode, enabled, + groupMode, groupIDs, tplMode, tplIDs, credMode, credIDs, + backupMode, bpEnabled, bpCron, bpProfile, bpCred, bpSyslog, + chkMode, chkType, chkParams, chkInterval, chkEnabled, + tuneIntervalOn, tuneInterval, tuneTimeoutOn, tuneTimeout, + tuneRetriesOn, tuneRetries, tuneEnabled, + ]) + + // Людською мовою те саме, що поїде на сервер. Показується поруч із + // кнопкою: масову дію підтверджують за описом наслідку, а не за + // станом десятка перемикачів, розкиданих по формі. + const summary = useMemo(() => { + const out: string[] = [] + const agentName = (id: string) => agents.find((a) => a.id === id)?.name ?? '—' + const groupNames = (v: string[]) => + v.map((id) => groups.find((g) => g.id === id)?.name ?? '—').join(', ') + const tplNames = (v: string[]) => + v.map((id) => templates.find((t) => t.id === id)?.name ?? '—').join(', ') + const credNames = (v: string[]) => + v.map((id) => credentials.find((c) => c.id === id)?.name ?? '—').join(', ') + const listWord: Record = { + add: 'додати', + remove: 'прибрати', + replace: 'замінити на', + } + + if (agentMode === 'set') out.push(`Зонд → ${agentName(agentID)}`) + if (agentMode === 'clear') out.push('Зонд → відв’язати (хост перестане опитуватись)') + if (kindMode === 'set') out.push(`Тип → ${kindLabel[kind] ?? kind}`) + if (vendorMode === 'set') out.push(`Виробник → ${vendor.trim()}`) + if (vendorMode === 'clear') out.push('Виробник → очистити') + if (enabledMode === 'set') out.push(enabled ? 'Опитування → увімкнути' : 'Опитування → вимкнути') + if (groupMode !== 'keep') out.push(`Групи: ${listWord[groupMode]} ${groupNames(groupIDs) || '—'}`) + if (tplMode !== 'keep') out.push(`Шаблони: ${listWord[tplMode]} ${tplNames(tplIDs) || '—'}`) + if (credMode !== 'keep') out.push(`Доступи: ${listWord[credMode]} ${credNames(credIDs) || '—'}`) + if (backupMode === 'set') { + out.push( + `Збір конфігів → ${bpEnabled ? 'увімкнено' : 'вимкнено'}, розклад «${bpCron.trim()}»`, + ) + } + if (backupMode === 'clear') out.push('Збір конфігів → прибрати розклад') + + if (chkMode !== 'keep' && chkType) { + const t = checkTypes.find((x) => x.key === chkType) + const label = `«${t?.name ?? chkType}» (${chkType})` + if (chkMode === 'add') { + out.push( + `Перевірки: додати ${label}, кожні ${humanInterval(chkInterval)}` + + (chkEnabled ? '' : ', вимкненою') + + ' — тим, у кого її ще немає', + ) + } else if (chkMode === 'remove') { + out.push(`Перевірки: прибрати ${label} у тих, у кого вона заведена руками`) + } else { + const bits: string[] = [] + if (tuneIntervalOn) bits.push(`кожні ${humanInterval(tuneInterval)}`) + if (tuneTimeoutOn) bits.push(`таймаут ${tuneTimeout} мс`) + if (tuneRetriesOn) bits.push(`повторів ${tuneRetries}`) + if (tuneEnabled !== 'keep') bits.push(tuneEnabled === 'on' ? 'увімкнути' : 'вимкнути') + out.push(`Перевірки: ${label} — ${bits.join(', ') || '—'}`) + } + } + return out + }, [ + agents, groups, templates, credentials, checkTypes, + agentMode, agentID, kindMode, kind, vendorMode, vendor, enabledMode, enabled, + groupMode, groupIDs, tplMode, tplIDs, credMode, credIDs, backupMode, bpEnabled, bpCron, + chkMode, chkType, chkInterval, chkEnabled, + tuneIntervalOn, tuneInterval, tuneTimeoutOn, tuneTimeout, + tuneRetriesOn, tuneRetries, tuneEnabled, + ]) + + const chkTypeObj = useMemo( + () => checkTypes.find((t) => t.key === chkType) ?? null, + [checkTypes, chkType], + ) + + /** + * Обов'язкові параметри, яких людина ще не заповнила. + * + * Це і є та межа, де масове додавання впирається в природу + * параметрів. `url` у http.status обов'язковий, а спільного значення + * для сорока хостів у нього не існує — тому дозволено підстановку + * `{{address}}` / `{{name}}` / `{{fqdn}}`, і лише її. Порожнім поле + * лишити не можна: чек без url не запуститься взагалі, і сорок + * непрацездатних перевірок гірші за одну незручність тут. + */ + const missingParams = useMemo(() => { + if (chkMode !== 'add' || !chkTypeObj) return [] + return (chkTypeObj.params_schema?.required ?? []).filter((k) => { + const v = chkParams[k] + if (v === undefined || v === null) return true + if (typeof v === 'string') return v.trim() === '' + if (Array.isArray(v)) return v.length === 0 + return false + }) + }, [chkMode, chkTypeObj, chkParams]) + + // Чи є що втрачати. Хост без жодного зібраного байта видаляють + // назавжди без зайвих церемоній: галочка «розумію» на порожньому + // місці навчає натискати її не читаючи — і тоді вона не спрацює там, + // де справді потрібна. + const hasHistory = useMemo( + () => + (targets ?? []).some( + (t) => t.configs > 0 || t.samples > 0 || t.alerts > 0 || t.series > 0, + ), + [targets], + ) + + // Причина, чому кнопку не можна натиснути. Текстом, а не мовчазним + // disabled: людина має знати, чого від неї хочуть. + const blocker = useMemo(() => { + if (targets === null) return 'Звіряємо перелік…' + if (targets.length === 0) return 'Немає жодного хоста, доступного на запис' + if (mode === 'restore') return null + if (mode === 'delete') { + if (delMode === 'purge' && hasHistory && !understood) + return 'Підтвердьте, що зібране можна стерти назавжди' + return null + } + // Незавершений вибір типу перевірки перевіряється ДО загального + // «оберіть, що саме змінити»: без типу дія в summary не потрапляє + // взагалі, і людина читала б, ніби вона нічого не обрала, хоча + // обрала — і не доклала. + if (chkMode !== 'keep' && !chkType) return 'Оберіть тип перевірки' + if (summary.length === 0) return 'Оберіть, що саме змінити' + if (agentMode === 'set' && !agentID) return 'Оберіть зонд' + if (vendorMode === 'set' && !vendor.trim()) return 'Впишіть виробника' + if (groupMode !== 'keep' && groupMode !== 'replace' && groupIDs.length === 0) + return 'Оберіть групи' + if (tplMode !== 'keep' && tplMode !== 'replace' && tplIDs.length === 0) return 'Оберіть шаблони' + if (credMode !== 'keep' && credMode !== 'replace' && credIDs.length === 0) + return 'Оберіть доступи' + if (backupMode === 'set' && !bpCron.trim()) return 'Вкажіть розклад' + if (chkMode === 'add' && missingParams.length > 0) + return `Заповніть параметр${missingParams.length > 1 ? 'и' : ''}: ${missingParams.join(', ')}` + if ( + chkMode === 'tune' && + !tuneIntervalOn && + !tuneTimeoutOn && + !tuneRetriesOn && + tuneEnabled === 'keep' + ) + return 'Оберіть, що змінити в перевірці' + return null + }, [ + targets, mode, delMode, understood, hasHistory, + summary, agentMode, agentID, vendorMode, vendor, + groupMode, groupIDs, tplMode, tplIDs, credMode, credIDs, backupMode, bpCron, + chkMode, chkType, missingParams, + tuneIntervalOn, tuneTimeoutOn, tuneRetriesOn, tuneEnabled, + ]) + + const total = targets?.length ?? 0 + + const run = async () => { + setBusy(true) + setErr(null) + try { + const picked = (targets ?? []).map((t) => t.device_id) + if (mode === 'restore') { + const res = await api.bulkRestoreDevices(filter, picked) + await onDone() + // Вікно не закривається навіть тоді, коли все пройшло рівно: + // «повернуто 3, увімкнено 11 перевірок, один лишився вимкненим» + // — це і є відповідь на питання, чи хост справді працює. + setRestored(res.result ?? null) + return + } + if (mode === 'delete') { + const res = await api.bulkDeleteDevices(filter, picked, delMode) + await onDone() + // Архівне видалення нічого не повідомляє понад те, що людина + // вже прочитала в підтвердженні, — вікно закривається. Повне + // повідомляє: скільки версій, рядів і вимірів справді зникло і + // чи все вдалося. + if (delMode === 'purge') { + setPurged(res.purged ?? null) + return + } + onClose() + return + } + const res = await api.bulkUpdateDevices(filter, picked, changes) + await onDone() + // Дія над перевірками — єдина, у якої наслідок не збігається з + // наміром: частина хостів уже мала таку перевірку, частина не + // мала чим підставити адресу. Закрити вікно, показавши лише + // «готово», означало б сховати рівно те, про що спитають далі. + if (res.checks) { + setChkResult(res.checks) + return + } + onClose() + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)) + } finally { + setBusy(false) + } + } + + return ( + +
    + {restored ? ( + <> + +
    + +
    + + ) : purged ? ( + <> + +
    + +
    + + ) : chkResult ? ( + <> + +
    + +
    + + ) : targets === null ? ( + // Помилка звірки (найчастіше — перевищена стеля) читається + // нижче, у ErrorNote; крутити при цьому спінер означало б + // обіцяти, що воно от-от завантажиться. + err ? null : ( + + ) + ) : ( + <> + + + {dropped.length > 0 && ( +

    + Не потрапили в дію: {dropped.map((id) => nameByID.get(id) ?? id).join(', ')}. + Хост зник, більше не підпадає під фільтр або доступний лише на читання. +

    + )} + + {mode === 'restore' ? ( + + ) : mode === 'delete' ? ( + { + setDelMode(m) + setUnderstood(false) + }} + understood={understood} + onUnderstood={setUnderstood} + needsConfirm={hasHistory} + archived={archived} + /> + ) : ( +
    +

    + Поля, залишені на «не чіпати», не змінюються — у кожного хоста лишається своє + значення. «Очистити» — окремий вибір, і він саме стирає. +

    + + + + + + + + + + + setVendor(e.target.value)} + /> + + + + + + + + ({ id: g.id, label: g.name }))} + value={groupIDs} + onChange={setGroupIDs} + empty="Груп ще немає" + /> + + + + + + + + ({ id: c.id, label: c.name, hint: c.proto }))} + value={credIDs} + onChange={setCredIDs} + empty="Доступів ще немає" + /> + + + +
    + +
    + + setBpCron(e.target.value)} + placeholder="0 3 * * *" + /> + + + + + + + + +
    +
    +
    + + { + setChkMode(m) + // Тип не скидаємо: людина найчастіше перемикається + // між «прибрати» і «змінити наявні» на тому самому + // типі, і змушувати обирати його заново — марна + // робота. Параметри при цьому не переносяться: вони + // є лише в «додати». + setErr(null) + }} + type={chkType} + onType={(k) => { + setChkType(k) + const t = checkTypes.find((x) => x.key === k) + setChkParams(t ? defaultsFromSchema(t) : {}) + setChkInterval(DEFAULT_INTERVAL[k] ?? 60) + }} + typeObj={chkTypeObj} + params={chkParams} + onParams={setChkParams} + interval={chkInterval} + onInterval={setChkInterval} + enabled={chkEnabled} + onEnabled={setChkEnabled} + tuneIntervalOn={tuneIntervalOn} + onTuneIntervalOn={setTuneIntervalOn} + tuneInterval={tuneInterval} + onTuneInterval={setTuneInterval} + tuneTimeoutOn={tuneTimeoutOn} + onTuneTimeoutOn={setTuneTimeoutOn} + tuneTimeout={tuneTimeout} + onTuneTimeout={setTuneTimeout} + tuneRetriesOn={tuneRetriesOn} + onTuneRetriesOn={setTuneRetriesOn} + tuneRetries={tuneRetries} + onTuneRetries={setTuneRetries} + tuneEnabled={tuneEnabled} + onTuneEnabled={setTuneEnabled} + /> + + {summary.length > 0 && ( +
    +
    + Буде застосовано до {total} {plural(total, ['хоста', 'хостів', 'хостів'])} +
    +
      + {summary.map((line) => ( +
    • · {line}
    • + ))} +
    +
    + )} +
    + )} + + )} + + {err} + + {!chkResult && !purged && !restored && ( +
    + {blocker && {blocker}} + + +
    + )} +
    +
    + ) +} + +/** + * Точний перелік того, що зачепить дія. + * + * Не «50 хостів», а імена й адреси: кількість підтверджує намір, + * перелік підтверджує вибір, і замінити друге першим не можна. + */ +function TargetList({ targets }: { targets: BulkDeviceTarget[] }) { + if (targets.length === 0) { + return ( +

    + Жоден з обраних хостів не доступний вам на запис — дія нічого не зачепить. +

    + ) + } + return ( +
    +
    + {targets.length} {plural(targets.length, ['хост', 'хости', 'хостів'])} +
    +
      + {targets.map((t) => ( +
    • + {t.name} + + {t.address || '—'} + +
    • + ))} +
    +
    + ) +} + +/** + * Вибір між двома видаленнями — і числа, якими вони різняться. + * + * ЧОМУ ТУТ ВИБІР, А НЕ ОДНА ДІЯ + * + * «Видалити» має означати видалити — це очікування, і воно правильне. + * Але зібраний архів конфігів і історія алертів у частини інсталяцій + * тримаються навмисно: перше — бо ним доводять, як виглядало залізо в + * конкретний день, друге — бо ним розбирають аварії. Одна дія на два + * законні наміри означала б, що комусь із двох система мовчки псує + * роботу. + * + * Тому обидва названі своїми словами, а не «видалити» й «видалити + * назавжди (розширено)». Типово обране повне: воно відповідає слову на + * кнопці, а архівне лишається свідомим вибором. + * + * ЧОМУ ЧИСЛА, А НЕ «ВИ ВПЕВНЕНІ?» + * + * Питання «ви впевнені» перевіряє, чи людина натиснула кнопку, а не чи + * розуміє наслідок. Різниця між двома цими діями вимірюється в + * конкретних величинах — версіях конфігу, рядах метрик, вимірах, + * алертах, — і саме вони мають стояти поруч із вибором. Числа + * рахуються сервером на тому ж кроці, що й перелік хостів + * (BulkDeviceTarget): другий їх лічильник розійшовся б із першим на + * найпершій правці. + */ +function DeleteChoice({ + targets, + value, + onChange, + understood, + onUnderstood, + needsConfirm, + archived, +}: { + targets: BulkDeviceTarget[] + value: DeleteMode + onChange: (m: DeleteMode) => void + understood: boolean + onUnderstood: (v: boolean) => void + needsConfirm: boolean + /** Набір узято з архіву — архівувати його вдруге нема куди. */ + archived?: boolean +}) { + const sum = (f: (t: BulkDeviceTarget) => number) => targets.reduce((a, t) => a + f(t), 0) + const checks = sum((t) => t.checks) + const activeAlerts = sum((t) => t.active_alerts) + const nodes = sum((t) => t.map_nodes) + const configs = sum((t) => t.configs) + const configBytes = sum((t) => t.config_bytes) + const series = sum((t) => t.series) + const samples = sum((t) => t.samples) + const capped = targets.some((t) => t.samples_capped) + const alerts = sum((t) => t.alerts) + const ifaces = sum((t) => t.interfaces) + const policies = targets.filter((t) => t.has_backup_policy).length + const branches = targets.filter((t) => !!t.branch).length + + const num = (n: number) => n.toLocaleString('uk-UA') + + return ( +
    + onChange('purge')} + title="Видалити назавжди" + hint="Хоста й усього, що з ним пов'язане, більше не буде. Повернути неможливо." + > +
      +
    • + · {num(configs)}{' '} + {plural(configs, ['версія', 'версії', 'версій'])} конфігу + {configBytes > 0 && <> ({fmtBytes(configBytes)})} — разом із зашифрованими тілами +
    • +
    • + · {num(series)}{' '} + {plural(series, ['ряд', 'ряди', 'рядів'])} метрик і{' '} + {capped ? `понад ${num(samples)}` : num(samples)}{' '} + {plural(samples, ['вимірювання', 'вимірювання', 'вимірювань'])} +
    • +
    • + · {num(alerts)}{' '} + {plural(alerts, ['алерт', 'алерти', 'алертів'])} разом з історією +
    • +
    • + · {num(checks)}{' '} + {plural(checks, ['перевірка', 'перевірки', 'перевірок'])},{' '} + {num(ifaces)}{' '} + {plural(ifaces, ['порт', 'порти', 'портів'])},{' '} + {num(nodes)}{' '} + {plural(nodes, ['вузол', 'вузли', 'вузлів'])} на мапах,{' '} + {num(policies)}{' '} + {plural(policies, ['розклад', 'розклади', 'розкладів'])} збору +
    • + {branches > 0 && ( +
    • + · {num(branches)}{' '} + {plural(branches, ['гілка', 'гілки', 'гілок'])} у Git-архіві — тут і на дзеркалі +
    • + )} +
    + {branches > 0 && ( + // Про дзеркало кажемо прямо: воно на чужому сервері, і його + // недоступність не скасовує видалення хоста, а лише відкладає + // прибирання гілки. Мовчання тут читалось би як «зникло + // всюди», і виявилась би неправда через тиждень. +

    + Гілки прибираються локально одразу, а на зовнішньому Git — найближчим тактом + дзеркалення. Якщо він зараз недоступний, видалення хоста однаково відбудеться, а + гілка почекає в черзі. +

    + )} +

    + Лишиться запис у журналі аудиту — з іменем і адресою хоста, щоб через рік було видно, + що саме зникло. +

    +
    + + {!archived && ( + onChange('archive')} + title="Прибрати з переліку" + hint="Хост зникає з інтерфейсу, зібране лишається в базі." + > +
      +
    • + · {num(checks)} перевірок лишаться в базі, але + опитування спиниться +
    • +
    • + · {num(activeAlerts)} активних алертів закриються на + найближчому такті правил +
    • +
    • + · {num(policies)} розкладів збору лишаться, збір + спиниться +
    • +
    • + · {num(nodes)} вузлів на мапах ЛИШАТЬСЯ з останнім + відомим станом +
    • +
    • + · {num(configs)} версій конфігу,{' '} + {num(series)} рядів метрик і гілки в Git зберігаються +
    • +
    +

    + Прибрані хости лишаються видимими: перелік хостів → «Архів». Звідти хост повертають у + роботу або видаляють назавжди. +

    +
    + )} + + {archived && ( +

    + {targets.length === 1 ? 'Цей хост уже прибраний' : 'Ці хости вже прибрані'} з переліку, + тож вибору тут немає — лишається тільки видалити назавжди. Щоб натомість повернути + {targets.length === 1 ? ' його ' : ' їх '}у роботу, закрийте це вікно й натисніть + «Повернути». +

    + )} + + {value === 'purge' && needsConfirm && ( + + )} +
    + ) +} + +/** Один із двох варіантів видалення — картка-перемикач. */ +function ModeCard({ + active, + danger, + onClick, + title, + hint, + children, +}: { + active: boolean + danger?: boolean + onClick: () => void + title: string + hint: string + children: React.ReactNode +}) { + const border = active + ? danger + ? 'border-red-700 bg-red-950/30' + : 'border-slate-600 bg-slate-900/60' + : 'border-slate-800 bg-slate-900/20' + return ( +
    { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onClick() + } + }} + className={`cursor-pointer rounded border p-2.5 ${border}`} + > +
    + + + {title} + + {hint} +
    + {/* Наслідки показуються лише для обраного: два переліки чисел + поруч читаються як один довгий, і саме та різниця, заради + якої вони тут, у ньому губиться. */} + {active &&
    {children}
    } +
    + ) +} + +/** + * Що справді зникло. + * + * Наслідок незворотний, і показати його треба один раз — після дії. + * Числа тут не ті самі, що в підтвердженні: між переглядом і + * натисканням могли доїхати нові виміри, а якийсь хост міг і не + * видалитись. + */ +function PurgeOutcome({ result }: { result: PurgeResult }) { + const num = (n: number) => n.toLocaleString('uk-UA') + const done = result.purged ?? [] + const failed = result.failed ?? [] + return ( +
    +
    +

    + Видалено назавжди: {done.length} {plural(done.length, ['хост', 'хости', 'хостів'])}. +

    +
      +
    • + · {num(result.configs)} {plural(result.configs, ['версія', 'версії', 'версій'])} конфігу, + звільнено {fmtBytes(result.freed_bytes)} +
    • +
    • + · {num(result.series)} {plural(result.series, ['ряд', 'ряди', 'рядів'])} метрик,{' '} + {num(result.samples)}{' '} + {plural(result.samples, ['вимірювання', 'вимірювання', 'вимірювань'])} +
    • +
    • + · {num(result.branches)} {plural(result.branches, ['гілка', 'гілки', 'гілок'])}{' '} + поставлено в чергу на видалення в Git +
    • +
    +
    + + {done.length > 0 && ( +
      + {done.map((d) => ( +
    • + {d.name} + + {d.address || '—'} + +
    • + ))} +
    + )} + + {failed.length > 0 && ( + // Видалення йде по одному хосту в своїй транзакції, тож частина + // могла не пройти. Мовчати про це не можна: у переліку хостів + // вони просто лишаться, і причина буде невідома. +
    +

    Не вдалося видалити:

    +
      + {failed.map((f) => ( +
    • + · {f.name} — {f.error} +
    • + ))} +
    +
    + )} +
    + ) +} + +/** + * Що станеться при поверненні — до натискання. + * + * Той самий принцип, що й у вікні видалення: не «ви впевнені», а + * числа. Різниця лише в тому, що тут вони не про втрату, а про те, чи + * справді хост запрацює: повернутий хост, у якого нічого не + * опитується, виглядає як зроблена дія й нею не є. + * + * Чого тут навмисно немає — обіцянки, що хост увімкнеться. Архівування + * запам'ятовує, увімкненим він був чи ні, і повертає рівно те; для + * хостів, прибраних до появи цієї пам'яті, відповіді немає взагалі, і + * вигадувати її не можна. Тому текст говорить про перевірки (їх + * система вимикала сама й сама ж вмикає назад) і чесно попереджає про + * решту. + */ +function RestoreNote({ targets }: { targets: BulkDeviceTarget[] }) { + const checks = targets.reduce((a, t) => a + t.checks, 0) + const nodes = targets.reduce((a, t) => a + t.map_nodes, 0) + return ( +
    +
    +

    Повернути в роботу

    +
      +
    • · хости знову з'являться в переліках, на мапах і в добірках
    • +
    • + · перевірки, вимкнені саме архівуванням, увімкнуться назад (усього в цих хостів{' '} + {checks}) +
    • +
    • · розклад збору конфігів і гілка в Git-архіві продовжаться своєю історією
    • + {nodes > 0 && ( +
    • + · {nodes} вузлів на мапах знову покажуть живий стан +
    • + )} +
    +
    +

    + Хост повертається в тому стані, у якому його прибрали: вимкнений до архівування — + лишиться вимкненим. Перевірки, вимкнені руками ще до видалення, теж лишаться + вимкненими. Стан («на зв'язку», «недоступний») скидається до «невідомо» — доки не + пройде перше опитування, справжнього ми не знаємо. +

    +
    + ) +} + +/** + * Що вийшло з повернення. + * + * Три речі, яких немає більше ніде: чи ввімкнувся хост, скільки + * перевірок ожило й чи довелось зняти прив'язку до машини зонда. + * Остання — єдина зміна, яку повернення робить самé, і промовчати про + * неї не можна: збір конфігів такого сервера просто не почнеться, а + * причина буде невидима. + */ +function RestoreOutcome({ result }: { result: RestoreResult }) { + const done = result.restored ?? [] + const failed = result.failed ?? [] + const offline = done.filter((d) => !d.enabled) + const unbound = done.filter((d) => d.self_agent_dropped) + return ( +
    +
    +

    + Повернуто: {done.length} {plural(done.length, ['хост', 'хости', 'хостів'])}, увімкнено{' '} + {result.checks} {plural(result.checks, ['перевірку', 'перевірки', 'перевірок'])}. +

    +
    + + {done.length > 0 && ( +
      + {done.map((d) => ( +
    • + {d.name} + + {d.enabled ? 'опитування увімкнено' : 'лишився вимкненим'} ·{' '} + {d.checks} {plural(d.checks, ['перевірка', 'перевірки', 'перевірок'])} + +
    • + ))} +
    + )} + + {offline.length > 0 && ( +

    + Лишились вимкненими: {offline.map((d) => d.name).join(', ')}. Опитування таких хостів не + почнеться, доки їх не увімкнути в картці — це або їхній стан до архівування, або хост + прибрали ще до того, як система почала це запам'ятовувати. +

    + )} + + {unbound.length > 0 && ( +

    + Знято прив'язку «хост = машина зонда»: {unbound.map((d) => d.name).join(', ')}. Поки + хост був в архіві, ту саму машину оголосив своєю інший хост, а вона може бути лише в + одного. Якщо потрібен саме цей — прив'яжіть заново в його картці. +

    + )} + + {failed.length > 0 && ( +
    +

    Не вдалося повернути:

    +
      + {failed.map((f) => ( +
    • + · {f.name} — {f.error} +
    • + ))} +
    +
    + )} +
    + ) +} + +/** Рядок форми з перемикачем «не чіпати / задати / очистити». */ +function TriRow({ + label, + hint, + mode, + onMode, + clearLabel, + children, +}: { + label: string + hint?: string + mode: Tri + onMode: (m: Tri) => void + /** Якщо не задано — поле очистити не можна (значення обов'язкове). */ + clearLabel?: string + children: React.ReactNode +}) { + return ( +
    +
    + {label} + +
    + {hint &&

    {hint}

    } + {mode === 'set' &&
    {children}
    } +
    + ) +} + +/** + * Рядок форми для набору зв'язків. + * + * Чотири стани замість трьох — і це не ускладнення, а суть множини. + * «Додати до груп» і «прибрати з груп» — різні дії: хости в наборі + * мають різні поточні групи, і спільного «задати» для них не існує, + * поки людина не скаже, що старе можна викинути. «Замінити» лишено, + * бо без нього неможливо навести лад там, де вже наставлено абияк, — + * але окремим вибором і зі своїм попередженням. + */ +function ListRow({ + label, + hint, + mode, + onMode, + children, +}: { + label: string + hint?: string + mode: ListMode + onMode: (m: ListMode) => void + children: React.ReactNode +}) { + return ( +
    +
    + {label} + +
    + {hint &&

    {hint}

    } + {mode === 'replace' && ( +

    + Замінити — це стерти в цих хостах усе наявне й поставити рівно те, що обрано нижче. + Порожній вибір прибере все. +

    + )} + {mode !== 'keep' &&
    {children}
    } +
    + ) +} + +/** + * Ручні перевірки набору хостів. + * + * Чому це взагалі тут. Ручні перевірки колись лишили поза масовою + * формою з міркуванням, що вони поодинокі — те, чого немає в шаблонах. + * Поодинока насправді причина, а не кількість: «додати http.status усім + * вебсерверам дільниці» і «прибрати icmp.ping із сорока хостів, яким + * його колись наставили руками» — це одне рішення на сорок рядків, + * тобто рівно те, заради чого масова форма існує. + * + * Дії три, і кожна називає ТИП, якого стосується. «Замінити весь набір» + * тут немає навмисно: набори перевірок не видно в переліку хостів, тож + * заміна стерла б те, чого людина не бачила й побачити не могла. + * + * Автокеровані типи не пропонуються — так само, як у картці хоста: + * система завела свій такий чек сама, додати другий неможливо, а + * прибрати безглуздо, бо вона заведе його знову. + */ +function ChecksRow({ + types, + mode, + onMode, + type, + onType, + typeObj, + params, + onParams, + interval, + onInterval, + enabled, + onEnabled, + tuneIntervalOn, + onTuneIntervalOn, + tuneInterval, + onTuneInterval, + tuneTimeoutOn, + onTuneTimeoutOn, + tuneTimeout, + onTuneTimeout, + tuneRetriesOn, + onTuneRetriesOn, + tuneRetries, + onTuneRetries, + tuneEnabled, + onTuneEnabled, +}: { + types: CheckType[] + mode: ChecksMode + onMode: (m: ChecksMode) => void + type: string + onType: (k: string) => void + typeObj: CheckType | null + params: Record + onParams: (v: Record) => void + interval: number + onInterval: (v: number) => void + enabled: boolean + onEnabled: (v: boolean) => void + tuneIntervalOn: boolean + onTuneIntervalOn: (v: boolean) => void + tuneInterval: number + onTuneInterval: (v: number) => void + tuneTimeoutOn: boolean + onTuneTimeoutOn: (v: boolean) => void + tuneTimeout: number + onTuneTimeout: (v: number) => void + tuneRetriesOn: boolean + onTuneRetriesOn: (v: boolean) => void + tuneRetries: number + onTuneRetries: (v: number) => void + tuneEnabled: 'keep' | 'on' | 'off' + onTuneEnabled: (v: 'keep' | 'on' | 'off') => void +}) { + // Для «додати» потрібен увімкнений плагін — інакше зонд не має чим + // виконувати чек. Для «прибрати» й «змінити» — ні: рядки, що + // лишились від вимкненого плагіна, інакше не прибрати ніяк, а вони + // далі стоять у планах і на сторінці «Черги». + const pickable = types.filter((t) => !t.auto_managed && (mode !== 'add' || t.available)) + + return ( +
    +
    + Ручні перевірки + + {mode !== 'keep' && ( + + )} +
    +

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

    + + {mode === 'add' && typeObj && ( +
    +
    + + +
    + + + +

    + У текстових параметрах працює підстановка: {'{{address}}'} — адреса + хоста, {'{{name}}'} — імʼя, {'{{fqdn}}'} — доменне імʼя. + Наприклад http://{'{{address}}'}/health. Це єдине, чим хости в наборі + законно різняться; хост, у якого підставити нічого, потрапить у «не вдалося», а не + отримає зіпсовану перевірку. +

    +

    + Хостам, у яких перевірка цього типу вже є, нічого не станеться — вони підуть у «уже + було». Параметри наявних перевірок масово не міняються: у них різні значення, і + задати всім одне означало б зламати сорок перевірок одним рухом. +

    +
    + )} + + {mode === 'remove' && type && ( +

    + Перевірка зникне разом зі своїм розкладом; зібрані метрики лишаються. Якщо цей тип + заводить сама система (як опитування інтерфейсів), вона заведе його знову на + найближчому такті — прибирати такі руками нема сенсу. +

    + )} + + {mode === 'tune' && type && ( +
    + + + + + onTuneTimeout(Number(e.target.value))} + /> + + + onTuneRetries(Number(e.target.value))} + /> + + + + +

    + Знята галочка означає «не чіпати»: у кожного хоста лишається своє значення. Хости + без такої перевірки просто не потраплять у дію — це не помилка. +

    +
    + )} +
    + ) +} + +/** Поле «змінити наявні»: галочка вмикає саме це поле, а не всю дію. */ +function TuneField({ + label, + on, + onOn, + children, +}: { + label: string + on: boolean + onOn: (v: boolean) => void + children: React.ReactNode +}) { + return ( +
    + + {on && children} +
    + ) +} + +/** + * Наслідок дії над перевірками. + * + * Три списки, а не число. Масове додавання ГАРАНТОВАНО натрапляє на + * хости, у яких така перевірка вже є, — і це не помилка, а звичайний + * стан: половину дільниці колись налаштували руками. Показати + * «додано 12» і промовчати про решту означає збрехати; показати + * помилку — злякати на рівному місці. Тому те саме розрізнення, що й у + * відсіяних: зроблено / не було чого робити / не вдалося. + */ +function ChecksOutcome({ result, typeName }: { result: BulkChecksResult; typeName: string }) { + const verb: Record = { + add: 'Додано', + remove: 'Прибрано', + tune: 'Змінено', + } + const nothing: Record = { + add: 'Уже було', + remove: 'Не було чого прибирати', + tune: 'Немає такої перевірки', + } + + const rows: { title: string; tone: string; names: string[] }[] = [ + { + title: `${verb[result.op] ?? 'Зроблено'}: ${result.done.length}`, + tone: 'border-emerald-900/60 bg-emerald-950/20 text-emerald-100', + names: result.done, + }, + { + title: `${nothing[result.op] ?? 'Пропущено'}: ${result.skipped.length}`, + tone: 'border-slate-800 bg-slate-900/40 text-slate-300', + names: result.skipped.map((o) => o.device), + }, + { + title: `Не вдалося: ${result.failed.length}`, + tone: 'border-red-900/60 bg-red-950/20 text-red-100', + names: result.failed.map((o) => `${o.device} — ${o.reason}`), + }, + ] + + return ( +
    +
    + Перевірка «{typeName}» ({result.check_type}) +
    + {rows.map((r) => + r.names.length === 0 ? null : ( +
    +
    {r.title}
    +
      + {r.names.map((n) => ( +
    • · {n}
    • + ))} +
    +
    + ), + )} +

    + Решта змін у цій формі застосована до всіх хостів набору. +

    +
    + ) +} diff --git a/web/src/components/ChecksEditor.tsx b/web/src/components/ChecksEditor.tsx index 377d3e6..2827433 100644 --- a/web/src/components/ChecksEditor.tsx +++ b/web/src/components/ChecksEditor.tsx @@ -7,12 +7,16 @@ import type { CheckType } from '../types' // прохід по таблиці інтерфейсів коштує десятки OID-ів, і робити його // щоп'ять секунд означає навантажити обладнання заради даних, які // однаково усереднюються. -const DEFAULT_INTERVAL: Record = { +export const DEFAULT_INTERVAL: Record = { 'icmp.ping': 30, 'snmp.get': 60, 'snmp.if': 300, 'snmp.walk': 300, 'topology.discover': 3600, + // Розпізнавання ставить собі інтервал саме: часто, поки хост не + // впізнано, і рідко після. Значення тут — про всяк випадок: цей тип + // позначений auto_managed і в переліку для додавання не з'являється. + 'topology.identify': 21600, 'http.status': 60, 'http.ssl_expiry': 86400, 'ncm.backup': 86400, @@ -39,7 +43,10 @@ export function ChecksEditor({ const [adding, setAdding] = useState('') const used = new Set(checks.map((c) => c.check_type)) - const addable = types.filter((t) => t.available && !used.has(t.key)) + // Автокеровані типи не пропонуємо: систему вже завела свій такий чек + // і сама тримає йому розклад, а ручний дубль упирався б в унікальний + // індекс і повертався незрозумілою помилкою. + const addable = types.filter((t) => t.available && !t.auto_managed && !used.has(t.key)) function add(key: string) { const t = types.find((x) => x.key === key) @@ -142,7 +149,7 @@ export function ChecksEditor({ * типам. Складніші структури (перелік регістрів Modbus) поки редагуються * як текст: показати неповну форму гірше, ніж показати чесний JSON. */ -function ParamFields({ +export function ParamFields({ type, value, onChange, @@ -220,7 +227,7 @@ function ParamFields({ ) } -function defaultsFromSchema(t: CheckType): Record { +export function defaultsFromSchema(t: CheckType): Record { const out: Record = {} for (const [k, spec] of Object.entries(t.params_schema?.properties ?? {})) { if (spec?.default !== undefined) out[k] = spec.default diff --git a/web/src/components/ConfigDiff.tsx b/web/src/components/ConfigDiff.tsx new file mode 100644 index 0000000..8e3ce1d --- /dev/null +++ b/web/src/components/ConfigDiff.tsx @@ -0,0 +1,635 @@ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import type { CSSProperties, ReactNode, UIEvent } from 'react' +import { Button, plural } from './ui' +import { buildDiff, splitLines, wordDiff } from './linediff' +import type { DiffRow, Part } from './linediff' +import { + CODE_FONT, + ROW_H, + gutterWidth, + maxLen, + rowAt, + useCharWidth, + useVirtualWindow, +} from './ConfigViewer' +import { Minimap, buildShape } from './Minimap' +import type { MinimapMark } from './Minimap' + +/** + * Порівняння двох конфігів у дві колонки. + * + * Зліва стара версія, справа нова, рядок навпроти рядка. Там, де з + * одного боку рядка немає, стоїть заштрихований заповнювач — без нього + * колонки поїхали б після першої ж вставки, і «два вікна» перестали б + * означати «одне й те саме місце конфігу». + * + * Прокрутка вертикально спільна, горизонтально — окрема для кожного + * боку: рядки конфігу різної довжини, і змушувати дивитись на кінець + * короткого рядка тільки тому, що навпроти довгий, немає сенсу. + * + * На вузькому екрані дві колонки перетворюються на нечитабельні дві + * стрічки по двадцять символів, тому там вигляд сам стає одностовпцевим. + */ + +/** Скільки незмінених рядків лишається видимими навколо зміни. */ +const CONTEXT = 6 +/** Менші проміжки не згортаємо: кнопка «розгорнути» замість шести рядків — гірше за самі рядки. */ +const MIN_GAP = 10 +/** Крок часткового розгортання. */ +const STEP = 40 +/** Висота смуги згорнутої ділянки. */ +const GAP_H = 26 + +interface Gap { + from: number + to: number +} + +interface Item { + /** Індекс у rows або -1 для згорнутої ділянки. */ + row: number + gap: Gap | null +} + +const rowTone: Record = { + add: 'bg-emerald-950/60', + del: 'bg-red-950/60', + change: 'bg-amber-950/40', + equal: '', +} + +const gutterTone: Record = { + add: 'bg-emerald-950 text-emerald-500', + del: 'bg-red-950 text-red-500', + change: 'bg-amber-950 text-amber-500', + equal: 'bg-slate-900 text-slate-600', +} + +/** + * Заповнювач замість відсутнього рядка. + * + * Штриховка, а не просто темніший фон: порожній прямокутник читається + * як «тут порожній рядок конфігу», а це зовсім інше твердження. + */ +const FILLER: CSSProperties = { + backgroundImage: + 'repeating-linear-gradient(135deg, rgba(148,163,184,.06) 0 6px, transparent 6px 12px)', +} + +const sign: Record = { add: '+', del: '−', change: '~', equal: ' ' } + +/** Ділянки без змін, які можна сховати. */ +function collapse(rows: DiffRow[]): Gap[] { + const gaps: Gap[] = [] + let i = 0 + while (i < rows.length) { + if (rows[i].kind !== 'equal') { + i++ + continue + } + let j = i + while (j < rows.length && rows[j].kind === 'equal') j++ + // Початок і кінець файлу контексту не потребують — там нема чого + // відділяти від попередньої зміни. + const from = i === 0 ? i : i + CONTEXT + const to = j === rows.length ? j : j - CONTEXT + if (to - from >= MIN_GAP) gaps.push({ from, to }) + i = j + } + return gaps +} + +export function ConfigDiff({ + oldText, + newText, + oldLabel, + newLabel, +}: { + oldText: string + newText: string + oldLabel: string + newLabel: string +}) { + const oldLines = useMemo(() => splitLines(oldText), [oldText]) + const newLines = useMemo(() => splitLines(newText), [newText]) + const model = useMemo(() => buildDiff(oldLines, newLines), [oldLines, newLines]) + const rows = model.rows + + const [mode, setMode] = useState<'split' | 'unified'>('split') + const [narrow, setNarrow] = useState(false) + const rootRef = useRef(null) + + // Поріг живий, а не медіа-запит: панель ділить ширину з переліком + // версій і бічним меню, тож «широкий екран» ще не означає «широка + // панель». + useEffect(() => { + const el = rootRef.current + if (!el) return + const ro = new ResizeObserver(() => setNarrow(el.clientWidth < 860)) + ro.observe(el) + setNarrow(el.clientWidth < 860) + return () => ro.disconnect() + }, []) + + const split = mode === 'split' && !narrow + + const initial = useMemo(() => collapse(rows), [rows]) + const [gaps, setGaps] = useState(initial) + useEffect(() => setGaps(initial), [initial]) + + const { items, rowItem } = useMemo(() => { + const list: Item[] = [] + const map = new Int32Array(rows.length).fill(-1) + let i = 0 + let g = 0 + while (i < rows.length) { + if (g < gaps.length && gaps[g].from === i) { + list.push({ row: -1, gap: gaps[g] }) + i = gaps[g].to + g++ + continue + } + map[i] = list.length + list.push({ row: i, gap: null }) + i++ + } + return { items: list, rowItem: map } + }, [rows, gaps]) + + const offsets = useMemo(() => { + const o = new Int32Array(items.length + 1) + for (let i = 0; i < items.length; i++) { + const it = items[i] + const h = it.gap + ? GAP_H + : !split && rows[it.row].kind === 'change' + ? ROW_H * 2 + : ROW_H + o[i + 1] = o[i] + h + } + return o + }, [items, rows, split]) + + const vw = useVirtualWindow(offsets) + const { charW, probe } = useCharWidth() + const rightRef = useRef(null) + + // Верхній видимий рядок — щоб перемикання вигляду не викидало на + // початок файлу: у конфізі на 30 тисяч рядків повертатись до потрібного + // місця довелося б руками. + const offsetsRef = useRef(offsets) + offsetsRef.current = offsets + const topItem = useRef(0) + + // Синхронна вертикаль без прапорців-замків: колонка, якій уже + // виставили потрібне значення, просто нічого не робить у відповідь на + // власну подію прокрутки, і луна затухає на першому кроці. + const onMainScroll = useCallback( + (e: UIEvent) => { + vw.onScroll(e) + const t = e.currentTarget.scrollTop + topItem.current = rowAt(offsetsRef.current, t) + const r = rightRef.current + if (r && r.scrollTop !== t) r.scrollTop = t + }, + [vw], + ) + const onRightScroll = useCallback((e: UIEvent) => { + const t = e.currentTarget.scrollTop + const l = vw.ref.current + if (l && l.scrollTop !== t) l.scrollTop = t + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const firstRender = useRef(true) + useLayoutEffect(() => { + if (firstRender.current) { + firstRender.current = false + return + } + const el = vw.ref.current + if (el) el.scrollTop = offsetsRef.current[topItem.current] + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [split]) + + // Слова всередині змінених рядків рахуються ліниво, лише для видимих + // рядків, і запам'ятовуються: на прокрутці той самий рядок інакше + // перераховувався б щокадру. + const wcache = useRef(new Map()) + useEffect(() => { + wcache.current = new Map() + }, [rows]) + const parts = (idx: number, r: DiffRow) => { + let p = wcache.current.get(idx) + if (p === undefined) { + p = wordDiff(r.oldText, r.newText) + wcache.current.set(idx, p) + } + return p + } + + const blockItems = useMemo( + () => model.blocks.map((r) => rowItem[r]).filter((i) => i >= 0), + [model.blocks, rowItem], + ) + + /** + * Форма файлу для мінікарти. + * + * Береться нова версія, а зі старої — лише те, чого в новій немає + * (видалені рядки). Інакше мініатюра показувала б порожні смуги там, + * де щось прибрали, і місце видалення виглядало б як кінець файлу. + * Згорнута ділянка справді лишається порожньою: там і показувати + * нічого — рядки сховані. + */ + const shape = useMemo( + () => + buildShape( + items.map((it) => { + if (it.gap) return '' + const r = rows[it.row] + return r.newText || r.oldText + }), + ), + [items, rows], + ) + + /** + * Місця змін на мінікарті. + * + * Суцільними відрізками, а не рядок за рядком: блок на двісті + * видалених рядків — це одна позначка зі span, а не двісті. На файлі, + * що розійшовся цілком, різниця між цими двома підходами — тисячі + * записів у масиві проти десятків. + */ + const changeMarks = useMemo(() => { + const out: MinimapMark[] = [] + let i = 0 + while (i < items.length) { + const it = items[i] + if (it.gap || rows[it.row].kind === 'equal') { + i++ + continue + } + const kind = rows[it.row].kind + let j = i + 1 + while (j < items.length && !items[j].gap && rows[items[j].row].kind === kind) j++ + out.push({ + row: i, + span: j - i, + color: + kind === 'add' + ? 'rgba(52,211,153,0.9)' + : kind === 'del' + ? 'rgba(248,113,113,0.9)' + : 'rgba(251,191,36,0.9)', + }) + i = j + } + return out + }, [items, rows]) + + // Якір — те місце, куди «ставиться» зміна після переходу; від нього ж + // рахується, на якій зміні ми зараз. Інакше «наступна» одразу після + // переходу знаходила б ту саму. + const anchor = vw.scrollTop + ROW_H * 3 + 4 + let curBlock = -1 + for (let i = 0; i < blockItems.length; i++) { + if (offsets[blockItems[i]] <= anchor) curBlock = i + else break + } + + const goBlock = (delta: number) => { + if (blockItems.length === 0) return + let target: number + if (delta > 0) { + target = Math.min(curBlock + 1, blockItems.length - 1) + } else if (curBlock < 0) { + target = 0 + } else { + // Якщо поточна зміна вже втекла вгору за межі екрана, «назад» + // означає повернутись до неї, а не перестрибнути через голову. + const above = offsets[blockItems[curBlock]] < anchor - ROW_H + target = above ? curBlock : Math.max(0, curBlock - 1) + } + vw.scrollToRow(blockItems[target], 'top') + } + + const expand = (gap: Gap, how: 'up' | 'down' | 'all') => { + setGaps((cur) => + cur.flatMap((g) => { + if (g.from !== gap.from || g.to !== gap.to) return [g] + if (how === 'all') return [] + const next = how === 'up' ? { from: g.from + STEP, to: g.to } : { from: g.from, to: g.to - STEP } + return next.to - next.from >= MIN_GAP ? [next] : [] + }), + ) + } + + const gwOld = gutterWidth(oldLines.length, charW) + Math.ceil(charW) + 6 + const gwNew = gutterWidth(newLines.length, charW) + Math.ceil(charW) + 6 + const wOld = gwOld + Math.ceil(maxLen(oldLines) * charW) + 32 + const wNew = gwNew + Math.ceil(maxLen(newLines) * charW) + 32 + const gwUni = gutterWidth(oldLines.length, charW) + gutterWidth(newLines.length, charW) + Math.ceil(charW) + 8 + const wUni = gwUni + Math.ceil(Math.max(maxLen(oldLines), maxLen(newLines)) * charW) + 32 + + function painted(ps: Part[] | undefined, text: string, cls: string): ReactNode { + if (!ps) return text + return ps.map((p, i) => + p.changed ? ( + + {p.text} + + ) : ( + {p.text} + ), + ) + } + + function gapRow(it: Item, top: number, key: string) { + const g = it.gap as Gap + return ( +
    + + + + + +
    + ) + } + + /** Один бік двоколонкового вигляду. */ + function side(which: 'old' | 'new'): ReactNode[] { + const out: ReactNode[] = [] + const gw = which === 'old' ? gwOld : gwNew + const hl = which === 'old' ? 'rounded-sm bg-red-500/30' : 'rounded-sm bg-emerald-500/30' + for (let i = vw.first; i < vw.last; i++) { + const it = items[i] + const top = offsets[i] + if (it.gap) { + out.push(gapRow(it, top, `${which}-${i}`)) + continue + } + const r = rows[it.row] + const num = which === 'old' ? r.oldNum : r.newNum + const text = which === 'old' ? r.oldText : r.newText + if (num === 0) { + out.push( +
    , + ) + continue + } + const p = r.kind === 'change' ? parts(it.row, r) : null + out.push( +
    + + {num} + {sign[r.kind]} + + + {painted(which === 'old' ? p?.a : p?.b, text, hl)} + +
    , + ) + } + return out + } + + /** Один стовпець: класичний unified, змінений рядок займає два рядки. */ + function unified(): ReactNode[] { + const out: ReactNode[] = [] + const gwA = gutterWidth(oldLines.length, charW) + const gwB = gutterWidth(newLines.length, charW) + const line = ( + key: string, + top: number, + kind: string, + a: number, + b: number, + text: ReactNode, + ) => ( +
    + + + {a || ''} + + + {b || ''} + + {sign[kind]} + + {text} +
    + ) + + for (let i = vw.first; i < vw.last; i++) { + const it = items[i] + const top = offsets[i] + if (it.gap) { + out.push(gapRow(it, top, `u-${i}`)) + continue + } + const r = rows[it.row] + if (r.kind === 'change') { + const p = parts(it.row, r) + out.push( + line(`u-${i}-a`, top, 'del', r.oldNum, 0, painted(p?.a, r.oldText, 'rounded-sm bg-red-500/30')), + ) + out.push( + line( + `u-${i}-b`, + top + ROW_H, + 'add', + 0, + r.newNum, + painted(p?.b, r.newText, 'rounded-sm bg-emerald-500/30'), + ), + ) + continue + } + const text = r.kind === 'add' ? r.newText : r.oldText + out.push(line(`u-${i}`, top, r.kind, r.oldNum, r.newNum, text)) + } + return out + } + + const changes = blockItems.length + + // Аргументи мінікарти однакові для обох виглядів — вертикаль у них + // одна й та сама. Тримаємо одним об'єктом, щоб два місця не почали + // розходитись правками. + const mapProps = { + count: items.length, + offsets, + total: vw.total, + viewTop: vw.scrollTop, + viewH: vw.viewH, + shape, + marks: changeMarks, + // Права колонка — те, що є зараз; там, де рядка немає, беремо + // ліву. Мінікарта одна на обидві, і показувати вона має той бік, + // який людина читає як «поточний стан». + lineAt: (i: number) => { + const it = items[i] + if (!it || it.gap) return '' + const r = rows[it.row] + return r.newText || r.oldText || '' + }, + onSeek: vw.scrollToY, + } + + return ( +
    + {probe} + +
    + + +{model.added}{' '} + −{model.removed} + + + додано + видалено + змінено + + + {changes > 0 && ( + + + + {curBlock >= 0 ? curBlock + 1 : '–'} / {changes} + + + + )} + + + {narrow ? ( + вузько для двох колонок + ) : ( + <> + + + + )} + +
    + + {model.truncated && ( +

    + Версії розійшлися надто сильно — показано грубу заміну блоку, а не порядкові зміни +

    + )} + + {changes === 0 ? ( +

    + Ці дві версії однакові +

    + ) : split ? ( + <> +
    +
    + було · {oldLabel} +
    +
    + стало · {newLabel} +
    +
    +
    +
    +
    + {side('old')} +
    +
    +
    +
    + {side('new')} +
    +
    + {/* Одна мінікарта на обидві колонки: вертикаль у них + спільна, і друга смужка показувала б те саме вдруге. */} + +
    + + ) : ( +
    +
    +
    + {unified()} +
    +
    + +
    + )} +
    + ) +} + +function Chip({ cls, children }: { cls: string; children: ReactNode }) { + return ( + + + {children} + + ) +} diff --git a/web/src/components/ConfigViewer.tsx b/web/src/components/ConfigViewer.tsx new file mode 100644 index 0000000..31df0fe --- /dev/null +++ b/web/src/components/ConfigViewer.tsx @@ -0,0 +1,488 @@ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import type { ReactNode, UIEvent } from 'react' +import { api } from '../api/client' +import { Button, plural } from './ui' +import { splitLines } from './linediff' +import { Minimap, buildShape } from './Minimap' + +/** + * Перегляд тіла конфігу. + * + * Розрахунок на реальний масштаб, а не на демонстраційний: ZTE C320 + * віддає 32 854 рядки, і кожне рішення тут — наслідок саме цієї цифри. + * Рядки малюються не всі, а лише видимі; ширина блоку рахується з + * найдовшого рядка, щоб горизонтальна прокрутка не сіпалася під час + * вертикальної; пошук іде по заздалегідь опущеному регістру, бо + * приводити 1.5 МБ тексту на кожне натискання клавіші — це помітна оку + * затримка. + * + * Прокручується блок коду, а не сторінка: конфіг завжди ширший за + * екран, і горизонтальний скрол усієї сторінки забирав би з очей і + * панель версій, і шапку. + */ + +/** Висота рядка коду. Мусить збігатися з leading-5 у розмітці. */ +export const ROW_H = 20 + +/** Шрифт коду. Один рядок на всі три місця, де він потрібен, — щоб + * вимірювання ширини символа не розійшлося з тим, що намальовано. */ +export const CODE_FONT = 'font-mono text-[12px] leading-5' + +/** Останній рядок, що починається не нижче за y. */ +export function rowAt(offsets: Int32Array, y: number): number { + let lo = 0 + let hi = offsets.length - 2 + if (hi < 0) return 0 + while (lo < hi) { + const mid = (lo + hi + 1) >> 1 + if (offsets[mid] <= y) lo = mid + else hi = mid - 1 + } + return lo +} + +/** Накопичені координати верху рядків однакової висоти. */ +export function uniformOffsets(count: number, h: number): Int32Array { + const o = new Int32Array(count + 1) + for (let i = 0; i < count; i++) o[i + 1] = o[i] + h + return o +} + +/** + * Вікно віртуального списку. + * + * Тридцять тисяч рядків у DOM — це секунди на першу відмальовку й + * підвисання на кожній прокрутці. Тримаємо лише видиме плюс запас + * зверху й знизу, щоб швидкий скрол не показував порожнечу. + * + * offsets мають довжину count+1: останній елемент — повна висота. + */ +export function useVirtualWindow(offsets: Int32Array, overscan = 16) { + const ref = useRef(null) + const [node, setNode] = useState(null) + const [scrollTop, setScrollTop] = useState(0) + const [viewH, setViewH] = useState(480) + + // Прокручуваний елемент підставляється через callback-ref, а не через + // сам ref: перемикання «дві колонки / один стовпець» замінює вузол, і + // спостерігач, повішений один раз на монтуванні, стежив би за + // елементом, якого вже немає. + const attach = useCallback((n: HTMLDivElement | null) => { + ref.current = n + setNode(n) + }, []) + + useEffect(() => { + if (!node) return + setViewH(node.clientHeight) + // Новий вузол прокручений на початок, а стан пам'ятає позицію + // попереднього — без цього рядки малювалися б там, куди вже ніхто + // не дивиться, і людина бачила б порожнечу. + setScrollTop(node.scrollTop) + const ro = new ResizeObserver(() => setViewH(node.clientHeight)) + ro.observe(node) + return () => ro.disconnect() + }, [node]) + + const count = Math.max(0, offsets.length - 1) + const total = count > 0 ? offsets[count] : 0 + const first = Math.max(0, rowAt(offsets, scrollTop) - overscan) + const last = Math.min(count, rowAt(offsets, scrollTop + viewH) + 1 + overscan) + + const onScroll = useCallback((e: UIEvent) => { + setScrollTop(e.currentTarget.scrollTop) + }, []) + + /** Показати рядок: зверху з невеликим відступом або посередині. */ + const scrollToRow = useCallback( + (i: number, place: 'top' | 'center' = 'top') => { + const el = ref.current + if (!el || i < 0 || i >= offsets.length - 1) return + const y = + place === 'center' + ? offsets[i] - el.clientHeight / 2 + ROW_H + : offsets[i] - ROW_H * 3 + el.scrollTop = Math.max(0, y) + }, + [offsets], + ) + + /** Показати з початку — при заміні вмісту стара позиція безглузда. */ + const resetScroll = useCallback(() => { + if (ref.current) ref.current.scrollTop = 0 + setScrollTop(0) + }, []) + + /** + * Перейти на задану координату документа. + * + * Потрібно мінікарті: вона мислить пікселями файлу, а не номерами + * рядків, — саме тому, що один її піксель накриває півсотні рядків, і + * округлення до рядка зробило б тягу смикливою. + */ + const scrollToY = useCallback((y: number) => { + const el = ref.current + if (!el) return + el.scrollTop = Math.max(0, Math.min(el.scrollHeight - el.clientHeight, y)) + }, []) + + return { + ref, + attach, + scrollTop, + viewH, + first, + last, + total, + count, + onScroll, + scrollToRow, + scrollToY, + resetScroll, + } +} + +/** + * Ширина символа моноширинного шрифту. + * + * Потрібна, щоб порахувати ширину блоку коду наперед. Без цього ширина + * визначалася б найдовшим із НАМАЛЬОВАНИХ рядків, тобто змінювалася б + * під час прокрутки — і горизонтальний повзунок стрибав би сам собою. + * Вимірюємо живим елементом, а не константою: шрифт залежить від + * системи. + */ +export function useCharWidth(): { charW: number; probe: ReactNode } { + const ref = useRef(null) + const [charW, setCharW] = useState(7.2) + + useLayoutEffect(() => { + const el = ref.current + if (!el) return + const w = el.getBoundingClientRect().width / 20 + if (w > 0) setCharW(w) + }, []) + + const probe = ( + + 00000000000000000000 + + ) + return { charW, probe } +} + +/** Найдовший рядок — від нього рахується ширина блоку коду. */ +export function maxLen(lines: string[]): number { + let m = 0 + for (let i = 0; i < lines.length; i++) if (lines[i].length > m) m = lines[i].length + return m +} + +/** Ширина колонки з номерами під найбільший номер. */ +export function gutterWidth(count: number, charW: number): number { + return Math.ceil(String(Math.max(1, count)).length * charW) + 16 +} + +const bodyCache = new Map() + +/** + * Тіло конфігу з невеликим кешем. + * + * Перемикання «повний текст / порівняння» і вибір бази для порівняння + * повертають до тих самих версій по колу, а кожна з них — мегабайт + * тексту через мережу. Тримаємо кілька останніх; тримати всі означало б + * з'їсти пам'ять вкладки за десяток кліків. + */ +export async function fetchConfigBody(id: string): Promise { + const hit = bodyCache.get(id) + if (hit !== undefined) { + bodyCache.delete(id) + bodyCache.set(id, hit) + return hit + } + const r = await api.getConfig(id) + bodyCache.set(id, r.body) + while (bodyCache.size > 4) { + const oldest = bodyCache.keys().next() + if (oldest.done) break + bodyCache.delete(oldest.value) + } + return r.body +} + +interface Hits { + list: { line: number; at: number }[] + byLine: Map + len: number + capped: boolean +} + +const NO_HITS: Hits = { list: [], byLine: new Map(), len: 0, capped: false } + +function findHits(lower: string[], query: string): Hits { + const needle = query.toLowerCase() + if (needle === '') return NO_HITS + const list: { line: number; at: number }[] = [] + const byLine = new Map() + let capped = false + for (let i = 0; i < lower.length && !capped; i++) { + let at = lower[i].indexOf(needle) + if (at < 0) continue + const starts: number[] = [] + while (at >= 0) { + starts.push(at) + list.push({ line: i, at }) + // Двадцять тисяч збігів — це вже не пошук, а підсвічений файл; + // рахувати далі означає лише витрачати час на непотрібне. + if (list.length >= 20000) { + capped = true + break + } + at = lower[i].indexOf(needle, at + needle.length) + } + byLine.set(i, starts) + } + return { list, byLine, len: needle.length, capped } +} + +/** Рядок із підсвіченими збігами пошуку. */ +function marked(text: string, starts: number[] | undefined, len: number, cur: number): ReactNode { + if (!starts || starts.length === 0 || len === 0) return text + const out: ReactNode[] = [] + let pos = 0 + for (const s of starts) { + if (s > pos) out.push(text.slice(pos, s)) + out.push( + + {text.slice(s, s + len)} + , + ) + pos = s + len + } + if (pos < text.length) out.push(text.slice(pos)) + return out +} + +const searchInput = + 'w-40 rounded border border-slate-700 bg-slate-800 py-1 pl-2 pr-14 text-xs text-slate-100 ' + + 'outline-none placeholder:text-slate-600 focus:border-sky-600 md:w-56' + +export function ConfigViewer({ + text, + filename, + actions, +}: { + text: string + filename: string + /** Кнопки сторінки, які логічно стоять у цій же смузі. */ + actions?: ReactNode +}) { + const lines = useMemo(() => splitLines(text), [text]) + const lower = useMemo(() => lines.map((l) => l.toLowerCase()), [lines]) + const offsets = useMemo(() => uniformOffsets(lines.length, ROW_H), [lines.length]) + const vw = useVirtualWindow(offsets) + const { charW, probe } = useCharWidth() + + const [query, setQuery] = useState('') + const hits = useMemo(() => findHits(lower, query), [lower, query]) + const [cur, setCur] = useState(0) + const [copied, setCopied] = useState(false) + + // Новий запит — знову з першого збігу; інакше «3 з 5» лишалося б від + // попереднього слова й показувало не те, що підсвічено. + useEffect(() => setCur(0), [hits]) + + // Інша версія — інший конфіг: лишатись на тому самому номері рядка + // означало б показати випадкове місце чужого файлу. + const reset = vw.resetScroll + useLayoutEffect(() => reset(), [text, reset]) + + const go = useCallback( + (delta: number) => { + const n = hits.list.length + if (n === 0) return + const next = (cur + delta + n) % n + setCur(next) + vw.scrollToRow(hits.list[next].line, 'center') + }, + [cur, hits, vw], + ) + + // Перший збіг має бути видно одразу, без зайвого натискання «далі». + useEffect(() => { + if (hits.list.length > 0) vw.scrollToRow(hits.list[0].line, 'center') + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hits]) + + const width = gutterWidth(lines.length, charW) + Math.ceil(maxLen(lines) * charW) + 32 + const gw = gutterWidth(lines.length, charW) + const active = hits.list[cur] + + // Форма файлу для мінікарти. Рахується один раз на текст: перебір + // 33 000 рядків дешевий, але не на кожну прокрутку. + const shape = useMemo(() => buildShape(lines), [lines]) + + /** + * Збіги пошуку на мінікарті. + * + * По рядках, а не по кожному входженню: рядок із двадцятьма + * входженнями дає на смужці рівно ту саму одну лінію, і платити за + * нього двадцять разів немає за що. Стеля в тисячу — не про + * малювання (мінікарта однаково згортає до пікселя), а про розмір + * самого масиву: список збігів доходить до 20 000, і будувати з + * нього такий самий масив об'єктів щоразу, коли міняється запит, — + * єдина тут по-справжньому помітна витрата. + */ + /** + * Рядки зі збігами — для мінікарти. + * + * Без стелі. Попередній варіант обрізав перелік на тисячі позначок і + * тим брехав: у файлі з п'ятьма тисячами збігів мінікарта показувала + * лише перші дві тисячі, тобто «збіги є на початку, далі чисто». + * Людина робила з цього хибний висновок — а це рівно те, для чого + * вона на мінікарту й дивиться. + * + * Ключі Map і так є, множина з них — один прохід; малює мінікарта + * однаково не більше, ніж має пікселів. + */ + const hitRows = useMemo(() => new Set(hits.byLine.keys()), [hits]) + + const rows: ReactNode[] = [] + for (let i = vw.first; i < vw.last; i++) { + const starts = hits.byLine.get(i) + rows.push( +
    + + {i + 1} + + + {marked(lines[i], starts, hits.len, active && active.line === i ? active.at : -1)} + +
    , + ) + } + + return ( +
    + {probe} +
    +
    + setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + go(e.shiftKey ? -1 : 1) + } + if (e.key === 'Escape') setQuery('') + }} + /> + {query !== '' && ( + + {hits.list.length === 0 ? 'немає' : `${cur + 1}/${hits.list.length}${hits.capped ? '+' : ''}`} + + )} +
    + + + + {lines.length} {plural(lines.length, ['рядок', 'рядки', 'рядків'])} + +
    + {actions} + + +
    +
    + + {lines.length === 0 ? ( +

    Конфіг порожній

    + ) : ( + // Мінікарта збоку від тексту, у спільній рядковій смузі: вона + // має бути заввишки з саме прокручуваний блок, а не зі всю + // панель, інакше рамка вікна показувала б не те місце. +
    +
    +
    + {rows} +
    +
    + lines[i] ?? ''} + onSeek={vw.scrollToY} + /> +
    + )} +
    + ) +} + +/** Зберегти текст файлом. */ +export function download(text: string, filename: string) { + const url = URL.createObjectURL(new Blob([text], { type: 'text/plain;charset=utf-8' })) + const a = document.createElement('a') + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) +} + +/** + * Ім'я файла для збереження: без пробілів і всього, що ламає шляхи. + * + * Літери БУДЬ-ЯКОЇ абетки, а не лише латиниця. `\w` у JavaScript — це + * рівно ASCII, тому «Київ-ядро» і «Львів-ядро» давали однакове `_-_`: + * людина вивантажувала два конфіги й отримувала один файл — другий тихо + * затирав перший. На сервері цією ж хворобою хворів sanitizePath і + * вилікуваний тим самим: unicode.IsLetter замість латиниці, `\p{L}` із + * прапорцем `u` — його прямий відповідник. + * + * «..» згортається до однієї крапки з тієї ж причини, що й там: ім'я + * потрапляє в атрибут download як є, і ланцюжок крапок у ньому — це + * спроба вийти за теку завантажень. Крапки всередині імені законні: + * «10.0.0.1» має лишитись собою. + */ +export function safeName(parts: string[]): string { + let out = parts.join('-').replace(/[^\p{L}\p{N}._-]+/gu, '_') + while (out.includes('..')) out = out.replace(/\.\.+/g, '.') + return out +} diff --git a/web/src/components/DeviceFilterPanel.tsx b/web/src/components/DeviceFilterPanel.tsx new file mode 100644 index 0000000..ff3a472 --- /dev/null +++ b/web/src/components/DeviceFilterPanel.tsx @@ -0,0 +1,257 @@ +import { useMemo } from 'react' +import type { ReactNode } from 'react' +import { inputClass } from './ui' +import { kindLabel } from './DeviceForm' +import type { DeviceFilter, DeviceGroup, DeviceSummary, VersionOp } from '../types' + +/** + * Спільний відбір хостів. + * + * Три сторінки ставлять до інвентарю одне й те саме питання — «які саме + * хости» — і донедавна кожна питала його своїм набором елементів. Поки + * сторінок було дві, розбіжність коштувала одного зайвого перечитування + * коду; на третій вона почала коштувати оператору: людина, яка навчилась + * відбирати хости в «Командах», не має вчити другий набір понять у + * «Конфігах». + * + * Тому поля живуть тут, а сторінка додає до них лише те, що має сенс + * тільки на ній (див. `children`). Правило поділу просте: якщо питання + * ставлять до ХОСТА — воно тут; якщо до того, що з ним робить конкретна + * сторінка, — воно на сторінці. + * + * На сервері цим полям відповідає одна структура (store.DeviceFilter) і + * одна SQL-умова, тож нове поле досить додати в трьох місцях — тип, ця + * панель, умова, — і воно з'явиться скрізь одразу. + */ +export function DeviceFilterPanel({ + filter, + onChange, + devices, + groups, + children, +}: { + filter: DeviceFilter + onChange: (f: DeviceFilter) => void + /** Інвентар — джерело значень для перемикачів, а не перелік для показу. */ + devices: DeviceSummary[] + groups: DeviceGroup[] + /** Поля, що мають сенс лише на цій сторінці. Стають після спільних. */ + children?: ReactNode +}) { + // Довідники беремо з уже завантаженого інвентарю, а не окремими + // запитами: перелік хостів у пам'яті вже є, а виробники й майданчики — + // це буквально його стовпці. Так у фільтрі не з'явиться значення, + // якого в жодного хоста немає. + const vendors = useMemo(() => uniq(devices.map((d) => d.vendor ?? '')), [devices]) + const kinds = useMemo(() => uniq(devices.map((d) => d.kind)), [devices]) + const sites = useMemo(() => uniq(devices.map((d) => d.site_name ?? '')), [devices]) + const models = useMemo(() => uniq(devices.map((d) => d.model ?? '')), [devices]) + // Версії — не перемикачами, а підказкою до поля. + // + // Їх на дільниці бувають десятки, і ряд із десятків кнопок читається + // гірше за поле, у яке видно, що набирати. Крім того, питання до + // версії ставлять умовою («усе, крім…»), а не вибором зі списку. + const versions = useMemo(() => uniq(devices.map((d) => d.os_version ?? '')), [devices]) + + return ( +
    + onChange({ ...filter, query: e.target.value })} + /> + + ({ value: g.id, label: g.name }))} + selected={filter.group_ids ?? []} + onChange={(v) => onChange({ ...filter, group_ids: v })} + /> + ({ value: v, label: v }))} + selected={filter.vendors ?? []} + onChange={(v) => onChange({ ...filter, vendors: v })} + /> + ({ value: k, label: kindLabel[k] ?? k }))} + selected={filter.kinds ?? []} + onChange={(v) => onChange({ ...filter, kinds: v })} + /> + ({ value: s, label: s }))} + selected={filter.sites ?? []} + onChange={(v) => onChange({ ...filter, sites: v })} + /> + ({ value: m, label: m }))} + selected={filter.models ?? []} + onChange={(v) => onChange({ ...filter, models: v })} + /> + onChange({ ...filter, version_op: op, version_value: value })} + /> + onChange({ ...filter, statuses: v })} + /> + + {children} + + +
    + ) +} + +/** + * Ряд перемикачів-значень для одного поля фільтра. + * + * Усередині ряду значення складаються через АБО, між рядами — через І. + * Експортується разом із панеллю навмисно: поле, специфічне для однієї + * сторінки, має виглядати й поводитись так само, як спільні, інакше + * людина шукатиме в ньому іншу логіку. + */ +export function ChipRow({ + title, + options, + selected, + onChange, +}: { + title: string + options: { value: string; label: string }[] + selected: string[] + onChange: (v: string[]) => void +}) { + if (options.length === 0) return null + return ( +
    + {title} + {options.map((o) => { + const on = selected.includes(o.value) + return ( + + ) + })} +
    + ) +} + +/** + * Умова на версію ПЗ. + * + * Не ряд перемикачів, як у решти полів, і це не примха. Версій на + * дільниці бувають десятки, а головне питання до них звучить інакше: + * не «покажи ці», а «покажи все, що НЕ ця» — саме з цього починається + * планування оновлення прошивки. Переліком значень воно не виражається + * взагалі, тому тут операція плюс значення. + * + * П'ять операцій, і кожна відповідає на своє питання: + * «точно» — перевірка після оновлення; «усе, крім» — що лишилось + * оновити; «містить»/«не містить» — те саме на рівні сімейства релізу + * (у Juniper 14.1X53-D27.3 і 14.1X53-D46 — одне сімейство, і точний + * збіг тут не працює); «не визначена» — D-Link версії в sysDescr не + * повідомляє, і таких хостів може виявитись пів дільниці. + * + * «Усе, крім» навмисно захоплює й хости з невідомою версією: вони + * справді не на цільовій прошивці. Саме тому потрібна окрема операція, + * щоб подивитись на них. + */ +export function VersionRow({ + op, + value, + known, + onChange, +}: { + op?: VersionOp + value: string + known: string[] + onChange: (op: VersionOp | undefined, value: string) => void +}) { + const opts: { value: VersionOp | ''; label: string }[] = [ + { value: '', label: 'не важливо' }, + { value: 'eq', label: 'точно' }, + { value: 'ne', label: 'усе, крім' }, + { value: 'contains', label: 'містить' }, + { value: 'ncontains', label: 'не містить' }, + { value: 'empty', label: 'не визначена' }, + ] + + return ( +
    + Версія ПЗ + + + {op && op !== 'empty' && ( + <> + onChange(op, e.target.value)} + /> + {/* Підказка з тих версій, що справді є в інвентарі: набирати + «V100R006C01SPC100» з пам'яті ніхто не буде, а помилка в + одному символі дала б порожній перелік без пояснення. */} + + {known.map((v) => ( + + + )} +
    + ) +} + +export function uniq(values: string[]): string[] { + return [...new Set(values.filter(Boolean))].sort() +} diff --git a/web/src/components/DeviceForm.tsx b/web/src/components/DeviceForm.tsx index 4c9604e..fb38c9f 100644 --- a/web/src/components/DeviceForm.tsx +++ b/web/src/components/DeviceForm.tsx @@ -1,7 +1,6 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { api } from '../api/client' -import { Button, Confirm, ErrorNote, Field, Modal, Tabs, Toggle, inputClass } from './ui' -import type { ConfirmRequest } from './ui' +import { Button, ErrorNote, Field, Modal, Tabs, Toggle, inputClass } from './ui' import { Picker } from './Picker' import { TemplatePicker } from './TemplatePicker' import { ChecksEditor } from './ChecksEditor' @@ -89,10 +88,17 @@ export function DeviceForm({ const [checks, setChecks] = useState([]) const [checksLoaded, setChecksLoaded] = useState(!device) - const [confirm, setConfirm] = useState(null) const [busy, setBusy] = useState(false) const [err, setErr] = useState(null) + // Збереження вкладки «Збір конфігів». Розклад лежить в іншій таблиці + // й іншій ручці API, але для людини це одне вікно — тож і кнопка + // збереження тут одна, а вкладка лише віддає їй свою частину роботи. + const ncmSave = useRef<(() => Promise) | null>(null) + // Чи заходили на вкладку збору конфігів. Решта вкладок вільно + // розмонтовується, а ця — ні: див. коментар біля неї. + const [ncmSeen, setNcmSeen] = useState(false) + useEffect(() => { if (!device) return let alive = true @@ -166,7 +172,14 @@ export function DeviceForm({ return (
    - + { + if (t === 'ncm') setNcmSeen(true) + setTab(t) + }} + /> {tab === 'main' && ( <> @@ -203,8 +216,17 @@ export function DeviceForm({
    + {device?.sys_object_id && } +
    - + )} - {tab === 'ncm' && - (device ? ( - - ) : ( -
    -

    - Розклад збору конфігів кріпиться до хоста, тож налаштовується після його - створення. -

    -

    - Натисніть «Зберегти» — картка відкриється знову, і ця вкладка запрацює. Щоб профіль - підібрався сам, заповніть «Виробник» на вкладці «Хост». -

    -
    - ))} + {/* Відвідану вкладку збору конфігів ховаємо, а не розмонтовуємо. + Кнопка збереження в картці одна й лежить у футері — форма, + яку зняли при перемиканні вкладки, забрала б із собою і + незбережений розклад, і саме посилання на збереження, тож + зміни зникали б мовчки. */} + {device && ncmSeen && ( +
    + +
    + )} + + {tab === 'ncm' && !device && ( +
    +

    + Розклад збору конфігів кріпиться до хоста, тож налаштовується після його створення. +

    +

    + Натисніть «Зберегти» — далі можна буде одразу відкрити картку хоста, і ця вкладка + запрацює. Щоб профіль підібрався сам, заповніть «Виробник» на вкладці «Хост». +

    +
    + )} {tab === 'checks' && ( <> @@ -311,28 +340,21 @@ export function DeviceForm({ )} {err} - setConfirm(null)} />
    {onDelete && ( - )} @@ -344,6 +366,16 @@ export function DeviceForm({ setBusy(true) setErr(null) try { + // Спершу розклад збору конфігів, потім сам хост: + // успішне збереження хоста закриває вікно, і показати + // помилку розкладу вже не буде де. Свою помилку вкладка + // показує сама — нам лишається привести до неї людину. + try { + await ncmSave.current?.() + } catch { + setTab('ncm') + return + } await onSave({ name: name.trim(), address: address.trim(), @@ -373,3 +405,46 @@ export function DeviceForm({ ) } + +/** + * Що пристрій сказав про себе сам. + * + * Окремим блоком, а не полями форми: це не налаштування, а факт із + * мережі. Редагована модель у сусідстві з тією, що приїхала по SNMP, + * читалась би як пропозиція її поправити — а поправлене система все + * одно не перезапише, і людина лишилась би з двома різними правдами. + * + * sysDescr показуємо повністю: саме з нього дістаються модель і версія, + * і коли щось дісталось не так, дивитись треба сюди. + */ +function IdentityCard({ device }: { device: DeviceSummary }) { + const rows: [string, string][] = [ + ['Модель', device.model ?? ''], + ['Версія ПЗ', device.os_version ?? ''], + ['Серійний номер', device.serial_number ?? ''], + ['sysObjectID', device.sys_object_id ?? ''], + ].filter((r): r is [string, string] => r[1] !== '') + + return ( +
    +
    + Розпізнано по SNMP +
    +
    + {rows.map(([k, v]) => ( +
    +
    {k}:
    +
    + {v} +
    +
    + ))} +
    + {device.sys_descr && ( +

    + {device.sys_descr.trim()} +

    + )} +
    + ) +} diff --git a/web/src/components/DeviceMetricsView.tsx b/web/src/components/DeviceMetricsView.tsx new file mode 100644 index 0000000..82a9967 --- /dev/null +++ b/web/src/components/DeviceMetricsView.tsx @@ -0,0 +1,605 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { api } from '../api/client' +import { Button, Card, ErrorNote, ago, fmtValue, inputClass, plural } from './ui' +import { ChartLegend, MetricChart } from './MetricChart' +import { + OrphanNote, + OriginBadge, + matchesMetricFilter, + matchesMetricQuery, + metricFilters, + type MetricFilter, +} from './device/DeviceCollected' +import type { DeviceGraph, LatestMetric, MetricResult } from '../types' + +// Вікна перегляду. Ширші за добу вже читаються з роллапів, тому +// коштують стільки ж, скільки вузькі. +const RANGES: { key: string; label: string }[] = [ + { key: '30m', label: '30 хв' }, + { key: '6h', label: '6 год' }, + { key: '24h', label: 'доба' }, + { key: '7d', label: 'тиждень' }, + { key: '30d', label: 'місяць' }, +] + +// Скільки ліній малювати одразу. Більше однаково не читається, а сервер +// відмовляє після двадцяти. +const MAX_LINES = 6 + +/** + * Графіки одного хоста. + * + * Винесено зі сторінки «Метрики», щоб те саме показувати у вкладці + * картки хоста. Другої реалізації графіків у продукті бути не повинно: + * дві розійшлися б у дрібницях — межах осей, виборі роллапу, — і + * людина, яка порівнює два екрани, не знала б, якому вірити. + * + * Діапазон і добір рядів компонент тримає сам: вони належать графіку, а + * не сторінці, і на картці хоста для них немає своєї шапки. + * + * Перелік рядів береться з `/latest`, а не з `/series`, і це головна + * зміна в цьому файлі. `/series` віддає лише «що є», а питання, з яким + * до двохсот рядів приходять, — «що з них ще живе»: на стенді 471 ряд + * із 496 не має за собою жодної перевірки й не оновиться вже ніколи. + * Відповідь на це `/latest` уже рахує (походження, `stale`), і другого + * джерела тих самих фактів заводити не можна — вони розійшлися б, і + * ряд вважався б мертвим у картці хоста й живим у графіках. Заразом + * той запит ще й дешевший: 1.29 мс проти 2.42 мс на хості з 200 рядами + * (`/series` робить два корельовані підзапити на кожен ряд, `/latest` — + * один LATERAL). + */ +export function DeviceMetricsView({ + deviceID, + compact = false, + initialSeries, +}: { + deviceID: string + /** Вужча колонка — картка хоста віддає графікам менше місця, ніж сторінка. */ + compact?: boolean + /** + * Ряди, з яких почати. + * + * Приходить із «Останніх даних»: людина побачила число й хоче + * побачити, як воно змінювалось. Без цього перехід між вкладками + * означав би знайти той самий ряд удруге серед двохсот. + */ + initialSeries?: number[] +}) { + const [metrics, setMetrics] = useState([]) + const [orphans, setOrphans] = useState(0) + const [selected, setSelected] = useState([]) + const [range, setRange] = useState('6h') + const [data, setData] = useState(null) + const [graphs, setGraphs] = useState([]) + // Готові графіки з шаблонів чи власний добір метрик. + // + // Шаблонні першими: у дев'яти випадках із десяти людина приходить + // подивитись «як там пам'ять», а не збирати графік з нуля. + const [mode, setMode] = useState<'templates' | 'custom'>('templates') + const [busy, setBusy] = useState(false) + const [err, setErr] = useState(null) + + // Добір рядів усередині хоста. Ті самі два питання, що в «Останніх + // даних» картки хоста, і навмисно тими самими словами. + const [query, setQuery] = useState('') + const [state, setState] = useState('all') + const [open, setOpen] = useState>({}) + + // Перелік рядів хоста. Вибір скидається разом із хостом: id рядів + // наскрізні, і лишити їх означало б попросити чужі дані. + useEffect(() => { + if (!deviceID) return + let alive = true + setMetrics([]) + setOrphans(0) + setSelected([]) + setData(null) + setQuery('') + setState('all') + setOpen({}) + api + .deviceGraphs(deviceID) + .then((g) => { + if (!alive) return + setGraphs(g) + // Без шаблонних графіків показувати порожню вкладку немає сенсу. + // Названий ряд теж переважає шаблони: людина прийшла по нього. + setMode(g.length > 0 && !initialSeries?.length ? 'templates' : 'custom') + }) + .catch(() => alive && setGraphs([])) + api + .deviceLatest(deviceID) + .then((d) => { + if (!alive) return + setMetrics(d.metrics) + setOrphans(d.orphans) + // Одразу показуємо перші кілька: порожній графік із проханням + // щось відмітити — це зайвий крок перед відповіддю на питання + // «що тут відбувається». + // + // Але саме ЖИВІ. Це найдешевша частина всієї правки: на + // JUN.QFX перші ряди за абеткою — це порти, які стоять із + // 25 серпня, і графік «за замовчуванням» відкривався порожнім. + // Людина при цьому не робила нічого неправильного, а бачила + // зламану сторінку. + setSelected( + initialSeries?.length + ? initialSeries.slice(0, MAX_LINES) + : pickInitial(d.metrics, MAX_LINES), + ) + }) + .catch((e) => alive && setErr(e instanceof Error ? e.message : String(e))) + return () => { + alive = false + } + }, [deviceID, initialSeries]) + + const load = useCallback(() => { + if (!deviceID || selected.length === 0) { + setData(null) + return + } + setBusy(true) + api + .queryMetrics(deviceID, selected, range) + .then(setData) + .catch((e) => setErr(e instanceof Error ? e.message : String(e))) + .finally(() => setBusy(false)) + }, [deviceID, selected, range]) + + useEffect(load, [load]) + + // Групуємо за одиницею виміру: відсотки й біти на секунду на спільній + // осі перетворюють графік на пряму лінію біля нуля. + const groups = useMemo(() => { + const by = new Map() + for (const s of data?.series ?? []) { + const k = s.unit || '—' + const list = by.get(k) ?? [] + list.push(s) + by.set(k, list) + } + return [...by.entries()] + }, [data]) + + // --- добір рядів ------------------------------------------------------ + + const q = query.trim().toLowerCase() + const rows = useMemo( + () => metrics.filter((m) => matchesMetricFilter(m, state) && matchesMetricQuery(m, q)), + [metrics, state, q], + ) + + /** + * Ряди складаються в купки за КЛЮЧЕМ метрики, а не за портом. + * + * Двісті рядів JUN.QFX — це сто портів по дві метрики, і скласти їх + * можна по будь-якій із двох осей. Але складання по портах дало б сто + * купок замість двохсот рядків, тобто те саме гортання; складання по + * ключу дає шість — рівно стільки, скільки хост насправді вимірює. + * + * Другу вісь бере на себе пошук: набране «xe-0/0/12» лишає по одному + * ряду в кожній купці, тобто саме ту пару «вхід + вихід», заради якої + * до порту й приходять. Складання по портах, навпаки, не рятує від + * питання «покажи вхідний трафік на п'яти аплінках» узагалі. + * + * Та сама вісь, що в «Останніх даних» картки хоста, — і це не збіг: + * двоє однакових переліків, згорнутих по-різному, людина сприймає як + * різні дані. + */ + const seriesGroups = useMemo(() => { + const by = new Map() + for (const m of rows) { + const list = by.get(m.metric_key) ?? [] + list.push(m) + by.set(m.metric_key, list) + } + return [...by.entries()] + }, [rows]) + + const toggle = (id: number) => + setSelected((prev) => + prev.includes(id) + ? prev.filter((x) => x !== id) + : prev.length >= MAX_LINES + ? prev + : [...prev, id], + ) + + // Ряд за його id — щоб про намальовану лінію можна було спитати те, + // що знає лише перелік: чи вона ще жива й коли востаннє оновлювалась. + const byID = useMemo(() => { + const m = new Map() + for (const x of metrics) m.set(x.series_id, x) + return m + }, [metrics]) + + /** + * Чому цей графік порожній. + * + * Порожнє полотно має дві причини, і плутати їх найдорожче саме тут: + * «за цей період нічого не приходило» — це або вузьке вікно, або + * збій збору; «ці ряди більше не наповнюють» — це історія, і ширше + * вікно тут єдине, що допоможе. На стенді є хост (Леніна.21), у + * якого мертві ВСІ 62 ряди: без цього рядка його графіки виглядають + * як зламана сторінка. + */ + const emptyNoteFor = (list: MetricResult['series']): string | undefined => { + const known = list.map((s) => byID.get(s.id)).filter((m): m is LatestMetric => !!m) + if (known.length === 0 || !known.every((m) => m.stale)) return undefined + const last = known + .map((m) => m.last_at) + .filter(Boolean) + .sort() + .slice(-1)[0] + return `Ці ряди більше не оновлюються — останнє значення ${ago(last)}. Візьміть ширше вікно або інший ряд.` + } + + return ( +
    + {err} + +
    + {graphs.length > 0 && ( +
    + + +
    + )} +
    + {RANGES.map((r) => ( + + ))} +
    +
    + + {metrics.length === 0 ? ( +

    + Для цього хоста ще нічого не зібрано. Метрики з’являться після першого успішного + опитування — перевірте, що хост увімкнено й до нього прив’язано зонд. +

    + ) : mode === 'templates' ? ( +
    + {graphs.map((g) => ( + + ))} +
    + ) : ( +
    +
    + {/* Та сама плашка, що в картці хоста, — не другий її варіант. + Стоїть над добором, бо пояснює саме те, що людина зараз у + ньому побачить. */} + + + +
    + setQuery(e.target.value)} + /> + {/* Ті самі чотири стани й ті самі слова, що в «Останніх + даних»: другий набір означав би, що «застрягли» тут і + там — можливо, різні речі. */} +
    + {metricFilters.map((f) => ( + + ))} +
    +
    + + Вибрано {selected.length} з {MAX_LINES} + + + {rows.length} з {metrics.length} {plural(metrics.length, ['ряду', 'рядів', 'рядів'])} + +
    +
    + +
    + {seriesGroups.length === 0 ? ( +

    Нічого не знайдено

    + ) : ( + seriesGroups.map(([key, list]) => { + // Купку з двох-трьох рядів згортати немає сенсу; + // купку на сто портів — навпаки. Пошук, що звузив + // купку до трьох, розгортає її тим самим правилом. + const expanded = open[key] ?? list.length <= 3 + const head = list[0] + const chosen = list.filter((m) => selected.includes(m.series_id)).length + return ( +
    + + + {expanded && ( +
      + {list.map((m) => ( +
    • + +
    • + ))} +
    + )} +
    + ) + }) + )} +
    +
    +
    + +
    + {data && ( +

    + Крок {data.step_sec} с, джерело{' '} + {data.source === 'raw' ? 'сирі дані' : `роллап ${data.source}`} + {busy && ' · оновлюємо…'} +

    + )} + + {groups.length === 0 ? ( + +

    + {selected.length === 0 ? 'Відмітьте метрику зліва' : 'За цей період даних немає'} +

    +
    + ) : ( + groups.map(([unit, list]) => ( + +
    + + {unit === '—' ? 'без одиниці' : unit} + + +
    + +
    + )) + )} +
    +
    + )} +
    + ) +} + +/** + * З чого почати, коли людина нічого не називала. + * + * Спершу живі ряди, і лише якщо живих немає — будь-які. Порядок + * усередині лишається серверний (ключ, потім порт), тож добір + * передбачуваний: це перші кілька рядів переліку, а не «якісь». + * + * Три, а не MAX_LINES: шість ліній із першого ж кадру читаються гірше + * за три, а додати решту — один клац. + */ +function pickInitial(metrics: LatestMetric[], max: number): number[] { + const live = metrics.filter((m) => !m.stale && m.origin.kind !== 'none') + const from = live.length > 0 ? live : metrics + return from.slice(0, Math.min(3, max)).map((m) => m.series_id) +} + +/** + * Один графік, описаний у шаблоні. + * + * Вантажить себе сам: графіків на хості буває десяток, і вимикати + * половину з них заради одного спільного запиту означало б чекати на + * найповільніший. + */ +function TemplateGraphCard({ + graph, + deviceID, + range, + emptyNoteFor, +}: { + graph: DeviceGraph + deviceID: string + range: string + /** + * Чому цей графік порожній — рахує батько, бо лише в нього є перелік + * рядів зі станом. Шаблонний графік — типовий вигляд сторінки, і + * саме на ньому обрив лінії помічають першим. + */ + emptyNoteFor: (list: MetricResult['series']) => string | undefined +}) { + const [data, setData] = useState(null) + const [err, setErr] = useState(null) + + useEffect(() => { + let alive = true + api + .queryMetrics(deviceID, graph.series_ids, range, 250) + .then((r) => alive && setData(r)) + .catch((e) => alive && setErr(e instanceof Error ? e.message : String(e))) + return () => { + alive = false + } + }, [deviceID, range, graph.series_ids]) + + const single = graph.kind === 'stat' || graph.kind === 'gauge' + const last = data?.series[0]?.points.filter((p) => p.v != null).slice(-1)[0]?.v ?? null + + return ( + +
    + {graph.name} + {graph.template_name} +
    + + {err ? ( +

    {err}

    + ) : !data ? ( +

    Завантаження…

    + ) : single ? ( + + ) : ( + <> + +
    + +
    + + )} +
    + ) +} + +function SingleValue({ + graph, + value, + note, +}: { + graph: DeviceGraph + value: number | null + /** Пояснення порожньої плитки, коли воно є. Див. MetricChart.emptyNote. */ + note?: string +}) { + if (value == null) { + return ( +

    + {note || 'Даних немає'} +

    + ) + } + if (graph.kind === 'stat') { + return ( +
    + + {fmtValue(value)} + + {graph.options?.unit} +
    + ) + } + + const min = graph.options?.min ?? 0 + const max = graph.options?.max ?? 100 + const pct = Math.max(0, Math.min(1, (value - min) / Math.max(1e-9, max - min))) + // Ті самі пороги, що на шкалах дашборда: до 70 % спокійно, до 90 % + // варто глянути, далі погано. + const color = pct >= 0.9 ? '#ef4444' : pct >= 0.7 ? '#f59e0b' : '#34d399' + + return ( +
    +
    + + {fmtValue(value)} + + {graph.options?.unit} +
    +
    +
    +
    +
    + ) +} diff --git a/web/src/components/DeviceNode.tsx b/web/src/components/DeviceNode.tsx index 71bfb9b..2f50707 100644 --- a/web/src/components/DeviceNode.tsx +++ b/web/src/components/DeviceNode.tsx @@ -1,6 +1,7 @@ import { Fragment } from 'react' import type { ReactNode } from 'react' import { Handle, Position, type Node, type NodeProps } from '@xyflow/react' +import { NODE_DEFAULTS } from './mapStyle' import type { DeviceStatus } from '../types' export type DeviceNodeData = { @@ -30,8 +31,14 @@ export type DeviceNodeData = { iconId?: string /** Розмір картинки або крапки в пікселях. */ iconSize?: number - /** Показувати цифри пінга. */ + /** Не показувати цифри пінга. */ hideMetrics?: boolean + /** Адреса хоста. */ + address?: string + /** Виводити адресу під іменем. */ + showAddress?: boolean + /** Хост прибрали з обліку, а вузол лишився стояти на схемі. */ + deleted?: boolean } export type DeviceNodeType = Node @@ -108,23 +115,31 @@ const HANDLE_CLASS = 'transition-opacity group-hover:opacity-100 hover:!bg-sky-400' export function DeviceNode({ data, selected }: NodeProps) { - const s = STATUS[data.status || 'unknown'] ?? STATUS.unknown + // Видалений хост має вигляд «невідомо», а не останнього стану: + // зелена крапка означала б живий пристрій, якого вже немає в обліку. + const gone = data.deleted === true + const s = gone ? STATUS.unknown : (STATUS[data.status || 'unknown'] ?? STATUS.unknown) // 'none' — свідома відсутність значка, а не «не задано»: на схемі з // власними картинками типовий квадратик поруч із ними лише заважає. // Значка типово немає: поруч із крапкою він лише додає шуму, а сенс // несе колір стану. const showIcon = data.icon !== undefined && data.icon !== '' && data.icon !== 'none' const icon = ICONS[data.icon ?? data.kind] ?? ICONS.other - const size = data.size ?? 'sm' + const size = data.size ?? NODE_DEFAULTS.size const textSize = TEXT_SIZE[size] ?? TEXT_SIZE.md // Типовий вигляд — крапка з підписом знизу, без значка й цифр. // // Картка з рамкою добре читається на схемі з десяти вузлів і // перетворює схему на сітку прямокутників уже на п'ятдесяти. Крапка // лишає читабельним головне: де вузол і якого він стану. - const shape = data.shape ?? 'dot' - const labelPos = data.labelPos ?? 'bottom' - const accent = data.color ?? s.raw + // + // Значення беруться з NODE_DEFAULTS, а не пишуться тут числом: ті + // самі типові має знати й інспектор, інакше він показує одне, а + // полотно малює інше (саме так і було: форма казала «картка» про + // вузол, який насправді крапка). + const shape = data.shape ?? NODE_DEFAULTS.shape + const labelPos = data.labelPos ?? NODE_DEFAULTS.labelPos + const accent = gone ? s.raw : (data.color ?? s.raw) // Виділення — кільце того ж кольору, що й акцент інтерфейсу, поверх // тіні стану. Два різні кільця одне на одному читаються як брак, тому @@ -165,13 +180,46 @@ export function DeviceNode({ data, selected }: NodeProps) { ) + // Що стоїть під іменем. + // + // Умова була `hideMetrics === false`, тобто цифри показувались лише + // тоді, коли хтось явно записав у стиль «false». А інспектор такого + // не пише ніколи — знявши галочку, він просто прибирає ключ. Тому + // RTT не з'являвся на жодному вузлі, і галочка «не показувати цифри + // пінга» виглядала як завжди ввімкнена. + const showMetrics = !data.hideMetrics && (data.rtt !== undefined || data.loss !== undefined) + const sub = + (data.showAddress && data.address) || showMetrics ? ( + + {data.showAddress && data.address && ( + {data.address} + )} + {showMetrics && data.rtt !== undefined && ( + {data.rtt.toFixed(2)} мс + )} + {showMetrics && data.loss !== undefined && data.loss > 0 && ( + втрати {data.loss.toFixed(0)}% + )} + + ) : null + + // Підпис видаленого хоста закреслений, а не прихований: людина має + // впізнати вузол, який збирається прибрати зі схеми. const label = data.hideLabel || labelPos === 'none' ? null : ( - {data.label} + + {data.label} + + {sub} ) @@ -272,6 +320,9 @@ export function DeviceNode({ data, selected }: NodeProps) { {data.label} + {/* Пігулка — форма в один рядок, тому підпис під іменем стає + праворуч від нього, а не зникає. */} + {sub}
    ) } @@ -304,15 +355,10 @@ export function DeviceNode({ data, selected }: NodeProps) {
    {/* Цифри показуємо лише коли вони є: порожній рядок «— мс» - створює враження зламаного моніторингу. */} - {data.hideMetrics === false && (data.rtt !== undefined || data.loss !== undefined) && ( -
    - {data.rtt !== undefined && {data.rtt.toFixed(2)} мс} - {data.loss !== undefined && data.loss > 0 && ( - втрати {data.loss.toFixed(0)}% - )} -
    - )} + створює враження зламаного моніторингу. Той самий блок, що + й під крапкою, — щоб зміна форми вузла не міняла того, ЩО + про нього написано. */} + {sub &&
    {sub}
    }
    ) diff --git a/web/src/components/EdgeInspector.tsx b/web/src/components/EdgeInspector.tsx index 355c12c..18ed5ee 100644 --- a/web/src/components/EdgeInspector.tsx +++ b/web/src/components/EdgeInspector.tsx @@ -1,6 +1,16 @@ -import { useEffect, useState } from 'react' -import { Button, Field, Toggle, inputClass } from './ui' -import type { EdgeInput, MapEdge, MapNode } from '../types' +import { useEffect, useMemo, useState } from 'react' +import { Button, Field, Toggle, formatBps, inputClass } from './ui' +import { Check, InspectorPanel, Row, Section } from './InspectorShell' +import { EDGE_TONE_CLASS, edgeState } from './edgeState' +import { + EDGE_DEFAULTS, + EDGE_LABEL_DEFAULT, + EDGE_LABEL_FIELDS, + EDGE_LABEL_FIELD_TITLE, + autoSides, + type EdgeLabelField, +} from './mapStyle' +import type { EdgeInput, MapEdge, MapInterface, MapNode } from '../types' // Форма лінії. smoothstep — типова: прямі кути читаються як схема, а не // як павутина, і саме так малюють мережі на папері. @@ -28,9 +38,23 @@ const DASHES: { key: string; label: string }[] = [ { key: 'dotted', label: 'крапками' }, ] +// Стрілка — не оформлення, а відповідь на питання «куди дивиться +// напрямок»: на лінії, підписаній портами A→Б, без неї не видно, який +// кінець який. +const ARROWS: { key: string; label: string }[] = [ + { key: 'none', label: 'без стрілки' }, + { key: 'forward', label: 'A → Б' }, + { key: 'both', label: 'в обидва боки' }, +] + /** * Інспектор зв'язку. * + * Налаштування розкладені по групах, а не одним стовпчиком. Причина не + * в місці: «до якого порту ввімкнено» — це факт про мережу, «якою + * товщиною малювати» — оформлення, і читати їх поспіль означає щоразу + * перебирати весь список очима. + * * Колір лінії рахується зі стану: зелений — працює, жовтий і червоний — * за порогами завантаження, червоний пунктир — обрив. Власний колір * перекриває цей розрахунок, і саме тому він тут окремим полем із @@ -40,29 +64,48 @@ const DASHES: { key: string; label: string }[] = [ export function EdgeInspector({ edge, nodes, + interfaces, readOnly, + edgeCount = 1, onPatch, + onPatchAll, onDelete, onClose, + onDiscover, + discovering, }: { edge: MapEdge nodes: MapNode[] + /** Порти хостів мапи — для ручного зіставлення. Порожньо, доки не зібрано інвентар. */ + interfaces: MapInterface[] readOnly: boolean + /** Скільки всього ліній на мапі — для «застосувати до всіх». */ + edgeCount?: number onPatch: (patch: EdgeInput, comment: string) => Promise | void + /** Той самий вигляд на всі лінії мапи. */ + onPatchAll?: (patch: Omit, comment: string) => Promise | void onDelete: () => void onClose: () => void + /** «Зібрати топологію» зі сторінки мапи — щоб стан вів до дії, а не був ярликом. */ + onDiscover?: () => void + discovering?: boolean }) { const [label, setLabel] = useState(edge.label ?? '') const [style, setStyle] = useState(edge.style) const [dash, setDash] = useState(edge.dash) const [color, setColor] = useState(edge.color ?? '') - const [width, setWidth] = useState(String(edge.width_px ?? 2)) - const [warn, setWarn] = useState(String(edge.thresholds?.warn_pct ?? 70)) - const [crit, setCrit] = useState(String(edge.thresholds?.crit_pct ?? 90)) + const [width, setWidth] = useState(String(edge.width_px ?? EDGE_DEFAULTS.widthPx)) + const [warn, setWarn] = useState(String(edge.thresholds?.warn_pct ?? EDGE_DEFAULTS.warnPct)) + const [crit, setCrit] = useState(String(edge.thresholds?.crit_pct ?? EDGE_DEFAULTS.critPct)) const [animated, setAnimated] = useState(edge.animation?.enabled !== false) const [showMetrics, setShowMetrics] = useState(edge.show_metrics) const [srcSide, setSrcSide] = useState(edge.source_handle || 'auto') const [dstSide, setDstSide] = useState(edge.target_handle || 'auto') + const [srcIf, setSrcIf] = useState(edge.source_interface_id ?? '') + const [dstIf, setDstIf] = useState(edge.target_interface_id ?? '') + const [fields, setFields] = useState(edge.view?.show ?? EDGE_LABEL_DEFAULT) + const [labelAt, setLabelAt] = useState(String(Math.round((edge.view?.label_at ?? 0) * 100))) + const [arrow, setArrow] = useState(edge.view?.arrow ?? EDGE_DEFAULTS.arrow) const [busy, setBusy] = useState(false) // Вибір іншої лінії має перезаряджати форму, інакше вона показувала б @@ -72,48 +115,122 @@ export function EdgeInspector({ setStyle(edge.style) setDash(edge.dash) setColor(edge.color ?? '') - setWidth(String(edge.width_px ?? 2)) - setWarn(String(edge.thresholds?.warn_pct ?? 70)) - setCrit(String(edge.thresholds?.crit_pct ?? 90)) + setWidth(String(edge.width_px ?? EDGE_DEFAULTS.widthPx)) + setWarn(String(edge.thresholds?.warn_pct ?? EDGE_DEFAULTS.warnPct)) + setCrit(String(edge.thresholds?.crit_pct ?? EDGE_DEFAULTS.critPct)) setAnimated(edge.animation?.enabled !== false) setShowMetrics(edge.show_metrics) setSrcSide(edge.source_handle || 'auto') setDstSide(edge.target_handle || 'auto') + setSrcIf(edge.source_interface_id ?? '') + setDstIf(edge.target_interface_id ?? '') + setFields(edge.view?.show ?? EDGE_LABEL_DEFAULT) + setLabelAt(String(Math.round((edge.view?.label_at ?? 0) * 100))) + setArrow(edge.view?.arrow ?? EDGE_DEFAULTS.arrow) }, [edge]) const nameOf = (id: string) => nodes.find((n) => n.id === id)?.label ?? '—' + // Порти показуємо лише свого хоста: перелік усіх портів мапи в + // випадному списку — це сотні рядків, у яких потрібний не знайти. + const srcDevice = nodes.find((n) => n.id === edge.source_node_id)?.device_id ?? '' + const dstDevice = nodes.find((n) => n.id === edge.target_node_id)?.device_id ?? '' + const srcPorts = useMemo( + () => interfaces.filter((i) => i.device_id === srcDevice), + [interfaces, srcDevice], + ) + const dstPorts = useMemo( + () => interfaces.filter((i) => i.device_id === dstDevice), + [interfaces, dstDevice], + ) + + // Видалений хост на кінці перекриває решту станів: доки його не + // повернули, звіряти лінію нема з чим. + const endpointGone = [edge.source_node_id, edge.target_node_id].some( + (id) => nodes.find((n) => n.id === id)?.device_deleted, + ) + const st = edgeState(edge, endpointGone) + + // Бік, який суперечить розташуванню вузлів. + // + // Лінія, що виходить з ВЕРХУ вузла до сусіда, який стоїть ЗНИЗУ, + // мусить обійти обидва вузли — React Flow чесно малює гак через + // півсхеми. Найчастіша причина — один невдалий рух мишею при + // малюванні, і виправити його інакше як видаленням лінії досі було + // ніяк: у формі просто стояло «зверху», і чому лінія крива, звідти + // не читалось. + const sideAdvice = useMemo(() => { + const a = nodes.find((n) => n.id === edge.source_node_id) + const b = nodes.find((n) => n.id === edge.target_node_id) + if (!a || !b) return null + const [wantSrc, wantDst] = autoSides(a, b) + const bad = + (srcSide !== 'auto' && srcSide !== wantSrc) || (dstSide !== 'auto' && dstSide !== wantDst) + return bad ? { wantSrc, wantDst } : null + }, [nodes, edge.source_node_id, edge.target_node_id, srcSide, dstSide]) + + const viewFields = edge.view?.show ?? EDGE_LABEL_DEFAULT const dirty = label !== (edge.label ?? '') || style !== edge.style || dash !== edge.dash || color !== (edge.color ?? '') || - width !== String(edge.width_px ?? 2) || - warn !== String(edge.thresholds?.warn_pct ?? 70) || - crit !== String(edge.thresholds?.crit_pct ?? 90) || + width !== String(edge.width_px ?? EDGE_DEFAULTS.widthPx) || + warn !== String(edge.thresholds?.warn_pct ?? EDGE_DEFAULTS.warnPct) || + crit !== String(edge.thresholds?.crit_pct ?? EDGE_DEFAULTS.critPct) || animated !== (edge.animation?.enabled !== false) || showMetrics !== edge.show_metrics || srcSide !== (edge.source_handle || 'auto') || - dstSide !== (edge.target_handle || 'auto') + dstSide !== (edge.target_handle || 'auto') || + srcIf !== (edge.source_interface_id ?? '') || + dstIf !== (edge.target_interface_id ?? '') || + fields.join(',') !== viewFields.join(',') || + labelAt !== String(Math.round((edge.view?.label_at ?? 0) * 100)) || + arrow !== (edge.view?.arrow ?? EDGE_DEFAULTS.arrow) + + // Вигляд без семантики — те, що має сенс застосувати до всіх ліній + // одразу. Порти, боки й текст підпису сюди не входять: вони в кожної + // лінії свої, і однаковими бути не можуть за визначенням. + // + // Положення підпису теж не входить навмисно: однакова частка на всіх + // лініях, що виходять з одного вузла, знову зведе їхні підписи в одну + // точку — тобто зробить рівно те, від чого це поле й рятує. + function appearance(): Omit { + return { + style, + dash, + // Порожній колір означає «за станом»: сервер зберігає порожнє + // значення, і розрахунок за завантаженням повертається. + color, + width_px: Number(width) || EDGE_DEFAULTS.widthPx, + thresholds: { + warn_pct: Number(warn) || EDGE_DEFAULTS.warnPct, + crit_pct: Number(crit) || EDGE_DEFAULTS.critPct, + }, + animation: { ...edge.animation, enabled: animated }, + show_metrics: showMetrics, + view: { show: fields, arrow }, + } + } async function apply() { setBusy(true) try { await onPatch( { + ...appearance(), id: edge.id, label, - style, - dash, - // Порожній колір означає «за станом»: сервер зберігає NULL, - // і розрахунок повертається. - color, - width_px: Number(width) || 2, - thresholds: { warn_pct: Number(warn) || 70, crit_pct: Number(crit) || 90 }, - animation: { ...edge.animation, enabled: animated }, - show_metrics: showMetrics, source_handle: srcSide, target_handle: dstSide, + // 'none' — саме «зняти порт», а не «не передавали»: без + // цього помилково обраний порт лишався б на ребрі назавжди. + source_interface_id: srcIf || 'none', + target_interface_id: dstIf || 'none', + // Положення підпису — на відміну від решти вигляду, воно + // особисте: 0 означає «рахуй сам», і саме так лишається у + // всіх ліній, яких людина не чіпала. + view: { show: fields, arrow, label_at: (Number(labelAt) || 0) / 100 }, }, 'зміна вигляду звʼязку', ) @@ -122,11 +239,54 @@ export function EdgeInspector({ } } + async function applyToAll() { + if (!onPatchAll) return + setBusy(true) + try { + await onPatchAll(appearance(), `вигляд усіх ліній (${edgeCount})`) + } finally { + setBusy(false) + } + } + + // Привʼязка до виявленого лінка. + // + // Порти беремо ті, що назвав зонд, а не ті, що стоять у формі: + // сенс дії саме в тому, щоб лінія почала показувати мережу, а не + // здогад того, хто малював. + async function bindDiscovered() { + if (!edge.discovered) return + setBusy(true) + try { + await onPatch( + { + id: edge.id, + link_id: edge.discovered.link_id, + source_interface_id: edge.discovered.source_interface_id || 'none', + target_interface_id: edge.discovered.target_interface_id || 'none', + }, + 'привʼязка звʼязку до виявленого лінка', + ) + } finally { + setBusy(false) + } + } + + // Зняття привʼязки: лінк міг залишитись від зниклого обладнання, і + // тоді лінія показує стан того, чого вже немає. + async function unbind() { + setBusy(true) + try { + await onPatch({ id: edge.id, link_id: 'none' }, 'зняття привʼязки звʼязку') + } finally { + setBusy(false) + } + } + + const atPct = Number(labelAt) || 0 + return ( -
    +
    {nameOf(edge.source_node_id)} → {nameOf(edge.target_node_id)} @@ -136,9 +296,43 @@ export function EdgeInspector({
    + {/* Стан звірки — перше, що людина читає про лінію. + Раніше тут стояло одне слово «без привʼязки»: правдиве й + непридатне, бо не казало ні що не так, ні що робити. */} +
    +
    {st.label}
    +

    {st.hint}

    + {st.action &&

    {st.action}

    } + {/* Збір топології доступний і в режимі перегляду: він не міняє + схему, а лише просить зонд опитати обладнання. Привʼязка — + навпаки, правка полотна, і без права на неї не показується. */} + {(!readOnly || onDiscover) && ( +
    + {!readOnly && st.key === 'unbound' && ( + + )} + {!readOnly && st.key === 'linked' && ( + + )} + {st.key !== 'linked' && onDiscover && ( + + )} +
    + )} +
    +
    - + {edge.link_id && } {edge.util_pct !== undefined && } + {edge.capacity_bps !== undefined && ( + + )} {edge.source_port && } {edge.target_port && }
    @@ -146,147 +340,316 @@ export function EdgeInspector({ {readOnly ? (

    Перегляд — редагування потребує maps:write

    ) : ( -
    - - setLabel(e.target.value)} - /> - +
    + {/* Порти — саме те, заради чого на схему й дивляться: яким + портом у який увімкнено. Доки автовиявлення не пройшло, + зіставити їх можна руками — тоді на лінії зʼявляться і + назви портів, і швидкість каналу. */} +
    +
    + + + + + + +
    + {srcPorts.length === 0 && dstPorts.length === 0 && ( +

    + Інвентар портів порожній — його заповнює автовиявлення. +

    + )} -
    - - - - - - -
    +
    + + + + + + +
    -
    - - - - - - -
    + {sideAdvice && ( +
    +

    + Бік не збігається з розташуванням вузлів — лінія йде в обхід. +

    + +
    + )} +
    -
    - +
    + + + setWidth(e.target.value)} + value={label} + disabled={!showMetrics} + onChange={(e) => setLabel(e.target.value)} /> - -
    + + {/* Кожне поле окремо, бо саме довжина підпису вирішує, чи + зіткнуться сусідні: два рядки по 30 символів на чотирьох + лініях з одного вузла не розведе жодне розміщення. */} +
    + {EDGE_LABEL_FIELDS.map((f) => ( + + setFields((prev) => + v + ? [...EDGE_LABEL_FIELDS].filter((x) => x === f || prev.includes(x)) + : prev.filter((x) => x !== f), + ) + } + > + {EDGE_LABEL_FIELD_TITLE[f as EdgeLabelField]} + + ))} +
    + + {/* Положення вздовж лінії. Типово рахує полотно — і це не + «нічого не задано», а окрема відповідь: воно розводить + підписи ліній, що сходяться в одному вузлі. */} + +
    setColor(e.target.value)} + type="range" + className="w-full accent-sky-500" + min="0" + max="95" + step="5" + disabled={!showMetrics} + value={labelAt} + onChange={(e) => setLabelAt(e.target.value)} /> - {color && ( + {atPct !== 0 && ( )}
    -
    +
    -
    - - s.key === style)?.label ?? style}`}> +
    + + + + + + +
    + +
    + + setWidth(e.target.value)} + /> + + +
    + setColor(e.target.value)} + /> + {color && ( + + )} +
    +
    +
    + + + - - setCrit(e.target.value)} - /> - -
    + - - +
    +
    + + setWarn(e.target.value)} + /> + + + setCrit(e.target.value)} + /> + +
    + +
    -
    +
    + {onPatchAll && edgeCount > 1 && ( + + )}
    )} -
    + + ) +} + +/** + * Вибір порту. + * + * Швидкість поруч з іменем не для краси: на комутаторі два десятки + * однакових Gi0/x, і саме швидкість підказує, який із них аплінк. + */ +function PortSelect({ + value, + ports, + onChange, +}: { + value: string + ports: MapInterface[] + onChange: (v: string) => void +}) { + return ( + ) } @@ -298,18 +661,10 @@ function statusLabel(s?: string): string { return 'обрив' case 'warning': return 'проблеми' + case 'maintenance': + return 'обслуговування' default: - // Порожній статус — не помилка: так виглядає лінія, намальована - // рукою й не привʼязана до фізичного лінка. - return 'без привʼязки' + // Лінк є, але жоден його кінець ще не опитували. + return 'ще не опитано' } } - -function Row({ k, v }: { k: string; v: string }) { - return ( -
    - {k} - {v} -
    - ) -} diff --git a/web/src/components/InspectorShell.tsx b/web/src/components/InspectorShell.tsx new file mode 100644 index 0000000..71bcf13 --- /dev/null +++ b/web/src/components/InspectorShell.tsx @@ -0,0 +1,113 @@ +import { useState, type ReactNode } from 'react' + +/** + * Обгортка інспектора: панель у куті полотна. + * + * Чому з власною прокруткою: панель приклеєна до НИЖНЬОГО краю й росте + * вгору. Щойно налаштувань стало більше, ніж вміщає екран ноутбука, + * верхні поля виїжджали за межу вікна — прокрутити туди неможливо, бо + * сторінка сама по собі не прокручується. Тому висота обмежена, а + * зайве прокручується всередині. + * + * Ширина трохи більша за колишні 288 px: у два стовпчики вміщується + * пара «підпис + значення», а не обрізаний селект. + */ +export function InspectorPanel({ children }: { children: ReactNode }) { + return ( +
    + {children} +
    + ) +} + +/** + * Група налаштувань, що згортається. + * + * Причина не в економії місця, а в тому, що налаштування різного роду: + * «до якого порту ввімкнено» — це факт про мережу, «якою товщиною + * малювати» — оформлення, і читати їх одним стовпчиком означає щоразу + * перебирати весь список очима. Групи з заголовками дають те саме, що + * дає Zabbix своїми вкладками: людина знає, де шукати, ще до того, як + * подивилась. + * + * Стан групи локальний і не зберігається: він живе рівно доти, доки + * відкрита панель, а мапу відкривають, щоб дивитись на схему, а не щоб + * повертатись до вчорашнього стану гармошки. + */ +export function Section({ + title, + hint, + defaultOpen = false, + right, + children, +}: { + title: string + hint?: string + defaultOpen?: boolean + /** Короткий підсумок праворуч від заголовка — видно й згорнутим. */ + right?: ReactNode + children: ReactNode +}) { + const [open, setOpen] = useState(defaultOpen) + + return ( +
    + + {open && ( +
    + {hint &&

    {hint}

    } + {children} +
    + )} +
    + ) +} + +/** Рядок «ключ — значення» у зведенні над формою. */ +export function Row({ k, v }: { k: string; v: string }) { + return ( +
    + {k} + {v} +
    + ) +} + +/** Галочка з підписом — форма зустрічається в обох інспекторах. */ +export function Check({ + checked, + onChange, + children, + disabled, +}: { + checked: boolean + onChange: (v: boolean) => void + children: ReactNode + disabled?: boolean +}) { + return ( + + ) +} diff --git a/web/src/components/InterfaceFilterEditor.tsx b/web/src/components/InterfaceFilterEditor.tsx new file mode 100644 index 0000000..fd62329 --- /dev/null +++ b/web/src/components/InterfaceFilterEditor.tsx @@ -0,0 +1,492 @@ +import { useEffect, useMemo, useState } from 'react' +import { api } from '../api/client' +import { Button, ErrorNote, Field, formatBps, inputClass, plural } from './ui' +import type { + IfFilterField, + IfFilterOp, + InterfaceFilter, + InterfaceFilterCondition, + InterfaceFilterDevice, + InterfaceFilterPreview, +} from '../types' + +/** + * Фільтр складу портів шаблону. + * + * Навіщо: без нього snmp.if-чек бере всі порти, які знайшло + * автовиявлення. На стенді це сто портів у Juniper QFX, з яких сорок — + * `.0`-двійники фізичних, а решта службові; у D-Link із 57 «портів» 29 + * насправді VLAN-и. Опитувати їх — це сотні OID за такт заради + * графіків, на які ніхто не подивиться. + * + * Головне тут — не поля вводу, а кнопка «Перевірити»: зразок, + * застосований до сотні пристроїв наосліп, або збирає не те, або не + * збирає нічого, і дізнаються про це через добу по порожніх графіках. + */ + +const FIELDS: { key: IfFilterField; label: string; hint: string }[] = [ + { key: 'name', label: "ім'я порту", hint: 'ifName: xe-0/0/1, Ethernet0/0/5, gpon_1/2/2' }, + { key: 'alias', label: 'опис', hint: 'ifAlias: те, що інженер написав про порт' }, + { key: 'type', label: 'тип', hint: 'ethernetCsmacd, ieee8023adLag, l2vlan, propVirtual' }, + { key: 'speed', label: 'швидкість', hint: 'номінальна: 10G, 1G, 100M' }, +] + +const OPS: { key: IfFilterOp; label: string; forSpeed: boolean }[] = [ + { key: 'matches', label: 'збігається зі зразком', forSpeed: false }, + { key: 'not_matches', label: 'не збігається зі зразком', forSpeed: false }, + { key: 'ge', label: 'не менше ніж', forSpeed: true }, + { key: 'le', label: 'не більше ніж', forSpeed: true }, +] + +/** + * Заготовки. + * + * Не вбудовані шаблони, а саме кнопки: зразок, який ніхто не перевіряв + * на вашому залізі, гірший за його відсутність — він створює враження, + * що склад портів під наглядом. Кнопка ж лише заповнює поля, і далі + * людина натискає «Перевірити» на своєму хості. + */ +const PRESETS: { label: string; title: string; filter: InterfaceFilter }[] = [ + { + label: 'Фізичні порти й LAG', + title: + 'Найнадійніше з усього: тип порту не залежить від того, як вендор його назвав. ' + + 'На стенді Juniper QFX 98 → 49, D-Link 57 → 28 (усі 29 псевдопортів «802.1Q Encapsulation Tag» відпали). ' + + 'На OLT відсіє й gpon-дерева — там додайте другу умову й «хоча б одну»', + filter: { + match: 'all', + conditions: [{ field: 'type', op: 'matches', value: '^(ethernetCsmacd|ieee8023adLag)$' }], + }, + }, + { + label: 'Juniper: xe-/ge-/ae-', + title: + 'За іменем, без .0-підінтерфейсів. На JUN.QFX: 98 → 44. На інших вендорах не дасть нічого — перевіряйте', + filter: { + match: 'all', + conditions: [ + { field: 'name', op: 'matches', value: '^(xe|ge|et|ae)-?\\d' }, + { field: 'name', op: 'not_matches', value: '\\.\\d+$' }, + ], + }, + }, + { + label: 'Тільки магістральні', + title: + 'Порти від 10 Гбіт/с. На стенді це порожньо на трьох хостах із пʼяти — самі по собі магістральні порти є не всюди', + filter: { + match: 'all', + conditions: [{ field: 'speed', op: 'ge', value: '10G' }], + }, + }, + { + label: 'Тільки з описом', + title: + 'Порт, який комусь був потрібен настільки, щоб його підписати. ' + + 'Ознака ненадійна: Huawei заповнює опис сам («HUAWEI, Quidway Series, …») і не відсіює нічого, ' + + 'а на D-Link описів немає взагалі — там пройде нуль портів', + filter: { + match: 'all', + conditions: [{ field: 'alias', op: 'matches', value: '\\S' }], + }, + }, +] + +const emptyCondition = (): InterfaceFilterCondition => ({ + field: 'name', + op: 'matches', + value: '', +}) + +export function InterfaceFilterEditor({ + value, + templateID, + readOnly, + onChange, +}: { + value?: InterfaceFilter + /** Потрібен лише щоб не рахувати цей самий шаблон серед «інших фільтрів хоста». */ + templateID?: string + readOnly?: boolean + onChange: (next: InterfaceFilter | undefined) => void +}) { + const conditions = value?.conditions ?? [] + const matchAny = value?.match === 'any' + + const [devices, setDevices] = useState([]) + const [deviceID, setDeviceID] = useState('') + const [preview, setPreview] = useState(null) + const [busy, setBusy] = useState(false) + const [err, setErr] = useState(null) + const [onlyEligible, setOnlyEligible] = useState(true) + + useEffect(() => { + api + .listInterfaceFilterDevices(templateID) + .then((list) => { + setDevices(list) + // Типово — хост, якому цей шаблон уже причеплений: саме на + // ньому зміна складу портів станеться насправді. Якщо таких + // немає, беремо найбагатший на порти: зразок перевіряють на + // найгіршому випадку. + const bound = list.find((d) => d.bound) + setDeviceID((cur) => cur || bound?.id || list[0]?.id || '') + }) + .catch((e) => setErr(e instanceof Error ? e.message : String(e))) + }, [templateID]) + + function patch(next: Partial, conds?: InterfaceFilterCondition[]) { + const c = conds ?? conditions + if (c.length === 0) { + // Фільтр без умов — це відсутність фільтра, а не «нічого не + // брати». Порожній обʼєкт у базі означав би те саме, але змушував + // би всіх читачів здогадуватись. + onChange(undefined) + return + } + onChange({ match: value?.match ?? 'all', conditions: c, ...next }) + } + + async function run() { + if (!deviceID) return + setBusy(true) + setErr(null) + try { + setPreview( + await api.previewInterfaceFilter( + deviceID, + { match: matchAny ? 'any' : 'all', conditions }, + templateID, + ), + ) + } catch (e) { + setPreview(null) + setErr(e instanceof Error ? e.message : String(e)) + } finally { + setBusy(false) + } + } + + // Порти, які зараз опитуються, а після цього фільтра перестануть. + // Саме це число, а не «пройшло 12», лякає — і саме його треба + // побачити до збереження, а не по обірваному графіку через тиждень. + const willStop = useMemo( + () => (preview?.ports ?? []).filter((p) => p.eligible && !p.matched && !p.filtered_out_at), + [preview], + ) + + const rows = useMemo( + () => (preview?.ports ?? []).filter((p) => !onlyEligible || p.eligible), + [preview, onlyEligible], + ) + + return ( +
    +

    + Без фільтра в чек потрапляють усі порти, які знайшло автовиявлення. На стопортовому + комутаторі це сотні OID за такт заради графіків, на які ніхто не дивиться. Умови + застосовуються до того, що віддав пристрій: {"ім'я"} (ifName), опис (ifAlias), тип і + номінальна швидкість. +

    + + {!readOnly && ( +
    + {PRESETS.map((p) => ( + + ))} + {conditions.length > 0 && ( + + )} +
    + )} + + {conditions.length > 1 && ( +
    + Порт проходить, якщо: + {[ + { on: false, label: 'виконано всі умови' }, + { on: true, label: 'виконано хоча б одну' }, + ].map((o) => ( + + ))} +
    + )} + + {conditions.map((c, i) => { + const isSpeed = c.field === 'speed' + const field = FIELDS.find((f) => f.key === c.field) + return ( +
    + + + + + + patch( + {}, + conditions.map((x, n) => (n === i ? { ...x, value: e.target.value } : x)), + ) + } + /> + + {!readOnly && ( + + )} + +

    + {isSpeed + ? 'Швидкість пишуть суфіксом: 10G, 1G, 100M. Порти, які пристрій віддає з нульовою швидкістю, під «не менше» не підпадають.' + : `${field?.hint ?? ''} · Зразок шукається в будь-якому місці рядка; на початок прив'язує ^, на кінець $. Регістр не враховується.`} +

    +
    + ) + })} + + {!readOnly && ( + + )} + + {conditions.length === 0 && ( +

    + Фільтра немає — цей шаблон не звужує склад портів. Якщо хосту причеплений інший + шаблон із фільтром, діятиме він: фільтри шаблонів обʼєднуються, і шаблон без думки + про порти нічого в інших не відбирає. +

    + )} + + {/* ---- Перевірка на справжньому хості ---- */} + +
    +
    +
    + + + +
    + +
    + + {err} + + {preview && ( +
    +
    + + {preview.matched} з {preview.eligible} + + + придатних портів ({preview.total} усього в інвентарі) + +
    + + {preview.matched === 0 && preview.eligible > 0 && ( +

    + Жоден порт не проходить. Такий шаблон зберегти не вийде: порожній snmp.if-чек + ззовні не відрізнити від справного, і помітять його тоді, коли метрик портів + уже тиждень немає. +

    + )} + + {willStop.length > 0 && ( +

    + {willStop.length} {plural(willStop.length, ['порт', 'порти', 'портів'])} зараз опитується й після + застосування фільтра перестане: {willStop.slice(0, 8).map((p) => p.name).join(', ')} + {willStop.length > 8 ? ` та ще ${willStop.length - 8}` : ''}. Зібрані метрики + лишаться в історії, але оновлюватись перестануть — графік обірветься на дні + застосування. +

    + )} + + {preview.truncated && ( +

    + Пройшло більше за стелю чека ({preview.limit}) — зайве буде обрізано. Звузьте + фільтр, інакше який саме порт випаде, вирішить порядок ifIndex. +

    + )} + + {preview.other_templates && preview.other_templates.length > 0 && ( +

    + У цього хоста є ще фільтри: {preview.other_templates.join(', ')}. У чек піде + обʼєднання — шаблон не має права відбирати порти, які просить інший. +

    + )} + + + +
    + + + + + + + + + + + + {rows.map((p) => ( + + + + + + {/* Коротка форма: колонка вузька, і назва одиниці + в неї не влазить — зате число те саме, що на + осі графіка. */} + + + ))} + +
    портопистипшвидк.
    + {!p.eligible ? '·' : p.matched ? '✓' : '✕'} + + {p.name} + {p.filtered_out_at && ( + + уже виключено + + )} + {p.skip && — {p.skip}} + {p.alias ?? ''}{p.type ?? ''} + {p.speed_bps ? formatBps(p.speed_bps, 'short') : '—'} +
    +
    +
    + )} +
    +
    + ) +} diff --git a/web/src/components/JsonView.tsx b/web/src/components/JsonView.tsx new file mode 100644 index 0000000..97d80cf --- /dev/null +++ b/web/src/components/JsonView.tsx @@ -0,0 +1,133 @@ +import { useState } from 'react' + +/** + * Показ довільного JSON. + * + * Потрібен там, де вміст не має схеми на клієнті й мати її не може: + * `meta` в журналі аудиту складає та ручка, яка записала подію, і в ній + * лежить те, що вона вважала за потрібне зберегти. Малювати таке + * табличкою «поле — значення» означало б вигадати схему й приховати + * все, що в неї не влізло, — тобто зіпсувати рівно ту властивість, + * заради якої журнал і читають. + * + * Тому дерево, а не таблиця, і сирі ключі, а не перекладені. Переказ + * українською вже є в переліку (його робить сервер); тут — те, що + * справді лежить у базі, у тому вигляді, у якому воно там лежить. + * + * Чому не `JSON.stringify(v, null, 2)` у
    . Стенограма сесії або
    + * перелік із чотирьохсот імен перетворюють такий блок на кілометр
    + * тексту, у якому структура зникає. Згортання дає відповідь на «що тут
    + * узагалі є» до того, як людина почне читати.
    + */
    +
    +/** Скільки рівнів розгорнуто одразу. */
    +const AUTO_OPEN_DEPTH = 2
    +/** Скільки елементів довгого масиву показувати до «ще». */
    +const ARRAY_HEAD = 50
    +/** Довжина рядка, після якої він ховається під «показати повністю». */
    +const STRING_CLAMP = 300
    +
    +export function JsonView({ value, label }: { value: unknown; label?: string }) {
    +  if (value === undefined || value === null) {
    +    return 

    порожньо

    + } + return ( +
    + +
    + ) +} + +function Node({ k, v, depth }: { k?: string; v: unknown; depth: number }) { + const [open, setOpen] = useState(depth < AUTO_OPEN_DEPTH) + const [showAll, setShowAll] = useState(false) + + const isArray = Array.isArray(v) + const isObject = !isArray && typeof v === 'object' && v !== null + + if (!isArray && !isObject) { + return ( +
    + {k !== undefined && {k}:} + +
    + ) + } + + const entries: [string, unknown][] = isArray + ? (v as unknown[]).map((e, i) => [String(i), e]) + : Object.entries(v as Record) + + // Порожні контейнери не варті кнопки згортання: вона обіцяє вміст, + // якого немає. + if (entries.length === 0) { + return ( +
    + {k !== undefined && {k}:} + {isArray ? '[]' : '{}'} +
    + ) + } + + const shown = showAll ? entries : entries.slice(0, ARRAY_HEAD) + const hidden = entries.length - shown.length + + return ( +
    + + + {open && ( +
    + {shown.map(([key, val]) => ( + + ))} + {hidden > 0 && ( + + )} +
    + )} +
    + ) +} + +function Scalar({ v }: { v: unknown }) { + const [full, setFull] = useState(false) + + if (typeof v === 'string') { + // Багаторядкове (вивід команди, стенограма) лишається багаторядковим: + // склеїти його в один рядок означає зробити нечитабельним саме те, + // заради чого на нього дивляться. + const long = v.length > STRING_CLAMP + const text = long && !full ? v.slice(0, STRING_CLAMP) + '…' : v + return ( + + {text} + {long && ( + + )} + + ) + } + if (typeof v === 'number') return {v} + if (typeof v === 'boolean') return {String(v)} + return null +} diff --git a/web/src/components/MapAddHosts.tsx b/web/src/components/MapAddHosts.tsx index cc5bdbe..c989fe4 100644 --- a/web/src/components/MapAddHosts.tsx +++ b/web/src/components/MapAddHosts.tsx @@ -88,7 +88,7 @@ export function MapAddHosts({
    - Знайдено {plural(list.length, 'хост', 'хости', 'хостів')}, доступно до додавання{' '} + Знайдено {list.length} {plural(list.length, ['хост', 'хости', 'хостів'])}, доступно до додавання{' '} {selectable.length} diff --git a/web/src/components/MapCanvas.tsx b/web/src/components/MapCanvas.tsx index 60cd90e..59cffb9 100644 --- a/web/src/components/MapCanvas.tsx +++ b/web/src/components/MapCanvas.tsx @@ -15,11 +15,22 @@ import { ConnectionMode, ReactFlowProvider, useReactFlow, + useStore, } from '@xyflow/react' import '@xyflow/react/dist/style.css' import { DeviceNode, type DeviceNodeType } from './DeviceNode' import { TrafficEdge, type TrafficEdgeType } from './TrafficEdge' +import { edgeState } from './edgeState' +import { + EDGE_DEFAULTS, + EDGE_LABEL_DEFAULT, + autoLabelPositions, + autoSides, + type EdgeLabelField, +} from './mapStyle' +import { setLabelZoom } from './labelLayout' +import { plural } from './ui' import type { MapPatch, MapState } from '../types' const nodeTypes = { device: DeviceNode } @@ -84,6 +95,12 @@ function MapCanvasInner({ iconId: n.style?.iconId as string | undefined, iconSize: n.style?.iconSize as number | undefined, hideMetrics: n.style?.hideMetrics as boolean | undefined, + // Адреса під іменем. Хост із іменем «ЦЕХ-2» нічого не каже + // тому, хто прийшов лагодити; 10.0.3.7 економить перехід у + // перелік хостів. + address: n.address, + showAddress: n.style?.showAddress as boolean | undefined, + deleted: n.device_deleted, }, })), [state.nodes, readOnly], @@ -97,6 +114,20 @@ function MapCanvasInner({ return m }, [state.nodes]) + // Вузли видалених хостів: лінія до такого веде в нікуди, і це окремий + // стан ребра, а не «просто не звірено». + const goneNodes = useMemo( + () => new Set(state.nodes.filter((n) => n.device_deleted).map((n) => n.id)), + [state.nodes], + ) + + // Де стоятимуть підписи ліній. + // + // Рахується для всіх ребер разом, а не в кожному окремо: ребро не + // знає, скільки ще ліній сходиться в його вузлах, а саме це й + // визначає, чи вистачить місця підпису в середині лінії. + const labelAt = useMemo(() => autoLabelPositions(state.edges, nodePos), [state.edges, nodePos]) + const rfEdges = useMemo( () => state.edges.map((e) => { @@ -120,6 +151,10 @@ function MapCanvasInner({ selected: e.id === selectedEdge, data: { label: e.label, + // Форма лінії з бази. Досі сюди не доїжджала, і полотно + // завжди малювало криву — вибір в інспекторі зберігався й + // не робив нічого. + shape: e.style, dash: e.dash, widthPx: e.width_px, color: e.color, @@ -128,14 +163,23 @@ function MapCanvasInner({ linkStatus: e.link_status, utilPct: e.util_pct, capacityBps: e.capacity_bps, - warnPct: e.thresholds?.warn_pct ?? 70, - critPct: e.thresholds?.crit_pct ?? 90, + warnPct: e.thresholds?.warn_pct ?? EDGE_DEFAULTS.warnPct, + critPct: e.thresholds?.crit_pct ?? EDGE_DEFAULTS.critPct, animated: e.animation?.enabled !== false, showMetrics: e.show_metrics, + fields: (e.view?.show as EdgeLabelField[] | undefined) ?? EDGE_LABEL_DEFAULT, + labelAt: e.view?.label_at, + autoLabelAt: labelAt.get(e.id) ?? 0.5, + arrow: e.view?.arrow, + // Стан звірки рахується тут, а не в самому ребрі: та сама + // функція живить інспектор, і два незалежні розрахунки + // рано чи пізно розійшлися б у трактуванні. + state: edgeState(e, goneNodes.has(e.source_node_id) || goneNodes.has(e.target_node_id)) + .key, }, } }), - [state.edges, selectedEdge, nodePos], + [state.edges, selectedEdge, nodePos, goneNodes, labelAt], ) const [nodes, setNodes, onNodesChange] = useNodesState(rfNodes) @@ -175,12 +219,22 @@ function MapCanvasInner({ [onSelectionChange], ) + // Перехід з вузла на вузол приходить ОДНИМ пакетом змін: зняти + // виділення зі старого й поставити на новий. Раніше цикл кликав + // onSelect на кожну зміну, тож підсумок залежав від порядку в + // масиві — прийшов «зняли» останнім, і панель показувала не той + // вузол або закривалась на щойно обраному. Беремо підсумок пакета: + // якщо в ньому когось обрали — це він, інакше вибору немає. const handleNodesChange = useCallback( (changes: NodeChange[]) => { onNodesChange(changes) + let picked: string | null | undefined for (const c of changes) { - if (c.type === 'select') onSelect(c.selected ? c.id : null) + if (c.type !== 'select') continue + if (c.selected) picked = c.id + else if (picked === undefined) picked = null } + if (picked !== undefined) onSelect(picked) }, [onNodesChange, onSelect], ) @@ -197,7 +251,7 @@ function MapCanvasInner({ return !before || Math.round(before.x) !== n.x || Math.round(before.y) !== n.y }) if (upsert.length === 0) return - onPatch({ nodes: { upsert } }, `перетягування ${upsert.length} вузлів`) + onPatch({ nodes: { upsert } }, `перетягування ${upsert.length} ${plural(upsert.length, ['вузла', 'вузлів', 'вузлів'])}`) }, [onPatch, state.nodes], ) @@ -327,6 +381,8 @@ function MapCanvasInner({ proOptions={{ hideAttribution: false }} className="netpulse-canvas" > + + {/* Два шари сітки: дрібна для відчуття масштабу й велика для орієнтації. Одна сітка або рябить, або не читається — залежно від кроку, а крок задає людина. */} @@ -368,6 +424,19 @@ function MapCanvasInner({ ) } +/** + * Масштаб полотна — для розкладки підписів. + * + * Окремий компонент, який нічого не малює, свідомо: підписка на зум у + * самому полотні перемальовувала б усі вузли й лінії на кожен щиглик + * колеса, а тут перемальовується один порожній вузол. + */ +function LabelZoomProbe() { + const zoom = useStore((s) => s.transform[2]) + useEffect(() => setLabelZoom(zoom), [zoom]) + return null +} + /** * Ідентифікатор порту з бази у вигляд, який розуміє полотно. * @@ -379,25 +448,6 @@ function normalizeHandle(h?: string): string | null { return h.endsWith('-s') ? h.slice(0, -2) : h } -/** - * Боки підключення для лінії без явної привʼязки. - * - * React Flow сам найкоротший бік не шукає — без імені порту він бере - * перший-ліпший, і лінія чіпляється до лівого краю навіть коли сусід - * стоїть праворуч. Тому рахуємо самі: що більше — різниця по осі X чи - * по Y — та вісь і диктує пару боків. - */ -function autoSides( - a?: { x: number; y: number }, - b?: { x: number; y: number }, -): [string, string] { - if (!a || !b) return ['r', 'l'] - const dx = b.x - a.x - const dy = b.y - a.y - if (Math.abs(dx) >= Math.abs(dy)) return dx >= 0 ? ['r', 'l'] : ['l', 'r'] - return dy >= 0 ? ['b', 't'] : ['t', 'b'] -} - /** * React Flow тримає стан у контексті, і useReactFlow працює лише * всередині нього. Провайдер тут, а не в сторінці, щоб виклик мапи diff --git a/web/src/components/MapSettings.tsx b/web/src/components/MapSettings.tsx index 4660ecf..90cc71b 100644 --- a/web/src/components/MapSettings.tsx +++ b/web/src/components/MapSettings.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react' import { api } from '../api/client' -import { Button, ErrorNote, Modal, inputClass, plural } from './ui' +import { Button, ErrorNote, Field, Modal, inputClass, plural } from './ui' import type { Icon, MapPermission, MapSummary, UserGroup } from '../types' const LEVELS: { key: string; label: string; hint: string }[] = [ @@ -10,11 +10,10 @@ const LEVELS: { key: string; label: string; hint: string }[] = [ ] /** - * Налаштування мапи: доступи груп і бібліотека іконок. + * Налаштування мапи: полотно, доступи груп і бібліотека іконок. * - * Доступи й іконки в одному вікні свідомо: і те, і те налаштовують раз - * на мапу й рідко, і розкидати їх по різних місцях означало б змусити - * шукати. + * Усе в одному вікні свідомо: це налаштовують раз на мапу й рідко, і + * розкидати по різних місцях означало б змусити шукати. */ export function MapSettings({ map, @@ -25,12 +24,15 @@ export function MapSettings({ onClose: () => void onChanged: () => void }) { - const [tab, setTab] = useState<'access' | 'icons'>('access') + const [tab, setTab] = useState<'canvas' | 'access' | 'icons'>('canvas') return (
    + @@ -39,16 +41,107 @@ export function MapSettings({
    - {tab === 'access' ? ( - - ) : ( - - )} + {tab === 'canvas' && } + {tab === 'access' && } + {tab === 'icons' && }
    ) } +/** + * Полотно: назва мапи й сітка. + * + * Сітку полотно читало з мапи від самого початку (`state.grid`), а + * задати її не давало ніде — крок 16 px був вирізаний у камені. Крок + * сітки визначає, наскільки рівно стають вузли при перетягуванні, і на + * схемі з рядами хостів це найпомітніше налаштування з усіх. + * + * Ревізія навмисно не передається: це правка властивостей мапи, а не + * полотна, і 409 «вас випередили» тут лише заважав би — двоє людей, які + * одночасно змінюють крок сітки, це не конфлікт даних. + */ +function CanvasTab({ map, onChanged }: { map: MapSummary; onChanged: () => void }) { + const [name, setName] = useState(map.name) + const [grid, setGrid] = useState('') + const [snap, setSnap] = useState(true) + const [busy, setBusy] = useState(false) + const [err, setErr] = useState(null) + + // Сітка живе в стані полотна, а не в переліку мап, тож читаємо її + // окремо: інакше форма показувала б типові значення замість заданих. + useEffect(() => { + api + .getMap(map.id) + .then((st) => { + setGrid(String(st.grid?.size ?? 16)) + setSnap(st.grid?.snap !== false) + }) + .catch(() => {}) + }, [map.id]) + + return ( +
    + + setName(e.target.value)} /> + + +
    + + setGrid(e.target.value)} + /> + + +
    + + {err} + +
    + +
    +
    + ) +} + function AccessTab({ mapID, onChanged }: { mapID: string; onChanged: () => void }) { const [groups, setGroups] = useState([]) const [perms, setPerms] = useState([]) @@ -79,12 +172,16 @@ function AccessTab({ mapID, onChanged }: { mapID: string; onChanged: () => void if (loading) return

    Завантаження…

    + // Число й слово тепер друкуються поруч, тож рахунок беремо один раз: + // двічі писати той самий filter у рядку — напрошуватись на розбіжність. + const allowed = perms.filter((p) => p.level !== 'deny').length + return (

    {perms.length === 0 ? 'Зараз мапа доступна всім, хто має право дивитись мапи. Щойно ви дасте доступ хоч одній групі, решта її не побачить.' - : `Мапу бачать ${plural(perms.filter((p) => p.level !== 'deny').length, 'група', 'групи', 'груп')}. Заборона перекриває дозвіл.`} + : `Мапу бачать ${allowed} ${plural(allowed, ['група', 'групи', 'груп'])}. Заборона перекриває дозвіл.`}

    {groups.length === 0 ? ( diff --git a/web/src/components/MetricChart.tsx b/web/src/components/MetricChart.tsx index c4cb3c0..401eba1 100644 --- a/web/src/components/MetricChart.tsx +++ b/web/src/components/MetricChart.tsx @@ -1,4 +1,5 @@ import { useMemo } from 'react' +import { fmtValue } from './ui' import type { MetricPoint, MetricSeries } from '../types' // Палітра ліній. Кольори підібрані так, щоб розрізнятися й у відтінках @@ -25,6 +26,7 @@ export function MetricChart({ kind = 'line', min, max, + emptyNote, }: { series: MetricSeries[] /** line | area | stacked | bar */ @@ -32,13 +34,23 @@ export function MetricChart({ /** Жорсткі межі осі. Порожньо — рахуються з даних. */ min?: number max?: number + /** + * Чим замінити «За цей період даних немає». + * + * Порожній графік має дві різні причини, і плутати їх дорого саме + * тут: «за цей період нічого не приходило» лікується ширшим вікном + * або пошуком збою збору, а «цей ряд узагалі більше не наповнюють» — + * ні тим, ні тим. Знає про різницю той, хто добирав ряди, тому текст + * приходить звідти, а не вигадується полотном. + */ + emptyNote?: string }) { const model = useMemo(() => build(series, kind, min, max), [series, kind, min, max]) if (!model) { return ( -
    - За цей період даних немає +
    + {emptyNote || 'За цей період даних немає'}
    ) } @@ -239,21 +251,4 @@ function makeTicks(min: number, max: number) { return out } -/** - * Коротке число для осі. - * - * Біти на секунду й байти пам'яті на одній осі з відсотками не - * поміщаються без скорочення, а «14437155.5» на графіку не читає ніхто. - */ -export function fmtValue(v: number): string { - const a = Math.abs(v) - if (a >= 1e9) return `${(v / 1e9).toFixed(1)}G` - if (a >= 1e6) return `${(v / 1e6).toFixed(1)}M` - if (a >= 1e3) return `${(v / 1e3).toFixed(1)}k` - if (a >= 10) return v.toFixed(0) - if (a >= 1) return v.toFixed(1) - if (a === 0) return '0' - return v.toFixed(3) -} - export type { MetricPoint } diff --git a/web/src/components/Minimap.tsx b/web/src/components/Minimap.tsx new file mode 100644 index 0000000..fa09116 --- /dev/null +++ b/web/src/components/Minimap.tsx @@ -0,0 +1,592 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +/** + * Мінікарта великого тексту — вузька смужка збоку з усім файлом у + * мініатюрі й рамкою поточного вікна. + * + * Розрахунок на реальний масштаб: ZTE C320 віддає 32 854 рядки. Звідси + * всі рішення нижче. + * + * ЧОМУ CANVAS, А НЕ DOM. Тридцять три тисячі рядків — це тридцять три + * тисячі елементів, тобто рівно те, від чого вже тікає віртуалізація в + * ConfigViewer. Малювати мініатюру елементами означало б повернути в + * сторінку весь той DOM, який вона обходить. + * + * ЧОМУ СМУЖКА ПРОКРУЧУЄТЬСЯ, А НЕ ВМІЩАЄ ВЕСЬ ФАЙЛ. Перша версія + * стискала весь документ у висоту смужки. На 32 000 рядків це п'ятдесят + * рядків на піксель — і мініатюра перетворювалась на суцільну заливку, + * бо в кожному пікселі знаходився хоч один довгий рядок і хоч один без + * відступу. Ні кольори, ні контраст цього не лікують: інформації там + * уже немає, лишається силует. + * + * Тому як у редакторах коду: рядок отримує щонайменше два пікселі, а + * коли файл у смужку не влазить — смужка сама їде за прокруткою. + * Видно рядки, а не хвилю. Короткий файл, як і раніше, показується + * цілком: там прокручувати нічого. + * + * Позначки (збіги пошуку, місця змін) при цьому лишаються по ВСЬОМУ + * документу — вузькою колонкою ліворуч. Інакше пошук втратив би головне: + * скільки збігів і де вони, а не лише ті, що поруч. + * + * ЧОМУ КІЛЬКА ШАРІВ. Позначки (збіги пошуку, місця змін) живуть на + * власному полотні й залежать лише від самих позначок. Рамка видимого + * вікна — звичайний div, який рухається зміною style. + * + * У режимі «влазить цілком» прокрутка не коштує жодного піксельного + * малювання взагалі. У режимі прокрутки вміст перемальовується разом із + * вікном — але це рівно стільки прямокутників, скільки рядків у вікні + * (близько трьохсот), а не тридцять тисяч. Ціна свідома: без неї + * мініатюра лишалась би суцільною плямою, тобто не працювала б зовсім. + */ + +/** Позначка на смужці: рядок (або діапазон рядків) певним кольором. */ +export interface MinimapMark { + /** Індекс рядка у віртуальному документі. */ + row: number + /** Скільки рядків займає позначка; 1 — один рядок. */ + span?: number + color: string +} + +/** + * Ширина смужки. + * + * Було 68 — і цього замало: на такій ширині слова зливаються в суцільну + * рису незалежно від того, як їх малювати. Разом із більшою шириною + * росте й MAX_COLS: на смужку лягає більше символів рядка, кожен — + * вужчий. Тобто «текст дрібніший, а видно його більше». + * + * Число ОДНЕ на полотно й на контейнер. Було два: полотно малювало по + * 180, а клас лишався `w-[68px]` від попередньої ширини — і браузер + * стискав намальоване в два з половиною рази. На екрані це виглядало не + * як помилка, а як «дрібний шрифт»: смужка чесно показувала весь текст, + * тільки сплюснутий по горизонталі. Тому ширина йде в style, а не + * класом: клас із довільним числом Tailwind збирає статично, і наступна + * зміна MAP_W знову розійшлася б із ним мовчки. + */ +export const MAP_W = 180 + +/** + * Скільки пікселів заввишки віддається рядку — і скільки з них займає + * сам рядок. + * + * Три й два: рядок малюється двома пікселями, третій лишається + * порожнім. Проміжок обов'язковий — без нього рядки зливаються в + * пляму; але й одного пікселя на рядок замало, він виходить блідим і + * рваним. Два пікселі дають щільний штрих, який видно чітко. + * + * Ціна — менше рядків у вікні (близько двохсот замість трьохсот). + * Розмін свідомий: мініатюра, на якій нічого не розібрати, не стає + * кориснішою від того, що вміщає більше. + */ +const ROW_PX = 3 +const BAR_PX = 2 + +/** Ширина колонки позначок по всьому документу (режим прокрутки). */ +const GUTTER_W = 6 + +/** Скільки символів рядка вміщається в ширину смужки. Далі — обрізаємо. */ +const MAX_COLS = 280 + +/** + * Скільки слів у рядку малюємо окремо. + * + * Саме проміжки між словами роблять смужку схожою на текст: суцільна + * риса від відступу до кінця рядка читається як штрих, а не як рядок. + * + * Стеля потрібна, бо в конфігах трапляються рядки на сотні коротких + * лексем (списки VLAN, префікс-листи): малювати їх усі — це тисячі + * прямокутників заради ділянки в кілька пікселів завширшки. + */ +const MAX_SEGS = 40 + +const SP = ' ' +const TAB = ' ' + +/** + * Форма тексту: довжина й відступ кожного рядка. + * + * Рахується один раз на текст. Int32Array, а не масив об'єктів: на + * 33 000 рядків різниця між двома типізованими масивами й тридцятьма + * трьома тисячами об'єктів — це мегабайти купи й робота для збирача + * сміття під час прокрутки. + */ +export interface Shape { + len: Int32Array + indent: Int32Array + /** Вид рядка: KIND_*. Визначає колір смужки. */ + kind: Uint8Array +} + +export function buildShape(lines: string[]): Shape { + const n = lines.length + const len = new Int32Array(n) + const indent = new Int32Array(n) + const kind = new Uint8Array(n) + for (let i = 0; i < n; i++) { + const s = lines[i] + len[i] = s.length + let k = 0 + while (k < s.length && (s.charCodeAt(k) === 32 || s.charCodeAt(k) === 9)) k++ + // Табуляція шириною чотири: у конфігах відступ несе структуру, і + // рахувати табуляцію за один символ означало б показати блоки + // Juniper пласкими. + let w = 0 + for (let j = 0; j < k; j++) w += s.charCodeAt(j) === 9 ? 4 : 1 + indent[i] = s.length === k ? 0 : w + + // Вид рядка визначає колір на мініатюрі. Без нього карта — рівна + // сіра штриховка, на якій не видно, де закінчився один розділ і + // почався інший; а саме це людина й шукає, коли тягне повзунок. + if (s.length === k) { + kind[i] = KIND_BLANK + } else { + const c = s.charCodeAt(k) + // #, !, ; — коментар у всіх поширених CLI; «//» — у JunOS-подібних. + const comment = + c === 35 || c === 33 || c === 59 || (c === 47 && s.charCodeAt(k + 1) === 47) + if (comment) kind[i] = KIND_COMMENT + else if (w === 0) kind[i] = KIND_TOP + else kind[i] = KIND_BODY + } + } + return { len, indent, kind } +} + +// Види рядків. Числами, а не рядками: масив на тридцять тисяч +// елементів має бути Uint8Array, інакше сама мініатюра коштує більше +// за текст, який вона показує. +export const KIND_BLANK = 0 +export const KIND_TOP = 1 +export const KIND_BODY = 2 +export const KIND_COMMENT = 3 + +// Кольори підібрані так, щоб карта читалась як текст, а не як +// штрихування: рядок верхнього рівня — найяскравіший, бо саме він +// розділяє розділи; коментар — приглушений, бо його читають востаннє. +// +// Насиченість тут навмисно висока. Мініатюра шириною в кількасот +// пікселів і так втрачає все, крім силуету; бліда заливка перетворює +// цей силует на рівний сірий шум, у якому не видно жодної межі. +/** Колір знайденого рядка. Той самий бурштин, що й у підсвітці тексту. */ +const HIT_COLOR = '#fbbf24' + +export const KIND_COLOR: Record = { + [KIND_TOP]: '#e2e8f0', + [KIND_BODY]: '#94a3b8', + [KIND_COMMENT]: '#64748b', +} + +/** + * Колір штриха рядка. null — рядок не малюється зовсім. + * + * Окремою функцією, бо це єдине місце мінікарти, де є що перевірити без + * полотна, — і саме тут ховалась помилка: рядок із самих пробілів + * отримує KIND_BLANK, але довжину має ненульову, тож доходив до + * малювання. Кольору для KIND_BLANK у таблиці немає, спрацьовував + * запасний колір тіла — і порожній рядок виглядав звичайним. А саме + * порожні рядки розділяють розділи конфігу: намальовані як текст, вони + * стирають межі, по яких людина на мінікарту й дивиться. + * + * Знайдений рядок перефарбовується цілком, а не заклеюється смугою + * поверх: смуга на всю ширину ховала форму рядка — те саме, що людина + * шукає очима. + */ +export function rowColor(shape: Shape, i: number, hit: boolean): string | null { + if (shape.len[i] <= 0 || shape.kind[i] === KIND_BLANK) return null + if (hit) return HIT_COLOR + return KIND_COLOR[shape.kind[i]] ?? KIND_COLOR[KIND_BODY] +} + +export function Minimap({ + count, + offsets, + total, + viewTop, + viewH, + shape, + marks, + hits, + lineAt, + onSeek, +}: { + /** Скільки рядків у віртуальному документі. */ + count: number + /** Верх кожного рядка в пікселях документа; довжина count+1. */ + offsets: Int32Array + /** Повна висота документа. */ + total: number + /** Поточна позиція прокрутки. */ + viewTop: number + /** Висота видимого вікна. */ + viewH: number + shape: Shape + marks?: MinimapMark[] + /** Куди прокрутити (координата документа, верх вікна). */ + /** + * Текст рядка. Без нього мініатюра малює суцільні риси; з ним — + * окремі слова, і саме це відрізняє «схоже на текст» від + * «схоже на штрихування». + * + * Функція, а не масив: читаються лише ті кількасот рядків, що зараз + * у вікні смужки, і тримати заради них другу копію документа не треба. + */ + /** + * Рядки зі збігами пошуку. + * + * Окремо від marks, бо поводяться інакше: збіг — це один рядок, який + * треба ПЕРЕФАРБУВАТИ, а не ділянка, яку треба позначити смугою. + * Множина, а не масив позначок: сюди потрапляють усі збіги до + * єдиного, і на двадцяти тисячах різниця відчутна. + */ + hits?: Set + lineAt?: (i: number) => string + onSeek: (top: number) => void +}) { + const box = useRef(null) + const content = useRef(null) + const overlay = useRef(null) + const gutter = useRef(null) + const [h, setH] = useState(0) + + useEffect(() => { + const el = box.current + if (!el) return + const ro = new ResizeObserver(() => setH(el.clientHeight)) + ro.observe(el) + setH(el.clientHeight) + return () => ro.disconnect() + }, []) + + // Чи влазить документ цілком. + // + // Влазить — показуємо весь, як карту; не влазить — смужка їде за + // прокруткою й показує вікно рядків. Межа не в кількості рядків, а в + // тому, чи лишається рядку два пікселі: саме з них починається + // схожість на текст. + const winRows = Math.max(1, Math.floor(h / ROW_PX)) + const fits = count > 0 && count <= winRows + + // Скільки пікселів смужки припадає на піксель документа. Потрібне + // лише в режимі «влазить цілком». + const k = total > 0 ? h / total : 0 + + // Перший рядок вікна смужки. + // + // Частка прокрутки документа стає часткою прокрутки смужки: коли + // людина внизу файлу — смужка теж унизу. Так само поводяться + // мінікарти в редакторах, і будь-яка інша прив'язка збиває відчуття + // «де я». + const startRow = useMemo(() => { + if (fits || count <= winRows) return 0 + const room = Math.max(1, total - viewH) + const frac = Math.max(0, Math.min(1, viewTop / room)) + return Math.round(frac * (count - winRows)) + }, [fits, count, winRows, total, viewH, viewTop]) + + /** Рядок → y на смужці. -1, якщо рядок поза видимим вікном смужки. */ + const yOfRow = useCallback( + (i: number) => { + if (fits) return Math.min(h - 1, (offsets[i] * k) | 0) + const r = i - startRow + return r < 0 || r >= winRows ? -1 : r * ROW_PX + }, + [fits, offsets, k, h, startRow, winRows], + ) + + /** y на смужці → рядок. Зворотне до yOfRow. */ + const rowAtY = useCallback( + (y: number) => { + if (!fits) return Math.max(0, Math.min(count - 1, startRow + Math.floor(y / ROW_PX))) + // Двійковий пошук по offsets: рядки можуть мати різну висоту + // (у порівнянні є порожні заповнювачі), тож ділення не годиться. + const target = k > 0 ? y / k : 0 + let lo = 0 + let hi = count - 1 + while (lo < hi) { + const mid = (lo + hi + 1) >> 1 + if (offsets[mid] <= target) lo = mid + else hi = mid - 1 + } + return lo + }, + [fits, count, startRow, offsets, k], + ) + + // Найдовший рядок задає горизонтальний масштаб. Обрізаємо стелею: + // один рядок ACL на тисячу символів інакше стиснув би весь інший + // файл у ліву третину смужки й позбавив би мініатюру сенсу. + const cols = useMemo(() => { + let m = 1 + for (let i = 0; i < shape.len.length; i++) if (shape.len[i] > m) m = shape.len[i] + return Math.min(m, MAX_COLS) + }, [shape]) + + // --- шар 1: сам файл --- + useEffect(() => { + const cv = content.current + if (!cv || h <= 0 || count === 0) return + const dpr = Math.min(window.devicePixelRatio || 1, 2) + cv.width = Math.round(MAP_W * dpr) + cv.height = Math.round(h * dpr) + const ctx = cv.getContext('2d') + if (!ctx) return + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.clearRect(0, 0, MAP_W, h) + + const left = fits ? 3 : GUTTER_W + 3 + const scale = (MAP_W - left - 3) / cols + + // У режимі «влазить цілком» рядку дістається стільки пікселів, + // скільки дає масштаб; беремо не більше, ніж у режимі прокрутки, + // щоб короткий файл не перетворювався на набір товстих брусків. + // Середня висота рядка документа в пікселях смужки. Точну взяти + // нема звідки — у порівнянні є порожні заповнювачі, — але для + // товщини штриха середньої досить. + const perRow = count > 0 ? (h * 1.0) / count : BAR_PX + const barH = fits ? Math.max(1, Math.min(BAR_PX, Math.floor(perRow) - 1 || 1)) : BAR_PX + + const bar = (a: number, b: number, y: number) => { + const x0 = left + Math.min(a, cols) * scale + const x1 = left + Math.min(b, cols) * scale + // Мінімум один піксель ширини: коротке `!` теж має бути видно. + ctx.fillRect(x0, y, Math.max(1, x1 - x0), barH) + } + + const draw = (i: number) => { + const color = rowColor(shape, i, hits?.has(i) === true) + if (color === null) return + const L = shape.len[i] + const y = yOfRow(i) + if (y < 0) return + ctx.fillStyle = color + + // Без доступу до самого тексту лишається суцільна риса від + // відступу до кінця рядка. Працює, але читається гірше. + const text = lineAt?.(i) + if (!text) { + bar(shape.indent[i], L, y) + return + } + + let j = 0 + let segs = 0 + while (j < text.length && segs < MAX_SEGS) { + while (j < text.length && (text[j] === SP || text[j] === TAB)) j++ + const a = j + while (j < text.length && text[j] !== SP && text[j] !== TAB) j++ + if (j > a) { + bar(a, j, y) + segs++ + } + } + } + + if (fits) { + for (let i = 0; i < count; i++) draw(i) + } else { + const last = Math.min(count, startRow + winRows) + for (let i = startRow; i < last; i++) draw(i) + } + }, [count, shape, cols, h, fits, startRow, winRows, yOfRow, lineAt, hits]) + + // --- шар 1б: позначки по ВСЬОМУ документу (лише в режимі прокрутки) --- + // + // Вікно смужки показує кількасот рядків із тридцяти тисяч. Без цієї + // колонки людина, яка шукає слово, бачила б лише збіги поруч і не + // знала б ні скільки їх усього, ні куди прокручувати. + useEffect(() => { + const cv = gutter.current + if (!cv || h <= 0) return + const dpr = Math.min(window.devicePixelRatio || 1, 2) + cv.width = Math.round(GUTTER_W * dpr) + cv.height = Math.round(h * dpr) + const ctx = cv.getContext('2d') + if (!ctx) return + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.clearRect(0, 0, GUTTER_W, h) + if (fits || count === 0) return + + // Щільність, а не «є/немає». + // + // На 32 000 рядків у 620 пікселів в один піксель падає п'ятдесят + // рядків. Бінарна заливка перетворює колонку на суцільну смугу вже + // від сотні розкиданих збігів — і людина бачить «збіги всюди» + // замість «ось де їх багато». + const kk = h / count + const dens = new Float32Array(h) + let peak = 0 + const bump = (row: number) => { + if (row < 0 || row >= count) return + const y = Math.min(h - 1, (row * kk) | 0) + dens[y] += 1 + if (dens[y] > peak) peak = dens[y] + } + let color = 'rgba(251,191,36,0.9)' + if (hits) for (const row of hits) bump(row) + else if (marks) { + for (const m of marks) bump(m.row) + if (marks.length > 0) color = marks[0].color + } + if (peak === 0) return + + for (let y = 0; y < h; y++) { + const d = dens[y] + if (d === 0) continue + // Корінь, а не пряма пропорція: поодинокий збіг має лишатись + // помітним поруч зі згустком на півсотні. + ctx.globalAlpha = 0.35 + 0.65 * Math.sqrt(d / peak) + ctx.fillStyle = color + ctx.fillRect(0, y, GUTTER_W, 1) + } + ctx.globalAlpha = 1 + }, [marks, hits, count, h, fits]) + + // --- шар 2: позначки --- + useEffect(() => { + const cv = overlay.current + if (!cv || h <= 0) return + const dpr = Math.min(window.devicePixelRatio || 1, 2) + cv.width = Math.round(MAP_W * dpr) + cv.height = Math.round(h * dpr) + const ctx = cv.getContext('2d') + if (!ctx) return + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.clearRect(0, 0, MAP_W, h) + if (!marks || marks.length === 0 || count === 0) return + + // Двадцять тисяч збігів пошуку в шістсот пікселів — це двадцять + // тисяч викликів fillRect поверх шести сотень видимих ліній. + // Позначка на піксель уже намальована — другу малювати нема сенсу. + // Збіги пошуку сюди не потрапляють: їх малює шар вмісту, кольором + // самого рядка. Тут лишаються смуги порівняння — вони позначають + // ділянку, а не окремий рядок, і закривати текст їм не страшно. + const left = fits ? 0 : GUTTER_W + const painted = new Map() + for (const m of marks) { + if (m.row < 0 || m.row >= count) continue + const y0 = yOfRow(m.row) + if (y0 < 0) continue + const y1 = fits + ? Math.min(h, (offsets[Math.min(count, m.row + (m.span ?? 1))] * k) | 0) + : y0 + Math.max(1, (m.span ?? 1) * ROW_PX) + for (let y = y0; y < Math.max(y0 + 1, Math.min(h, y1)); y++) { + if (!painted.has(y)) painted.set(y, m.color) + } + } + for (const [y, color] of painted) { + ctx.fillStyle = color + ctx.fillRect(left, y, MAP_W - left, 1) + } + }, [marks, offsets, count, h, k, fits, yOfRow]) + + // --- перетягування --- + // + // Клік і тяга — одна дія, а не дві: людина тисне на потрібне місце й + // нерідко одразу ж підправляє позицію, не відпускаючи кнопки. + // setPointerCapture потрібен, щоб курсор, який виїхав за смужку, не + // обривав тягу — інакше довелося б вести мишу рівно по ширині смужки. + const seekTo = useCallback( + (clientY: number) => { + const el = box.current + if (!el || k <= 0) return + const rect = el.getBoundingClientRect() + const y = Math.max(0, Math.min(rect.height, clientY - rect.top)) + // Клікнуте місце стає ЦЕНТРОМ вікна, а не його верхом: людина + // цілиться в те, що хоче побачити, а не в межу екрана. + onSeek(Math.max(0, offsets[rowAtY(y)] - viewH / 2)) + }, + [rowAtY, offsets, viewH, onSeek], + ) + + const [dragging, setDragging] = useState(false) + + // Рамка видимого вікна. Звичайний div: прокрутка рухає його зміною + // style, і жодного піксельного малювання при цьому не відбувається. + // Рамка видимого вікна. + // + // У режимі прокрутки вона рахується в РЯДКАХ, а не в пікселях + // документа: смужка тепер показує рядки, і рамка, побудована на + // піксельній частці, з'їжджала б із них на пів екрана. + const firstRow = fits ? 0 : viewFirstRow(offsets, count, viewTop) + const rowsInView = fits ? 0 : Math.max(1, Math.round(viewH / Math.max(1, total / count))) + const frameTop = fits + ? Math.max(0, viewTop * k) + : Math.max(0, (firstRow - startRow) * ROW_PX) + const frameH = fits + ? Math.max(4, Math.min(h - frameTop, viewH * k)) + : Math.max(4, Math.min(h - frameTop, rowsInView * ROW_PX)) + + if (count === 0) return null + + return ( +