Netpulse_SasS/server/migrations/0065_snmp_traps.sql
byrsapty ed8fc831bf Дві сесії роботи: 0058–0068, розгортання однією командою, тести
Один коміт, а не десяток тематичних, свідомо: теми переплетені в
спільних файлах (store.go, docker-compose.yml, deploy/README.md), і
розділити їх можна було б лише індексуванням шматків. Коміти, які не
збираються, гірші за один великий — тим паче що це рівно той стан, який
перевірявся разом.

ЩО ПРАЦЮЄ НА СТЕНДІ Й ПЕРЕВІРЕНО ТАМ

  0058  подієві алерти: syslog, ncm, compliance спрацьовують у мить
        події; правило з нереалізованим джерелом більше не зберігається
        мовчки
  0059  snmp.walk і прототипи шаблонів — таблиці з динамічним індексом
        описуються шаблоном, а не Go
  0060  відкат конфігу: план як різниця, маскування паролів із підписом
        плану, обов'язковий контрольний збір, verifying при обриві
  0061  кнопки Telegram: довге опитування, авторизація не з callback_data
  0062  аудит і архів хостів; тест на AST, що падає на ключі без назви
  0063  RLS: три ролі, окремий пул для фонових тактів
  0064  строки зберігання даних і сторінка сховища
  0065  приймач SNMP-трапів; перевірено справжніми пакетами по дроту,
        переклад v1→v2 за RFC 3584 дає правильний OID
  0066  ескалації сповіщень
  0067  алерт про вичерпання диска
  0068  поля заливки конфігу переїхали в каталог профілів

Плюс: 137 тестів вебу з нуля (їх не було взагалі), одинадцять справжніх
вад, знайдених ними й виправлених, і виправлення двох інтеграційних
тестів grpcapi, які мовчки пропускались півтора року.

ЩО ЩЕ НЕ ЗАПУСКАЛОСЬ

  netpulse            установник: одна команда замість 18 змінних і
                      593 рядків інструкції
  RLS з першого запуску  нова інсталяція під політиками одразу;
                      RLS-EXISTING-INSTALL.md лишається тільки для
                      старих інсталяцій
  .forgejo + CI       раннер не зареєстрований

Ці три перевірені компіляцією й міркуванням, але не виконанням.

ГОЛОВНИЙ ВИСНОВОК ДВОХ СЕСІЙ

Зелена перевірка доводить рівно те, що вона перевіряє. Тест ізоляції RLS
був правильний і зелений — і пропустив зламаний вхід, бо перевіряв «чи
не видно чужого», коли зламалось «чи видно своє». Інтеграційні тести
grpcapi були зелені, бо не виконувались. Схема, довідник і протокол
описували те, чого в коді не існувало, і виглядало це як готове.

Тому в кожному завданні цих сесій стояла вимога назвати НЕПОКРИТЕ, а
чотири задачі закінчились не можливістю, а відмовою: правило з
нереалізованим джерелом не зберігається, профіль без команд заливки
каже про це замість мовчазної кнопки, міграція RLS валить сама себе на
таблиці без політики, тест словника аудиту падає на ключі без назви.

Подробиці — HISTORY.md, розділи за 26 і 27 серпня.
2026-08-27 17:32:49 +03:00

233 lines
16 KiB
PL/PgSQL
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

-- =====================================================================
-- NetPulse :: 0065_snmp_traps.sql
-- Трапи перестають бути портом, якого ніхто не слухає.
--
-- Що було. Таблиця ts.snmp_traps існує з 0005, приймач WriteLogs — з
-- перших днів gRPC, поле LogBatch.traps описане в контракті. Не було
-- рівно однієї речі: на зонді ніхто не слухав 162/udp. Тобто вся
-- дорога від пристрою до бази була прокладена, а на її початку не
-- стояв ніхто. Клієнт, який налаштував на комутаторі `snmp-server host
-- <зонд> traps`, отримував порожній журнал — і жодного способу
-- дізнатися, що справа не в комутаторі.
--
-- Друге. 0058 увімкнув подієві алерти для syslog, ncm і compliance, а
-- джерелу `trap` відмовив із таким аргументом: «трап приїжджає як OID і
-- набір varbind-ів; без словника MIB умова звелася б до порівняння
-- цифр із крапками, яких людина не набере з голови». Аргумент був
-- правильний. Висновок із нього — ні: він мовчки припускав, що словник
-- буває або повний, або ніякий.
--
-- Повного не буде, і це рішення, а не відкладена робота: компілятор
-- ASN.1, сховище вендорських MIB на кабінет і підтримка діалектів, у
-- яких виробник суперечить сам собі між прошивками, — це окремий
-- продукт. Але між «усі MIB світу» і «нічого» лежить те, що працює
-- вже сьогодні:
--
-- * шість трапів, однакових у КОЖНОГО вендора, бо їх визначає сам
-- протокол (RFC 1215, snmpTraps з RFC 3418): coldStart, warmStart,
-- linkDown, linkUp, authenticationFailure, egpNeighborLoss. Це і є
-- те, заради чого трапи вмикають у переважній більшості випадків.
-- Вони вшиті в код (store/traps_mib.go), бо не залежать від
-- кабінету й не мають ним налаштовуватись;
-- * власний словник кабінету — таблиця нижче. Кілька рядків на
-- вендорські трапи, які клієнту справді потрібні. Кілька, а не
-- тисячі: у живому кабінеті трапів, на які хтось дивиться, менше
-- десятка;
-- * усе інше показується сирим OID із написом «невідомий трап».
-- Саме з написом. Підставити назву, вгадану за схожістю префікса,
-- означало б збрехати рівно там, де написаному довіряють найбільше.
--
-- Заборону на джерело `trap` знято в коді (store.UnsupportedSourceReason).
-- Чому не в цій міграції — див. останній розділ: там же пояснено, чому
-- правила, вимкнені 0058-ю, тут навмисно НЕ вмикаються назад.
-- =====================================================================
-- ---------------------------------------------------------------------
-- Словник кабінету: OID → людська назва
-- ---------------------------------------------------------------------
-- Таблиця в inv, а не в alr, бо це довідник ПРО МЕРЕЖУ, а не про
-- алерти. Ним підписується журнал трапів, перелік невідомих
-- відправників і текст алерту — тобто три різні місця, і жодне з них
-- не головніше за інші.
--
-- Словник тенантний і не має спільних рядків: вбудовані шість живуть у
-- коді, а не тут. Заводити їх рядками з tenant_id IS NULL означало б
-- пробити дірку в tenant_isolation заради шести констант, які й так
-- однакові для всіх.
CREATE TABLE inv.trap_oids (
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
-- Числовий OID без провідної крапки. Форма зводиться до однієї в
-- коді (store.NormalizeOID): OID приїжджає з трьох місць — від
-- зонда, з форми правила й звідси, — і кожне має свою звичку щодо
-- крапки. Правило, яке не спрацювало через один символ, виглядало б
-- точно як правило, під яке не було подій.
oid text NOT NULL,
name text NOT NULL,
description text,
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, oid)
);
ALTER TABLE inv.trap_oids ENABLE ROW LEVEL SECURITY;
ALTER TABLE inv.trap_oids FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON inv.trap_oids
USING (tenant_id = core.current_tenant())
WITH CHECK (tenant_id = core.current_tenant());
GRANT SELECT, INSERT, UPDATE, DELETE ON inv.trap_oids
TO netpulse_app, netpulse_worker;
COMMENT ON TABLE inv.trap_oids IS
'Власні відповідності «OID трапа → назва» кабінету; вбудовані шість стандартних живуть у коді';
COMMENT ON COLUMN inv.trap_oids.oid IS
'Числовий OID без провідної крапки — форму нормалізує store.NormalizeOID';
-- ---------------------------------------------------------------------
-- Хто шле нам трапи, не будучи хостом
-- ---------------------------------------------------------------------
-- Найдешевша функція в цій міграції й, можливо, найкорисніша.
--
-- Трап приходить від АДРЕСИ. Зонд намагається зіставити її зі своїми
-- хостами; коли не виходить, подія все одно доїжджає й лягає в
-- ts.snmp_traps з порожнім device_id. Цього достатньо, щоб її не
-- втратити, і зовсім недостатньо, щоб її ПОБАЧИТИ: журнал
-- відсортований за часом, і три трапи на добу від чужої адреси тонуть
-- між тисячею своїх.
--
-- А саме ці три найцікавіші. Трап від адреси, якої немає в інвентарі,
-- майже завжди означає одне з двох: у мережі з'явилось кероване
-- залізо, яке забули завести, або в ній стоїть щось чуже. І перше, і
-- друге — новина. Тому окремий перелік: не журнал, а список питань
-- «а що це таке».
--
-- Алертом це зробити не можна, і це свідоме обмеження, а не недоробка:
-- алерт без хоста нікуди не маршрутизується, не глушиться вікном
-- обслуговування й нічого не каже черговому. Алерт — для того, що вже
-- знаєш; перелік — для того, чого ще не знаєш.
CREATE TABLE inv.trap_unknown_sources (
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
source_ip inet NOT NULL,
-- Через який зонд це прийшло. Без цього питання «а де воно взагалі
-- стоїть» лишається без відповіді: приватні діапазони повторюються
-- в кожному другому кабінеті, і 192.168.1.50 сама по собі не каже
-- нічого.
agent_id uuid REFERENCES core.agents(id) ON DELETE SET NULL,
first_seen_at timestamptz NOT NULL DEFAULT now(),
last_seen_at timestamptz NOT NULL DEFAULT now(),
trap_count bigint NOT NULL DEFAULT 0,
-- Останній OID — щоб було видно, ЩО саме воно шле. Одна адреса, яка
-- раз на добу шле coldStart, і адреса, яка щохвилини шле
-- authenticationFailure, — це різні розмови.
last_trap_oid text,
PRIMARY KEY (tenant_id, source_ip)
);
CREATE INDEX trap_unknown_sources_seen_idx
ON inv.trap_unknown_sources (tenant_id, last_seen_at DESC);
-- Стеля на кабінет.
--
-- Адресу відправника UDP підробити нічого не варте: датаграма з
-- вигаданим source_ip прилітає на 162/udp зонда й доходить сюди. Без
-- обмеження цей шлях був би способом наростити таблицю клієнта прямо з
-- його ж мережі — рівно на стільки рядків, скільки в IPv4 адрес.
--
-- П'ятсот — це «більше, ніж буває чесно, і менше, ніж боляче». Перелік
-- читають очима, і сотий рядок у ньому вже ніхто не дивиться; а от
-- п'ятсот рядків по сотні байтів — це навіть не сторінка диска.
--
-- Тригер лише на INSERT: гілка ON CONFLICT DO UPDATE (та сама адреса
-- прислала ще один трап) його не зачіпає, тож у шторм із однієї адреси
-- прибирання не смикається взагалі.
CREATE OR REPLACE FUNCTION inv.trap_unknown_sources_cap() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
DELETE FROM inv.trap_unknown_sources u
WHERE u.tenant_id = NEW.tenant_id
AND u.source_ip NOT IN (
SELECT source_ip FROM inv.trap_unknown_sources
WHERE tenant_id = NEW.tenant_id
ORDER BY last_seen_at DESC
LIMIT 500);
RETURN NULL;
END $$;
CREATE TRIGGER trap_unknown_sources_cap
AFTER INSERT ON inv.trap_unknown_sources
FOR EACH ROW EXECUTE FUNCTION inv.trap_unknown_sources_cap();
ALTER TABLE inv.trap_unknown_sources ENABLE ROW LEVEL SECURITY;
ALTER TABLE inv.trap_unknown_sources FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON inv.trap_unknown_sources
USING (tenant_id = core.current_tenant())
WITH CHECK (tenant_id = core.current_tenant());
GRANT SELECT, INSERT, UPDATE, DELETE ON inv.trap_unknown_sources
TO netpulse_app, netpulse_worker;
COMMENT ON TABLE inv.trap_unknown_sources IS
'Адреси, які шлють трапи й не є хостами: перший слід заліза, про яке моніторинг не знає';
COMMENT ON COLUMN inv.trap_unknown_sources.trap_count IS
'Скільки трапів прийшло з цієї адреси; одна подія — сусід, тисяча — щось кероване';
-- ---------------------------------------------------------------------
-- Журнал трапів стає читабельним
-- ---------------------------------------------------------------------
-- Єдиний індекс, який був на ts.snmp_traps, — (device_id, ts DESC).
-- Він відповідає на питання «що прилітало ОЦЬОМУ хосту» і не відповідає
-- на те, з яким відкривають сторінку: «що прилітало за останню добу».
-- Без цього індексу такий перегляд читає всі чанки періоду підряд.
CREATE INDEX traps_tenant_ts_idx ON ts.snmp_traps (tenant_id, ts DESC);
-- Друге питання, яке ставлять до трапів, — «покажи всі linkDown за
-- тиждень». Воно виникає щоразу після аварії з портами, і без окремого
-- індексу коштує повного сканування періоду з фільтром.
--
-- Третього індексу («лише невідомі відправники») навмисно немає: такий
-- фільтр звужує до одиниць рядків, і вибірка за (tenant_id, ts) з
-- дофільтруванням тут дешевша за ще один індекс, який доводиться
-- підтримувати на кожній вставці.
CREATE INDEX traps_tenant_oid_ts_idx ON ts.snmp_traps (tenant_id, trap_oid, ts DESC);
-- ---------------------------------------------------------------------
-- Правила з джерелом `trap`
-- ---------------------------------------------------------------------
-- Строк життя подієвого алерту — те саме, що 0058 зробив для syslog,
-- ncm і compliance, і з тих самих міркувань. Подієвому алерту нема від
-- чого зникнути: трап linkDown стався й «перестати ставатись» не може.
-- Доба — це «встиг побачити на наступній зміні».
--
-- Правимо лише нулі: якщо в правилі вже стоїть інший строк, це чийсь
-- свідомий вибір, і затирати його міграцією не можна.
UPDATE alr.rules
SET auto_close_seconds = 86400
WHERE source = 'trap' AND auto_close_seconds = 0;
-- А ось чого ця міграція НЕ робить — і це найважливіший її абзац.
--
-- 0058 вимкнула всі наявні правила з джерелом `trap` (і відповідні
-- тригери шаблонів), бо вони не працювали. Тепер джерело працює, і
-- напрошується UPDATE ... SET enabled = true. Його тут немає навмисно.
--
-- Причина в тому, що ті правила писались, коли перевірки умови не
-- існувало взагалі: у їхньому condition лежить що завгодно — порожній
-- об'єкт, залишений regex від syslog, назва трапа словами. Увімкнути
-- їх означало б у ніч після оновлення отримати або тишу (умова не
-- збігається ні з чим), або потоп (порожня умова підпадає під КОЖЕН
-- трап у мережі). Обидва варіанти — це знову «увімкнено й не працює»,
-- тобто рівно та хвороба, яку 0058 лікувала.
--
-- Правильний шлях коротший, ніж здається: правило лишається сірим і
-- вимкненим на екрані, людина відкриває його, бачить умову, і при
-- збереженні форма або приймає її, або каже, чого саме бракує
-- (store.validateTrapCondition). Тобто рішення ухвалює той, хто знає
-- свою мережу, і ухвалює його свідомо — один раз на правило.
--
-- Заборона на збереження таких правил знята в коді, а не тут, з тієї ж
-- причини, з якої 0058 не робила зворотного: перевірка умови живе в
-- застосунку (вона мусить пояснювати людині словами), а міграція не
-- вміє ані пояснювати, ані відмовляти в API.