core.login_attempts наповнювалась на кожній спробі входу й читалась
рівно в одному місці — для стримування підбору. Ні ендпойнта, ні
сторінки. На питання «хто заходив у мій моніторинг» продукт мав
відповідь у базі й не мав способу її показати.
ГОЛОВНЕ БУЛА НЕ ВЕРСТКА. У таблиці немає tenant_id — саме тому ці
події й не в аудиті. Показати «як є» означало б віддати одному
кабінету спроби входу чужих людей. Прив'язка непряма: спроба ->
користувач -> членство, трьома шляхами одночасно (user_id, username,
email) в одному JOIN LATERAL з tenant_id усередині. Саме JOIN, а не
LEFT JOIN: без збігу рядок ПРИБИРАЄТЬСЯ, а не лишається без імені.
На бойових даних це не теорія: у власника немає пошти, тож за email
не прив'язується ЖОДНА з 94 спроб. Шлях через username дає 93.
Невдала спроба з неіснуючим логіном не належить нікому: рядком не
показується, але рахується числом — сигнал «логіни перебирають»
лишається, чужа людина в чужий кабінет не потрапляє.
Ізоляцію перевірено проти справжньої бази (TestLoginAttemptsTenantIsolation).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
За цілий день активної роботи в журналі не з'явилось нічого: цих
доменів у ньому просто не було. Проєкт це визнавав сам, у
AuditBlindSpots().
Тепер пишуться: правила алертів (створення/зміна/видалення й окремо
вимкнення-увімкнення), канали, драбини ескалації, правила
відповідності; додавання людини в кабінет, зміна ролі, вилучення,
правка профілю й скидання пароля.
Два рішення про зміст запису:
* вимкнення видно з НАЗВИ дії (alr.rule.disable), а не з різниці
подробиць — питання «хто вимкнув правило, за яким приходив алерт»
має відповідатись переліком, а не порівнянням;
* config каналу не їде в запис ВЗАГАЛІ — там не лише токен бота, а й
адреса вебхука (доступ на запис у чужий чат) і заголовок
Authorization. Замість нього прапорець secret_changed.
Мапи, групи й шаблони свідомо лишились у сліпих зонах: їхня правка
видима в самому об'єкті. Туди ж додано дві нові чесні межі — тригери
від шаблонів пишуться повз обробник, і пробне повідомлення в канал.
Прогнано проти справжньої бази: секрет каналу й пароль учасника в
подробиці не потрапляють.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
«журнал аудиту 0 → 0 ok» читалось як доказ роботи журналу. Насправді
установник робить рівно три речі, і всі три — у переліку сліпих зон
(store.AuditBlindSpots): власник, строки зберігання, локальний зонд. Дані
пісочниця наливає прямим SQL, повз API. Нуль тут очікуваний.
Пробу лишено (покаже, якщо колись почнуть писати), але поруч тепер
сказано, що правило min на нулі не доводить нічого. І додано пробу, яка
доводить: спроби входу — самоперевірка заходить у продукт, порожня
таблиця означала б, що записування входів відвалилось.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Перший прогін на стенді пройшов, але таблиця «дані до й після» мала
ОДИН рядок замість вісімнадцяти — і виглядала повною.
docker exec читає stdin, а stdin у циклі проб — той самий їх перелік.
Перша проба з'їдала решту, цикл завершувався після одного оберту, і
нічим цього не виказував: рядок є, «ok» є.
Одне </dev/null. Тепер видно, що міграція пройшла по базі з 6936
відліками телеметрії, 24 конфігами й 12 алертами, а не по порожній.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Наявна пісочниця перевіряла лише установку з нуля. Шлях «стояла 0.1,
стала 0.2» не перевіряло ніщо, а ламається він частіше: міграції поверх
даних, зміна конфігурації, старий зонд проти нового колектора.
«Попередня версія» — це КОМІТ, а не тег і не дамп. Тег неможливий:
перевірка оновлення є вхідним квитком до першого тегу. Дамп не запускає
старий колектор і старий зонд, тобто не бачить двох класів поломки з
трьох. Коміт дає повне дерево, яке ВИКОНУЄТЬСЯ, а не описується — і тег
теж ref, тож --from v0.1.0 запрацює в день першого тегу.
Ізоляція закрита першою, зокрема неочевидна діра: обидві версії
збиралися б під тегом netpulse/server:dev — тим самим, що ділить бойова
інсталяція, і обірваний прогін лишив би :dev на старій збірці.
ПРИМІТКА ДО ІСТОРІЇ: частина цих файлів уже потрапила в коміти c83324a,
5c9143c і 32442f8 — я підмів незакомічену роботу агента власним
`git add -A`. Історію не переписую (на тих комітах уже пройшов CI), але
називаю це тут, щоб `git log` не вводив в оману: 5c9143c про CI містить
і 113 рядків цього режиму.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Обрано побайтову точність архіву замість маскування — без неї
відновлення з архіву не працює. Наслідок прийнято свідомо: доступ до
дзеркала рівносильний доступу до всіх паролів у мережі.
Маскування лишається там, де конфіг читає людина: план відкату й
стенограми завдань.
Записано, щоб не переобговорювати.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Окрема робота dbtest у ci.yml. Базу дає services: — докер-сокет
усередину роботи не прокидається, тож кожна робота не отримує root на
хості. Запобіжник імені бази спрацював на DSN роботи server і змусив
завести окрему netpulse_probe: підлаштували конвеєр, а не запобіжник.
Сторож вимагає в логу «застосовано міграцій: N» і «усе зелене проти
бази» — «зелено, нічого не зробивши» неможливо.
Задача 110 на раннері: success.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Механізм відкату існував з 0035 і жодного разу не виконувався на
справжньому обладнанні. Прогін до межі заліза (план -> заявка ->
погодження, без заливки) дав три правильні відмови й одну знахідку.
D-Link питає пароль інтерактивно, і `show config` віддає відповіді
окремими рядками без ключових слів. Порядкове маскування за зразками
такий рядок не бачить, тож пароль адміністратора живого комутатора
лежав у плані відкату, у ncm.rollbacks і в git-дзеркалі — маскування
перед записом у git немає взагалі.
redactLines отримав стан: після рядка заведення облікового запису до
двох односкладових рядків маскуються. Ім'я запису лишається видимим.
Дорогою: перший рядок збереженого конфігу — відлуння команди
(`Command: show config`), і планувальникклав його в команди до заливки.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Старий розділ лишено як опис причин, але половина його вже неправда:
CI-раннер стоїть, build.py --check у конвеєрі, тести вебу виросли до
340, приймач кнопок Telegram працює. Новий розділ — те, що відкрите
сьогодні, у порядку ціни помилки.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Перша спроба віддавала p.Username — прогін на стенді показав порожнє.
Поле оголошене в структурі й не присвоюється ніде, а в токені лежить
лише пошта. Нова UserByID читає ім'я з core.users; класти його в токен
означало б, що після перейменування людина чверть години бачить старе.
Спіймано лише тому, що перевірка йшла наживо, а не на збірці.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Кнопка під сповіщенням відповідала «ваш Telegram не прив'язано» і не
давала виходу. У базі нуль прив'язок і нуль кодів за весь час.
Сторінка профілю існує, але пункту меню не мала, а єдиний вхід — ім'я
користувача в шапці — малювався за умовою «є ім'я або пошта», тоді як
/me віддавало лише пошту. В облікового запису власника, який заводить
установник і який входить ІМЕНЕМ, вона порожня. Тобто в типовій
інсталяції входу в профіль не було взагалі.
* /me віддає username (тип Me на фронтенді його вже вимагав);
* вхід у профіль малюється завжди для людини;
* пункт меню «Обліковий запис → Мій профіль», perm став необов'язковим;
* текст бота називає те, що видно на екрані;
* сторінка каналів показує стан прив'язки біля telegram-каналу.
Плюс 0073: оренда сходинки ескалації отримала lease_token. Партія
переростає 2-хвилинну оренду, і другий інстанс доставляв ту саму
сходинку паралельно з першим. Тепер запис проходить лише за збігу
токена; при розбіжності не відбувається нічого, сходинка лишається
належною.
65 міграцій, усе зелене проти справжньої бази.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Асиметрія: аварія о 21:59 ескалювала всю ніч, о 22:01 не ескалювала
ніколи. Дві хвилини різниці — протилежні наслідки, причому гірший
(повна тиша) виглядав як тиша справна.
ВАДА. targets() повертав порожньо в тиху годину, а взведення читало це
як «немає куди слати». Взводять лише новий алерт, тож драбина не
з'являлась уже ніколи: тиха година вимикала механізм саме тоді, коли
перше сповіщення не спрацювало. Тепер targets() розрізняє «каналів
немає» і «канали є, просто зараз ніч».
ВИБІР. 0072 додає respect_quiet_hours на драбину:
false (типово, як діяло) — драбина пробивається;
true — сходинка відкладається до ранку і НЕ витрачається.
Залежить від того, чи є в кабінету нічна зміна — це вирішує кабінет.
disaster пробивається за будь-якого значення, як і в targets().
Відлік драбини — від першого сповіщення, а не від started_at: інакше
для розглушеного алерту вона протухла б ще у вікні.
І сам прогін проти бази брехав: dbtest.sh котив схему готовим образом
(старі міграції), а тести брав із нового дерева. Тепер міграції з того
ж дерева. 64 міграції, усе зелене.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
За другою рецензією:
* scripts/dbtest.sh писав у шапці «не напрямляйте на робочу базу» й
нічого для цього не робив — перевірено, пішов котити міграції на базу
з бойовим іменем. Тепер вимагає probe/test в імені.
* sendText ковтав помилку, тож журнал ескалацій писав «надіслано» на
сходинці, жодне повідомлення якої не дійшло. Три результати замість
двох: no_channels, failed, sent.
* stopped_at IS NULL рятував лише від ack; гасіння правилом і
ResolveMissing рядка драбини не чіпають, і сходинка дзвонила за
погашеним алертом. Додано перевірку стану алерту в тому ж UPDATE.
* escalate() блокував весь тік движка — мертвий вебхук одного кабінету
зупиняв обчислення правил усім. Винесено в RunEscalations.
* алерт, народжений під заглушенням, не сповіщався ніколи: ні при
народженні, ні коли вікно скінчилось. Тепер перехід suppressed→firing
сповіщається, а драбина рахує час від першого сповіщення.
* alr.rules.channel_ids приймав чужі канали, глушачи і сповіщення, і
драбину. Перевірка як для сходинок; DeleteChannel чистить посилання.
І перше, що зловив прогін проти справжньої бази: nil-зріз каналів їде
явним NULL повз DEFAULT '{}' — правило без каналів давало 500.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Три вади, знайдені рецензією, яких щасливий шлях показати не міг:
* outcome='sent' писався до доставки; помилка читання каналів клала в
кеш порожню мапу й з'їдала всі сходинки кабінету за тік — усі зі
слідом «надіслано». Канали тепер читаються до просування стану,
журнал пишеться після доставки, з правдою.
* UPDATE не мав stopped_at IS NULL — підтвердження алерту посеред
партії не рятувало людину від дзвінка.
* час брався раз на партію.
Плюс суміжне: UpdateRule не гасив алертів вимкненого правила, сервер
домислював enabled на оновленні, channel_ids сходинок не звірялись із
каналами кабінету (зокрема чужого).
І scripts/dbtest.sh — тести проти бази перестали мовчки пропускатись.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Подія відповідності несла шкалу critical/high/medium, а поріг тригера
порівнювався шкалою алертів, де SeverityRank("critical") = 0. Тригер із
порогом «warning» пропускав середнє й відкидав найважче — тихо.
Подія тепер народжується в шкалі алертів; умова тригера приймає обидві.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Маршрут PUT /api/v1/channels/{id} стояв на handleCreateChannel з
першого дня. Обробник читав тіло й ЗАВЖДИ кликав CreateChannel: він
жодного разу не читав id зі шляху.
При цьому UpdateChannel у store написана повністю й ретельно
прокоментована — включно з рішенням «порожній секрет означає лишити
токен як є, бо розшифрувати збережений заради показу означало б
віддати його туди, звідки він не повернеться». У всьому дереві її не
кликав НІХТО.
Ззовні це виглядало так: правка каналу або заводила дубль, або — якщо
назву лишили — падала з «канал із такою назвою вже є». Тобто
відредагувати канал було неможливо взагалі, а повідомлення про помилку
вказувало не на ту причину.
Візерунок узято з правил у тому самому файлі: там гілка оновлення за
PathValue уже стояла й працювала.
Перший справжній прогін на живій мережі дав 18 порушень із 28: типові
SNMP-community на всіх шести хостах, telnet на керуванні на чотирьох,
паролі відкритим і зворотним текстом. Механізм працює — тому з
результатом тепер треба щось робити.
РЕДАГУВАННЯ. Вбудовані правила замкнені на те, що визначає ПИТАННЯ
(name, kind, pattern, config_type) і відкриті на політику кабінету
(enabled, severity, selector, remediation). Причина замка — доказ:
тест читає зразки з міграції й показує для кожного конфіг, де він
мусить спрацювати і де не мусить. Переписаний руками зразок цього
доказу не має, а значок «вбудоване» лишається — у звіті для аудитора
рядок означав би вже не те, що в довіднику. Для правок є копія.
Перевірка зразка на живому конфізі ДО збереження: віддає рядки з
номерами й окремо розрізняє «конфігу немає» від «нічого не знайшов».
Для правил «не має бути» нуль збігів підсвічується: це те саме, що
показало б правило з опискою.
ЗНАХІДКИ РЕЦЕНЗІЇ — всі пʼять підтверджені:
1. Перше збереження будь-якого вбудованого правила стирало результати.
Селектор порівнювався в базі, але порівнювались різні представлення
одного значення: міграція кладе {}, Go марширує сім ключів із null.
Тепер порівняння за ЗНАЧЕННЯМ у Go, колонка канонізується сама.
2. CSV приймав ін’єкцію формул — у клітинку йде сирий рядок конфігу, а
файл відкриває аудитор. Одне місце екранування на всі три звіти:
дублювати захист у трьох файлах означає забути його в четвертому.
3. Знахідки вимкнених правил і зниклих хостів лишались назавжди й
рахувались як чинні. Три заслони: фільтр у списку, прибирання при
прогоні, і звіт їх не рахує.
4. Лічильники в списку правил рахувались по всіх хостах повз права —
інженер філії бачив «5 з 12», а в знахідках дві. Тепер це одне
число, а не два.
5. Доказ перевірки зразка лишався на екрані після правки зразка — тобто
ручка робила протилежне до задуманого в мить найвищої довіри.
Продукт показував syslog З ПРИСТРОЇВ, аудит, черги, стенограми команд —
усе про мережу, і нічого про себе. Щоб дізнатись, на що лається сам
NetPulse, треба було заходити по ssh.
Буфер у памяті, не в базі: журнал, що пише в Postgres, замовкає рівно
тоді, коли ляже Postgres — у найцікавіший момент. Стеля в БАЙТАХ
(8 МіБ на процес), а не в рядках: один рядок із текстом SQL-помилки
буває довшим за сотню звичайних, тож «5000 рядків» означало б
непередбачувані десятки мегабайтів.
Два процеси — один перелік із позначкою джерела, а не дві вкладки:
людина знає симптом («о третій ночі перестали йти сповіщення»), а не
те, який із двох процесів за це відповідає.
Маскування — наявним gitstore.Scrub, не своїм: паролі DSN, матеріал
DEK, секрет підпису сесій. Це другий рубіж — відомі шляхи вже почищені
в місці народження, але кільце робить журнал видимим у браузері й
вивантажуваним у файл, що піде в тікет.
Межі написані НА СТОРІНЦІ, а не лише в документації: журнал не
переживає перезапуску й не покаже причини падіння бази. Поруч —
скільки записів витіснено: без цього числа не відрізнити «нічого не
сталося» від «сталося стільки, що початок уже не влазить».
Палітру приглушували тоді, коли мініатюра малювала РИСКИ. Для суцільної
риски контраст і є тією «жирністю» — тьмяніша риска виглядає тоншою.
Для дрібної ЛІТЕРИ все навпаки: вона складається зі штрихів в один
піксель, і кожен відсоток контрасту, який у неї забрали, це відсоток
розбірливості. Тьмяний шрифт такого розміру читається саме як
«нечіткий», хоча жодного розмиття в ньому немає.
Одна й та сама зміна лікує риски й калічить текст. Малюємо текст —
контраст потрібен.
Шрифт 5 px (округлюється до цілого пікселя пристрою), рядок 6 px.
Чіткість дрібного шрифту тримається на двох речах, і обидві про
піксельну сітку, а не про розмір. Позицію рядка вирівняли минулого
разу; бракувало другого — САМОГО РОЗМІРУ.
5.5 px при щільності екрана 1.25 це 6.875 фізичного пікселя: кожна
риска літери потрапляє між пікселями й розмазується. Тепер розмір
округлюється до цілого фізичного (5.6 px, що дає рівно 7), і штрихи
стають різкими без утрати розміру.
Саме тому подальше зменшення «на око» не працювало б: винен був не
розмір, а те, що він не лягав на сітку.
Менше — на прохання. Чіткіше — виправленням того, чого бракувало з
самого початку: полотно масштабоване на dpr, тож координата 9 при
масштабі 1.25 дає 11.25 фізичного пікселя, і браузер розмазує літеру
між двома. На дрібному шрифті це читається як «нечітко», хоча винен не
розмір, а півпікселя зсуву.
Тепер позиції округлюються до цілого пристроєвого пікселя. Розмиття
зникає без жодної втрати, і саме тому шість пікселів тепер працюють
там, де раніше не вистачало семи.
Скільки не підбирай товщину, колір і щільність — прямокутник не стає
літерою. Арифметика: щоб текст читався, символу треба 5-6 пікселів;
при 120 символах у смузі 180 px виходило 1.4. Читабельним він не міг
стати за жодних налаштувань, і три попередні спроби це лікували
симптоматично.
Тепер canvas друкує ПОЧАТОК рядка шрифтом 7 px — найменшим, на якому
моноширинний ще лишається літерами (на 6 зникають внутрішні просвіти в
e, a, o). Ширина символа вимірюється, а не рахується формулою: у різних
системах підставляється різний моноширинний шрифт.
Для конфігу початок рядка й несе структуру: відступ і ключове слово
стоять зліва, а хвіст з адресами не читався б однаково. Табуляція
розгортається в пробіли — у canvas вона не має ширини, і блоки JunOS
з їхали б до лівого краю.
Рядків у вікні стало близько вісімдесяти замість двохсот. Це обмін, а
не втрата: двісті нечитабельних показують силует файла, вісімдесят
читабельних показують, ДЕ ти в ньому.
Запасний шлях прямокутниками лишився для порівняння версій, де рядки
беруться з різниці, а не з файла.
Дві причини мазанини, і обидві не там, де я спершу шукав.
ШИРИНА. 280 символів рядка тиснулись у 180 пікселів — 0.64 пікселя на
символ. Проміжок між словами виходив менший за піксель і зникав при
округленні, тож рядки ставали суцільними рисами. Тепер 120 символів:
1.4 пікселя на символ, проміжок тримається цілим пікселем. Хвости
довгих рядків на карту не потрапляють — для мережевих конфігів це
майже нічого не коштує, бо відступ і ключове слово стоять на початку.
ЯСКРАВІСТЬ. На майже чорному тлі світло-сірий штрих читається як
жирний навіть в один піксель: око бачить не товщину, а контраст. Саме
тому попереднє зменшення висоти штриха мазанини не прибрало.
Палітра на крок тьмяніша, різниця між видами рядків збережена.
Скарга: «жирний, зливається, текст не видно — просто лінії». На конфігах
із довгими схожими рядками (ZTE на 33 тисячі) це перетворювалось на сіру
масу.
Причина не в кольорі й не в товщині шрифту як такій: світлий текст на
майже чорному тлі Windows малює помітно товщим, ніж він є. Типове
лікування — -webkit-font-smoothing: antialiased — на Windows у Chrome не
працює взагалі, воно лише для macOS. Тому зроблено те, що там справді
діє: рядок 22 px замість 20 і невеликий міжлітерний просвіт.
Розмір лишився 12 px навмисно: на 33 тисячах рядків кожен зайвий піксель
висоти — це ще один екран прокрутки.
ROW_H оновлено разом із класом: воно мусить збігатися з розміткою, бо
за ним рахується віртуальна прокрутка й позиція рамки на мінікарті.
ФАЙЛИ В /var/log/netpulse. rsyslog читає journald і розкладає по файлах
на службу. Драйвер docker лишається journald, а не syslog: syslog
віддав би рядки назовні й нічого не лишив докеру, тож `docker logs`
замовк би назавжди — і мовчання виглядало б як «служба нічого не пише».
Тепер працюють усі три шляхи: docker logs, journalctl, файли.
Дорогою три власні помилки, кожна виглядала як «rsyslog не працює»:
1. Умова матчила $!CONTAINER_NAME — метадані журналу. journalctl їх
бачить, а до правил rsyslog вони доходять не завжди. Каталог
створювався й лишався порожнім.
2. Тег «netpulse/{{.Name}}» здавався охайнішим, але rsyslog обриває
programname на скісній: для ВСІХ контейнерів вона ставала просто
«netpulse». Тег тепер — саме ім'я контейнера, воно й так має
префікс проєкту.
3. Шаблон `%msg:::sp-if-no-1st-sp,drop-last-lf%` давав ПОРОЖНІЙ
текст: файли були, рядки були, слів не було. Журнал виглядав
робочим і не містив нічого — найгірший різновид поломки.
Конфігурації в deploy/, щоб їхали клієнтам, а не лишались разовим
налаштуванням одного сервера.
МІНІКАРТА. Штрих рядка був 2 px при 3 px на рядок — просвіт в один
піксель. На дробовому масштабі екрана (1.25, 1.5 — тобто на більшості
ноутбуків) він губився при округленні, і рядки злипались у суцільну
пляму: мінікарта показувала не форму конфігу, а сірий прямокутник.
Тепер штрих 1 px, просвіт удвічі товщий за нього й переживає будь-яке
округлення. Малюнок став блідішим — це правильний бік розміну: на
мінікарту дивляться, щоб побачити структуру, а не прочитати текст.
МЕНЮ. Шість груп, разом 1246 px при вікні 720 — довше за екран. Тепер
згортаються, стан живе в localStorage як перелік ЗГОРНУТИХ (не
розгорнутих: інакше кожна нова група приїхала б до наявних користувачів
схованою). Ключ групи окремо від заголовка — підписи тут уже
переписували, і якби ключем був заголовок, кожне переписування слова
тихо скидало б налаштування всім.
Група з активною сторінкою розгортається сама, але рівно одна — вибір
для решти не чіпається. Правка стану на ЗМІНІ шляху, а не на кожному
рендері, тож згорнути активну групу теж можна.
Дві наявні поломки мобільного вигляду, знайдені дорогою: рейка
вмикалась класами md:, а розгалуження заголовок/риска було на JS і про
md: не знало — людина згортала панель на моніторі, і в шухляді на
телефоні ЗНИКАЛИ ВСІ назви розділів.
ЛОГО. Пульс на лінії зв'язку: вузол → сплеск → провал → норма → вузол.
Два вузли по краях — ключова деталь: без них це іконка «activity» з
будь-якого безкоштовного набору, з ними кардіограма стає лінією між
двома точками мережі. SVG кодом, не растр: слідує темі через
currentColor, важить сотні байтів.
Три РІЗНІ малюнки, а не один масштабований: повне лого, знак, і
спрощений для 16 px усередині favicon.ico (зібраний власним
кодувальником на zlib, без сторонніх інструментів). Пульсація вузла —
опційна й типово вимкнена: у шапці вона конкурувала б зі справжнім
показником зв'язку, який стоїть поруч.
ПОЛЯ З ПІДКАЗКОЮ. Field малює підказку під контролем, тож у ряду з
вирівнюванням по низу колонка з підказкою вища — її підпис і поле
спливають угору, а гола кнопка з'їжджає вниз. Видно було в словнику
трапів; та сама вада знайшлась у редакторі фільтра портів.
Ряди вирівняно по верху, а кнопки без підпису загорнуто у FieldGap —
порожній підпис такої самої висоти. Відступ у пікселях полагодив би
один ряд і роз'їхався б від зміни розміру шрифту.
Перевірено очима в живому Chrome: іконки віддаються (4/4, правильні
типи й розміри), лого 64×64 на сторінці входу, праве коло справді
sky-400 і пульсує лише там.
ЖУРНАЛИ БЕЗ СТЕЛІ. На запитання «чому не пишемо в /var/log» правильна
відповідь — «бо в контейнері це не той шар»: файл усередині зникає при
перестворенні, невидимий для docker logs і вимагає власної ротації.
Але за питанням стояла справжня вада: драйвер json-file був
налаштований порожньо, тобто НЕ КРУТИВ НІЧОГО. Проксі за добу набрав
27 МБ; до повного диска були місяці, і першим ліг би Postgres.
Тепер 10 МБ × 3 файли на службу — близько 250 МБ на інсталяцію, і
драйвер міняється однією змінною (journald, syslog) для тих, кому
потрібні справжні файли або чужий збирач.
САМОПЕРЕВІРКА ЗОНДА чекала хвилину й одного разу вже дала хибне
червоне: зонд зареєструвався, просто пізніше — перший старт припадає
на найзавантаженішу мить установки. Тепер три хвилини з повідомленням
кожні півхвилини: мовчазна пауза невідрізненна від зависання, а хибне
червоне після успішної установки коштує години пошуку неіснуючої
поломки.
GITSTORE: TestDeleteBranchLocal кличе зовнішній git і не мав перевірки
на його відсутність — падав у golang:1.25-alpine, тобто саме там, де
його проганяють. Сусідній тест таку перевірку має.
ЕТАП 13 у ROADMAP: реєстр образів, релізи, пакети. Записано, чому
порядок саме такий (пакет без реєстру ставив би «зберіть самі») і чого
треба досягти до першого тегу — оновлення з версії на версію не
перевіряв ніхто, а ламається найчастіше саме воно.
Агент свідомо перейменував вихідні колонки функції на devices_used,
maps_used, agents_used, users_used — щоб `devices` поруч із таблицею
inv.devices не давало неоднозначності. Запит у Go лишився на старих
іменах.
Код збирався, локальні тести були зелені (пропускались без
NETPULSE_TEST_DSN), а перше ж звернення до бази падало з
«column "devices" does not exist». Сторінка білінгу не показала б
нічого.
Знайдено пісочницею: установка зі свіжого клону, 63 міграції на чистій
базі, потім тести з живою БД. Це той самий клас, що ловився весь день —
зелена перевірка доводить рівно те, що перевіряє, а тест, який
мовчки пропускається, не перевіряє нічого.
Заодно записано в шапці api_test.go умову, якої той набір вимагає:
база має бути порожньою. Користувачі в продукті глобальні, і на вже
поставленій системі seedUser натрапляє на власника admin.
П'ять паралельних задач. Найцінніше в них — не можливості, а знайдене.
0069 БІЛІНГ. Аудит 0009 показав, що перевірка ліміту не спрацювала б
жодного разу: isPlanLimit шукала слово «ліміт», а тригер писав
"device limit reached" англійською. Перше ж досягнення стелі дало б
клієнту 500 замість пояснення. Плюс три діри: тригер лише на INSERT
(стеля в 15 обходилась за чотири дії через архів), max_maps/max_agents/
max_users не перевіряло ніщо — тобто рівно те, чим відрізняються плани,
і license_keys була закрита політикою tenant_isolation з 0011, хоча
tenant_id там NULLABLE навмисно: головний сценарій self-hosted був
недосяжний.
Після закінчення ліцензії не вимикається нічого — замерзає лише ріст.
Моніторинг, що перестав моніторити через несплачений рахунок, це
аварія в мережі клієнта, спричинена нами.
0070 SLA. Джерелом обрано ts.icmp_1h, а не device_status_history:
остання не вміє сказати «ми не знали» — перехід пишеться лише при
зміні стану, тож доба мовчання зонда виглядає як доба роботи. Час
розкладено на чотири частини, і «немає даних» не додається ні до чого;
замість вибору між двома брехнями звіт каже, яку частку періоду він
бачив. Закритий період тримає тригер, а не домовленість у Go.
0071 ВІДПОВІДНІСТЬ. 20 правил, кожне прив'язане до родини: об'єднаний
вираз, що покриває Cisco й не покриває MikroTik, дав би «0 порушень» і
сховав сліпу пляму. Вендор не входить у перелік, доки для нього немає
зразка конфігу в тесті. TestBuiltinRulesAreNotAlwaysGreen вимагає, щоб
у кожного правила був конфіг, де воно спрацювало, І де ні.
ПІСОЧНИЦЯ УСТАНОВНИКА — та сама установка в ізольованому проєкті
compose. Знайшла дві справжні вади з трьох спроб:
* healthcheck бази ходив unix-сокетом, а споживачі по TCP. При
первинній ініціалізації Postgres слухає лише сокет — compose
вважав базу здоровою, migrate отримував connection refused. На
створеній базі цієї фази немає, тож вада чекала на першого клієнта;
* у білому переліку модулів API не було traps і filecfg — зонд із
приймачем трапів неможливо було зареєструвати взагалі.
ТЕСТИ СТОРІНОК: 137 → 252. Мережевий шар, права доступу, незворотні
дії, фільтри з адресного рядка. Підмінюється лише fetch і WebSocket —
api/client.ts працює справжній.
Учора додав Dragonfly `--maxmemory`, щоб він не рахував ту саму
памʼять, яку вже порахував PG_SHARED_BUFFERS. На стенді це поклало
кеш у цикл перезапусків:
There are 4 threads, so 1.00GiB are required. Exiting...
Dragonfly вимагає 256 МБ на КОЖЕН потік вводу-виводу, а їх за
замовчуванням стільки ж, скільки ядер. На 4-ядерній машині стеля 512 МБ
означає «потрібен 1 ГіБ» — і відмову стартувати. Тепер потоків два:
для кешу сесій з запасом, і 512 МБ їм вистачає.
Друга половина помилки тихіша: `command:` ЗАМІНЮЄ команду образу, а не
доповнює її, тож разом зі стелею я загубив `--logtostderr`. Журнал
пішов у файл усередині контейнера — тобто `docker logs` мовчав би, і
контейнер, що падає в циклі, виглядав би як контейнер без проблем.
Обидва слова повернуто явно.
Знайдено відкриттям порту трапів: кеш ліг при тому ж перезапуску, і
якби не порожній `docker logs`, я шукав би причину значно довше.
Перший прогін CI завалив роботу agent на TestInformIsAcknowledged:
«connection refused» від власного слухача. Локально той самий тест
проходив — тобто плаваючий, а плаваючий тест отруює CI сильніше за
відсутність CI: він привчає не дивитись на червоне.
Причина — гонка в тесті. Run піднімається в горутині, а відправник шле
одразу, не чекаючи. Між стартом горутини й зайняттям сокета є вікно, і
датаграма, що в нього потрапила, отримує від ядра «port unreachable».
На вільній машині вікно програє гонку майже завжди, під навантаженням
раннера — виграє.
Заодно знайшлось гірше, і вже не в тесті: рядок «приймач трапів слухає»
друкувався ПЕРЕД tl.Listen(). Тобто журнал стверджував успіх до спроби,
і навіть тоді, коли порт зайняти не вдалося, — а для 162 це саме той
випадок, коли приймач, який мовчить, виглядає точнісінько як спокійна
мережа. Тепер повідомлення йде після фактичного зайняття, і поруч
з'явився Listening(): канал, який закривається тоді ж.
Перевірено 20 прогонів поспіль — зелено. І окремо варте уваги: цю ваду
не спіймала б жодна з наших перевірок, окрім справжнього CI під
навантаженням. Він виправдав себе на першому ж запуску.
Обидві знайдені першим справжнім стартом на стенді — саме там, де автор
файла чесно написав «поведінка act_runner це міркування з документації,
а не спостереження».
1. Демон падав у циклі з «permission denied … docker.sock». Образ
раннера працює від uid 1000, а сокет належить root:docker з правами
лише для власника й групи. Додано group_add із номером групи сокета;
номер різний на різних машинах, тому змінна DOCKER_GID і команда для
пошуку в коментарі.
2. Прибиральник падав у циклі з «syntax error: unexpected end of file
(expecting "do")». `command:` рядком compose розбиває на слова за
правилами оболонки, тож /bin/sh -c отримував аргументом саме «while».
Тепер список з одного блокового скаляра — один аргумент, як і
задумано.
Друга особливо показова: YAML валідний, синтаксис sh валідний, і кожна
перевірка окремо зелена. Ламається лише поєднання, і лише при запуску.
Перевірка портів давала на всі три випадки один текст: «майже завжди
сторонній веб-сервер (nginx, apache)». Для 80 і 443 це правда, для
9443 — ні: це порт колектора зондів, і найімовірніша причина там —
інший NetPulse на тій самій машині.
Знайшлось сухим прогоном на стенді, де бойова система тримає всі три
порти. Формально повідомлення «правильне» — порт справді зайнятий, — і
саме тому воно небезпечне: людина піде шукати nginx, якого немає.
Заодно зафіксовано, що сухий прогін працює: 12 кроків, обчислені
значення (shared_buffers 1024MB, стеля кешу 512mb, вісім фонових
робітників на чотирьох ядрах), і чесне «на справжній установці це
зупинило б» замість тихого продовження.
Один коміт, а не десяток тематичних, свідомо: теми переплетені в
спільних файлах (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 серпня.
Знайдено на живому ZTE C320. Вхід чекає «Username:» з дедлайном читання
у дві секунди, а дедлайн сокета липкий: він лишався на всі наступні
читання.
Наслідок подвійний. Довгий конфіг обривався на середині помилкою
«read tcp: i/o timeout» — мережевою там, де мережа ні до чого. А коли
ще й запрошення не збігалось зі зразком, той самий дедлайн спрацьовував
раніше за власний таймер, і замість «не дочекались запрошення» людина
бачила ту саму мережеву помилку.
Щоб це побачити, довелося спершу полагодити діагностику: стенограма
починалась після входу, тобто після того місця, де все зупинялось.
Тепер розмова входу пишеться теж — без пароля, лише те, що надіслав
пристрій.
Плюс вбудований профіль zte-zxan: наявні профілі ZTE чекають «>» або
«]», а OLT показує «ZXAN#». Окремий профіль, бо в Comware трапляються
рядки з самої решітки, і зразок із «#» обірвав би їхній конфіг.
Результат на живому C320: 26 555 рядків.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Раніше незроблене було розсипане: частина в таблиці «чесний зріз»,
частина в «дрібному боргу», частина лише в текстах етапів, а частина
ніде — як приймач трапів чи кнопки Telegram, що нікуди не ведуть.
Тепер один розділ, складений перевіркою коду: для кожного пункту
звірено, чи є під нього щось у server/internal, agent/internal і
web/src. Імена таблиць і міграцій теж звірені — sla_targets замість
вигаданого sla_reports, 0009_billing_licensing замість 0009_billing.
Розбито за тим, наскільки відсутність помітна: схема без коду,
оголошене без реалізації, зроблене наполовину, відсутні перевірки й
те, що впирається в зовнішні умови.
«Дрібний борг» прибрано — новий перелік поглинає його цілком.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Плагін http стояв у сіді як базовий — тобто обіцяний усім одразу, — а
модуля не існувало. Тепер є: код відповіді, час, збіг слова в тілі й
залишок днів до кінця сертифіката.
Недоступність повертається нулем, а не помилкою чека: на графіку це
читається як провал, і саме за цим ставлять тригер. Довіру до ланцюга
сертифікатів свідомо не перевіряємо — питають строк, а самопідписаний
теж має дату.
Повторна лінія між вузлами й повторно доданий хост давали однакове
«такий запис уже існує». Тепер кожен випадок каже, що робити далі.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Дві помилки, які видно лише на живому розгортанні.
Сервер віддавав зондам свою адресу прослуховування замість тієї, за
якою до нього дістаються: у посвідченні опинялось ":9443". Реєстрація
проходила, а підключитись після перезапуску зонд не міг ніколи.
Порожнє значення тепер означає «лишись на адресі, якою прийшов».
Запрошення одноразове, але живе в змінних оточення й лишається
назавжди — кожен рестарт контейнера падав із «запрошення недійсне»,
хоча посвідчення поруч робоче. Наявне посвідчення тепер важить більше.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Образи зібрались із першого разу, але далі знайшлось три речі.
memlock без обмеження для Dragonfly дозволений не всюди: у контейнерній
віртуалізації ядро відмовляє, і контейнер не стартує взагалі. Швидкість
того не варта — обмеження знято.
Caddy нескінченно просив Let's Encrypt видати сертифікат на IP, чого той
не робить, і сайт лишався без TLS. Тепер порожня ACME_EMAIL означає
самопідписаний сертифікат. Плюс default_sni: SNI не містить IP-адрес,
тож без нього рукостискання обривалось «internal error».
У README команда заведення власника не працювала: у образу ENTRYPOINT, і
без --entrypoint ім'я команди йшло аргументом до netpulse-api. Заразом
netpulse-user навчився створювати перший кабінет — свіжа база не має
жодного, а користувач без кабінету нікуди не належить.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Регресія від переносу списку в портал. Портал рве DOM-дерево, але не
дерево React: подія з нього спливає до компонента-батька, тобто до
модального вікна. Воно закривається за натисканням «повз панель» — і
клік по пункту, який фізично лежить у body, вважався кліком поза формою.
Зупиняємо спливання на самому списку. Клік по підкладці закриває форму,
як і раніше.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Два дефекти на одному екрані.
Модальні вікна ставали аркушем знизу під 768 px. Вікно браузера на
740 px — не телефон, а десктоп у півекрана, і аркуш там притискався до
низу й обрізався. Поріг знижено до 640 px.
Випадний список малювався absolute усередині вікна з власною
прокруткою, і та його обрізала: видно було стільки пунктів, скільки
лишалось місця до краю форми. Тепер портал у body з fixed-координатами
від поля, з розгортанням угору, коли знизу тісно. Портал, а не більший
max-height: обрізає будь-який предок із прокруткою.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Питання «чому всі профілі ssh» виявило справжню помилку. Список
правильний: ssh — розумний типовий вибір. Але це поле ВИРІШУВАЛО
транспорт, а профіль описує модель, не конкретну коробку.
Хост зі старою прошивкою, де є лише telnet, і з чесно заведеним
telnet-доступом усе одно набирався по SSH — і не збирався ніколи.
inv.credentials.proto вже ніс потрібну відповідь і доїжджав до зонда
невикористаним.
Тепер транспорт бере доступ; профільне поле лишилось підказкою й діє,
поки доступу немає. Логіка винесена в jobTransport() — щоб її можна
було перевірити без бази й без пристрою.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
147 вбудованих профілів досі жили лише в базі — виправити команди під
свою прошивку було ніяк. Тепер є сторінка з пошуком і формою.
Вбудований не правиться, а перекривається власною копією з тим самим
ключем: вони спільні для всіх кабінетів, і правка під одну прошивку не
має міняти їх усім. resolveProfile уже віддавав перевагу тенантському,
тож нічого дописувати не довелося.
Профіль — це три регулярні вирази й перелік команд, і порожня форма з
такими полями не підказує нічого. Тому три робочі заготовки, кнопка
«За зразок» на кожному вбудованому й приклад у кожному полі.
Вирази компілюються при збереженні: інакше про друкарську помилку
дізнаються з бекапу, який завис, чекаючи неіснуючого запрошення.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Пристрій сам каже, що він таке, і шаблон чіпляється без натискань.
Системна група знімається тією ж SNMP-сесією, що й обхід топології:
три зайві PDU дешевші за окремий чек із власним розкладом.
Збіг за префіксом на межі компонента: моделей у виробника тисячі, і
повний збіг означав би рядок на кожну коробку. Довший префікс
перемагає. Дванадцять вбудованих правил на основних виробників.
Шаблони тільки додаються, ніколи не знімаються: автоматика знає модель
пристрою, але не знає, чому цьому хосту дали ще один шаблон руками.
DiscoveredDevice отримав device_id: зіставляти за адресою не можна —
за одним NAT кілька хостів мають ту саму адресу опитування.
Заразом дубль перевірки перестав давати «внутрішню помилку».
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Чотири види правил над зібраними конфігами — має містити, не має
містити, збіг за виразом, немає збігу. jsonpath зі схеми свідомо не
реалізовано: він для конфігів у JSON, а писати його без жодного такого
пристрою під рукою означало б писати навмання.
Перевірка читає вже зібране й не створює сесій до заліза, тому прогін
синхронний і безкоштовний для мережі. Хост без конфігу пропускається,
а не рахується проваленим: «ще не збирали» і «не відповідає» — різні
речі, і плутати їх означає ховати справжні знахідки.
Знахідка показує рядок і його номер. Для правил «має бути» рядка немає,
і таблиця так і пише: нічого — саме це й проблема.
Вираз компілюється при збереженні, а не під час перевірки, інакше про
друкарську помилку дізнаються з правила, яке мовчки нічого не знаходить.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Телевізор нікуди не залогиниш — зранку на стіні висітиме форма входу
замість карти мережі. Тому /tv/<токен> розгалужується до перевірки
сесії, а доступ дає токен у посиланні.
Публічний зріз навмисно вузький: розкладка, алерти, хости й метрики
ЛИШЕ тих хостів, які показані на цьому дашборді. Ширший доступ був би
простішим у коді й перетворив би забутий лінк на ключ до кабінету.
Плитки ті самі, що в кабінеті: джерело даних підмінюється контекстом.
Дорогою знайдено дві помилки. Вбудовані icmp-тригери мали умову у
формі, якої движок не розуміє, і мовчали, засипаючи журнал помилками —
виправлено міграцією 0026 і в редакторі. База стенда виявилась у
SQL_ASCII: тепер міграція відмовляється накочуватись на не-UTF8, а
compose задає кодування явно.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Приймач на зонді лежав без діла — тепер під'єднаний. Окремий стрім
StreamLogs, а не контрольний канал: сплеск логів під час аварії не має
заважати heartbeat і командам.
Хост зіставляється за адресою джерела на зонді: у сервера немає
контексту мережі клієнта, а один приватний діапазон трапляється в
десятках кабінетів. Невідома адреса не привід викинути подію.
Подія, що збіглася зі зразком у ncm.device_policies.syslog_match,
ставить позачерговий збір конфігу. Типовий зразок покриває Cisco,
HP/Huawei, Juniper і MikroTik — навмисно широкий: зайвий бекап коштує
секунд, пропущений — цілої зміни.
Заразом увесь репозиторій прогнано через gofmt: CI, написаний два
кроки тому, перевіряє це і впав би на 29 файлах із порушеннями,
накопиченими за весь проєкт.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Дванадцять пунктів у стовпчик означали читати всі дванадцять, щоб
знайти один. Тепер чотири групи за питанням, з яким відкривають меню:
огляд, мережа, налаштування збору, організація. Група без доступних
ролі пунктів зникає разом із заголовком; у згорнутому вигляді заголовок
замінює риска, а назва групи переїжджає в підказку.
Зсув значків мала не розмітка (0.4 px), а смуга прокрутки — вона
забирає ширину справа й тягне колонку вліво. Плюс значок сидів у
коробці w-4 з паддінгом px-3, тобто з нульовою шириною вмісту, і
емодзі різної ширини центрувались випадково. Тепер квадрат h-5 w-5 з
flex-центруванням, смуга прокрутки схована. Зсув рівно 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
«Правила» пішли з бічної панелі у вкладку поруч з «Алертами»: тригери
переїхали в шаблони, а окремий пункт меню й далі пропонував заводити
правила там, звідки їх прибрали.
Кнопки перестали бути виділюваним текстом — у них типово
user-select: auto, і в підпис можна було поставити блимаючий курсор.
Бічна панель згортається у значки, стан у localStorage.
Дорогою закрито дірку: вбудований шаблон нікого не сповіщав. Тригери
стають правилами лише при збереженні шаблону, а вбудований нікому не
належить і не зберігається — хост із icmp-basic збирав пінг і мовчав
про недоступність. Плюс індекс правил з 0024 не враховував тенанта,
через що другий кабінет із тим самим вбудованим шаблоном отримав би
помилку унікальності.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>