Один коміт, а не десяток тематичних, свідомо: теми переплетені в
спільних файлах (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 серпня.
263 lines
11 KiB
Go
263 lines
11 KiB
Go
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
|
||
}
|