Netpulse_SasS/server/internal/alerting/notify.go
byrsapty ec4b2cd54b
All checks were successful
CI / hygiene (push) Successful in 10s
CI / web (push) Successful in 1m18s
CI / server (push) Successful in 1m54s
CI / agent (push) Successful in 1m1s
Тиха година й драбина: вада окремо, вибір окремо
Асиметрія: аварія о 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>
2026-08-28 18:08:27 +03:00

625 lines
26 KiB
Go
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

package alerting
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"net"
"net/http"
"net/smtp"
"net/url"
"strings"
"text/template"
"time"
"github.com/netpulse/netpulse/server/internal/crypto"
"github.com/netpulse/netpulse/server/internal/store"
)
// Notifier доставляє алерти в канали.
type Notifier struct {
st *store.Store
log *slog.Logger
hc *http.Client
// allowPrivate вимикає перевірку на внутрішні адреси. Для SaaS вона
// обов'язкова, а от у self-hosted інсталяції вебхук майже завжди
// веде саме всередину — у корпоративний Mattermost чи хелпдеск.
allowPrivate bool
}
func NewNotifier(st *store.Store, log *slog.Logger, allowPrivate bool) *Notifier {
return &Notifier{
st: st,
log: log.With("component", "notify"),
hc: &http.Client{
// Таймаут обов'язковий: канал, який не відповідає, інакше
// зупинив би доставку решті — а це рівно та ситуація, коли
// сповіщення потрібні найбільше.
Timeout: 15 * time.Second,
},
allowPrivate: allowPrivate,
}
}
// Dispatch розсилає партію алертів.
func (n *Notifier) Dispatch(ctx context.Context, tenantID string, alerts []store.Alert, ring *crypto.Keyring) {
channels, err := n.st.LoadChannels(ctx, tenantID, ring)
if err != nil {
n.log.Error("читання каналів", "tenant", tenantID, "помилка", err)
return
}
if len(channels) == 0 {
return
}
byID := make(map[string]store.Channel, len(channels))
for _, c := range channels {
byID[c.ID] = c
}
routes, err := n.st.LoadRoutes(ctx, tenantID)
if err != nil {
n.log.Error("читання маршрутів", "tenant", tenantID, "помилка", err)
return
}
// Маршрутизація самих правил. Помилка тут не має зупиняти доставку:
// краще розіслати за загальними маршрутами, ніж не розіслати нічого.
actions, err := n.st.LoadRuleActions(ctx, tenantID)
if err != nil {
n.log.Error("читання дій правил", "tenant", tenantID, "помилка", err)
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
}
targets, reachable := 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, reachable)
}
}
// armEscalation ставить драбину на бойовий звід — або свідомо не ставить.
//
// Три умови, і кожна з них — окрема відповідь на «чому мене розбудили»
// (та на дзеркальне «чому не розбудили»):
//
// - Драбина взводиться лише для 'firing'. Придушений алерт — це «не
// турбувати», а сповіщення про відновлення ескалювати нема сенсу:
// воно вже добра новина.
//
// - Драбина взводиться лише тоді, коли алерту БУЛО КУДИ піти. Якщо
// жодного каналу не знайшлось — вимкнені канали, поріг серйозності,
// порожній перелік — то ескалація о 15-й хвилині надіслала б те, що
// на нульовій ми свідомо не надіслали. Це виглядало б як
// несправність, і саме так і було б.
//
// Тиха година до цієї умови НЕ належить, хоч і виглядає так само:
// канали є, просто зараз ніч. Раніше вона роззброювала драбину
// назавжди (взводять лише новий алерт), тобто аварія о 22:01 не
// отримувала резервного механізму вже ніколи. Тепер драбина
// взводиться, а зважати їй на тиху годину чи ні — вирішує сама
// драбина (respect_quiet_hours).
//
// - Політику називає саме правило. Немає політики — немає драбини, і
// це типовий стан: після оновлення жоден кабінет не має раптом
// почати будити людей.
func (n *Notifier) armEscalation(ctx context.Context, tenantID string, a store.Alert,
act store.RuleAction, hasAct bool, policies map[string]store.EscalationPolicy, reachable bool) {
if !shouldArmEscalation(a, act, hasAct, reachable) {
return
}
p, ok := policies[act.EscalationPolicyID]
if !ok || len(p.Steps) == 0 {
// Правило посилається на політику, якої вже немає (або в неї
// немає жодної сходинки). Мовчати про це не можна: людина
// лишила галочку в формі й вважає, що її прикриють.
n.log.Warn("правило посилається на порожню або зниклу політику ескалації",
"tenant", tenantID, "правило", a.RuleName, "політика", act.EscalationPolicyID)
return
}
// Відлік драбини — від МОМЕНТУ ПЕРШОГО СПОВІЩЕННЯ, а не від початку
// алерту. Для щойно піднятого це той самий тік, різниці немає. А от
// для алерту, з якого щойно зняли заглушення, різниця вирішальна:
// відлік від started_at означав би, що вся драбина протухла ще у
// вікні обслуговування й висиплеться одним залпом. «П'ятнадцять
// хвилин на підтвердження» мають починатись тоді, коли людина вперше
// могла щось зробити.
if err := n.st.ArmEscalation(ctx, tenantID, a.ID, p.ID,
store.IsEventSource(act.Source), p, time.Now()); err != nil {
n.log.Error("взведення ескалації", "алерт", a.ID, "помилка", err)
}
}
// shouldArmEscalation — сам вирішувач, окремо від запису в базу.
//
// Виділено в чисту функцію навмисно: усі три його «ні» коштують чийогось
// сну, а перевірити їх інакше можна лише піднявши базу. Тест на «драбину
// взведено» доводить мало; тести на «драбину НЕ взведено, бо каналів не
// знайшлось / алерт придушено / політики немає» — це і є те, заради чого
// функція існує.
// reachable — чи БУЛО КУДИ слати цей алерт: або знайшлись канали, або
// вони знайшлись би, якби не тиха година.
//
// Друга половина — не дрібниця. Доти драбина не взводилась, коли перше
// сповіщення притлумила тиха година, а взводять лише НОВИЙ алерт — тобто
// аварія о 22:01 не отримувала драбини вже ніколи, до кінця свого життя.
// Тиха година мовчки роззброювала саме той механізм, який існує на
// випадок, коли перше сповіщення не спрацювало.
func shouldArmEscalation(a store.Alert, act store.RuleAction, hasAct bool, reachable bool) bool {
return a.State == "firing" && reachable && hasAct && act.EscalationPolicyID != ""
}
// targets вирішує, у які канали піде цей алерт.
//
// Порядок вирішення: канали самого правила → маршрути тенанта → усі
// придатні канали. Кожен наступний крок — це відповідь на «а якщо
// нічого не налаштовано», і останній із них навмисно не мовчить:
// підключили Telegram і чекають, що працюватиме.
func (n *Notifier) targets(a store.Alert, act store.RuleAction, hasAct bool,
routes []store.Route, all []store.Channel,
byID map[string]store.Channel, now time.Time, tenantID string) ([]store.Channel, bool) {
allow := func(c store.Channel) bool {
return c.Enabled && severityRank[a.Severity] >= severityRank[c.MinSeverity]
}
// Правило сказало явно — маршрути не питаємо. Інакше «шліть це
// черговому» перетворювалося б на «шліть це черговому і ще туди,
// куди вирішить спільна політика».
if hasAct && len(act.ChannelIDs) > 0 {
if act.Schedule.IsQuiet(now) && severityRank[a.Severity] < severityRank["disaster"] {
_ = n.st.RecordNotification(context.Background(), tenantID, a.ID, "",
"throttled", "тиха година правила", "", nil)
// Каналів немає не тому, що їх не налаштували, — тому що
// зараз ніч. Різницю треба донести нагору: від неї залежить,
// чи взведеться драбина.
return nil, true
}
var out []store.Channel
for _, id := range act.ChannelIDs {
if c, ok := byID[id]; ok && allow(c) {
out = append(out, c)
}
}
return out, len(out) > 0
}
// Тенант без жодного маршруту — найчастіший випадок: підключили
// Telegram і чекають, що працюватиме. Вимагати ще й маршрут означало б
// мовчати саме там, де налаштування щойно зроблене й здається повним.
if len(routes) == 0 {
var out []store.Channel
for _, c := range all {
if allow(c) {
out = append(out, c)
}
}
return out, len(out) > 0
}
seen := map[string]bool{}
var out []store.Channel
quiet := false
for _, r := range routes {
if !r.Matches(a) {
continue
}
if r.Schedule.IsQuiet(now) {
// Тиха година глушить лише те, що можна відкласти. Disaster
// будить завжди: сенс чергування в тому, щоб його підняли.
if severityRank[a.Severity] < severityRank["disaster"] {
_ = n.st.RecordNotification(context.Background(), tenantID, a.ID, "",
"throttled", "тиха година маршруту "+r.Name, "", nil)
// Маршрут підійшов, канали в нього є — мовчить лише ніч.
if len(r.ChannelIDs) > 0 {
quiet = true
}
continue
}
}
for _, id := range r.ChannelIDs {
c, ok := byID[id]
if !ok || seen[id] || !allow(c) {
continue
}
seen[id] = true
out = append(out, c)
}
}
return out, len(out) > 0 || quiet
}
func (n *Notifier) send(ctx context.Context, tenantID string, a store.Alert, c store.Channel) {
_ = n.sendText(ctx, tenantID, a, c, renderMessage(a, c))
}
// sendText відокремлено від send, бо ескалація шле той самий алерт із
// іншою шапкою: людина, розбуджена третьою сходинкою, має з першого
// рядка бачити, що це вже ескалація, а не перше сповіщення, яке вона
// проспала.
// sendText шле одне повідомлення й ПОВЕРТАЄ, чи дійшло.
//
// Повертає помилку не для того, щоб хтось її обробляв — журнал доставки
// пишеться тут же, і вище робити з нею нічого. Повертає, бо той, хто
// рахує «скільком дійшло», інакше рахує не доставки, а лише те, що
// повідомлення пройшло фільтр каналу. Різниця видна рівно тоді, коли
// вона дорога: Telegram лежить десять хвилин, усі доставки падають — а
// журнал ескалацій пише «надіслано».
func (n *Notifier) sendText(ctx context.Context, tenantID string, a store.Alert,
c store.Channel, text string) error {
var extID string
var err error
switch c.Kind {
case "telegram":
extID, err = n.sendTelegram(ctx, c, a, text)
case "webhook", "slack", "discord", "mattermost":
err = n.sendWebhook(ctx, c, a, text)
case "email":
err = n.sendEmail(c, a, text)
default:
err = fmt.Errorf("канал %q поки не реалізовано", c.Kind)
}
status, errMsg := "sent", ""
if err != nil {
status, errMsg = "failed", err.Error()
n.log.Error("доставка", "канал", c.Name, "вид", c.Kind, "помилка", err)
}
if rerr := n.st.RecordNotification(ctx, tenantID, a.ID, c.ID, status, errMsg, extID,
map[string]any{"text": text}); rerr != nil {
n.log.Error("журнал доставки", "помилка", rerr)
}
return err
}
// ---------------------------------------------------------------------
// Формат повідомлення
// ---------------------------------------------------------------------
var defaultTemplate = template.Must(template.New("alert").Parse(
`{{.Icon}} {{.Severity}}: {{.Title}}
{{.Message}}
{{if .DeviceName}}Пристрій: {{.DeviceName}}
{{end}}Час: {{.Started}}`))
type msgData struct {
Icon string
Severity string
Title string
Message string
DeviceName string
Started string
Value string
Threshold string
}
var severityIcon = map[string]string{
"info": "", "warning": "⚠️", "average": "🟠", "high": "🔴", "disaster": "🚨",
}
func renderMessage(a store.Alert, c store.Channel) string {
d := msgData{
Icon: severityIcon[a.Severity],
Severity: strings.ToUpper(a.Severity),
Title: a.Title,
Message: a.Message,
DeviceName: a.DeviceName,
Started: a.StartedAt.Format("2006-01-02 15:04:05"),
}
if a.Value != nil {
d.Value = fmt.Sprintf("%.3g", *a.Value)
}
if a.Threshold != nil {
d.Threshold = fmt.Sprintf("%g", *a.Threshold)
}
tmpl := defaultTemplate
if c.Template != "" {
// Крива шаблонка каналу не має ковтати сповіщення: якщо вона не
// компілюється або не виконується, іде стандартний текст.
if t, err := template.New("custom").Parse(c.Template); err == nil {
tmpl = t
}
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, d); err != nil {
var fb bytes.Buffer
_ = defaultTemplate.Execute(&fb, d)
return fb.String()
}
return buf.String()
}
// ---------------------------------------------------------------------
// Транспорти
// ---------------------------------------------------------------------
type telegramConfig struct {
ChatID json.Number `json:"chat_id"`
ThreadID int `json:"thread_id"`
}
func (n *Notifier) sendTelegram(ctx context.Context, c store.Channel, a store.Alert, text string) (string, error) {
if c.Secret == "" {
return "", fmt.Errorf("канал %s: не задано токен бота", c.Name)
}
var cfg telegramConfig
if err := json.Unmarshal(c.Config, &cfg); err != nil {
return "", fmt.Errorf("канал %s: config: %w", c.Name, err)
}
if cfg.ChatID.String() == "" {
return "", fmt.Errorf("канал %s: не задано chat_id", c.Name)
}
body := map[string]any{
"chat_id": cfg.ChatID,
"text": text,
"parse_mode": "HTML",
}
if cfg.ThreadID != 0 {
body["message_thread_id"] = cfg.ThreadID
}
// Кнопки: підтвердити алерт із телефона — головний сценарій
// чергового, і зайвий перехід у браузер тут коштує хвилин.
// Натискання приймає telegram_bot.go; те, що лежить у callback_data,
// нічого не авторизує — кабінет і особа з'ясовуються там із чату й
// прив'язки, а не з цього рядка.
//
// Кнопки немає в пробного повідомлення (TestChannel): алерту не
// існує, і «Підтвердити» показувало б дію, якої не буде.
if a.ID != "" {
row := []map[string]string{
{"text": "Підтвердити", "callback_data": "ack:" + a.ID},
}
// «Заглушити» без хоста глушити нічого. Алерт без пристрою —
// звичайна річ (зонд не на зв'язку, черга не встигає), і досі
// така кнопка малювалась усе одно: у 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)
if err != nil {
return "", err
}
// Токен іде в шляху URL — так вимагає Bot API. Тому він ніколи не
// потрапляє в журнал: логуються лише ім'я каналу й помилка.
endpoint := "https://api.telegram.org/bot" + c.Secret + "/sendMessage"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
res, err := n.hc.Do(req)
if err != nil {
return "", scrubToken(err, c.Secret)
}
defer res.Body.Close()
var out struct {
OK bool `json:"ok"`
Description string `json:"description"`
Result struct {
MessageID int64 `json:"message_id"`
} `json:"result"`
}
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
return "", fmt.Errorf("telegram: відповідь %d нерозбірлива", res.StatusCode)
}
if !out.OK {
return "", fmt.Errorf("telegram: %s", out.Description)
}
return fmt.Sprint(out.Result.MessageID), nil
}
// scrubToken прибирає токен із тексту помилки: http.Client вкладає в
// неї повний URL, а той містить токен бота.
func scrubToken(err error, token string) error {
if token == "" {
return err
}
return fmt.Errorf("%s", strings.ReplaceAll(err.Error(), token, "«токен»"))
}
type webhookConfig struct {
URL string `json:"url"`
Method string `json:"method"`
Headers map[string]string `json:"headers"`
// Формат тіла: "netpulse" (повний алерт) або "text" ({"text": "..."}),
// який розуміють Slack, Mattermost і Discord.
Format string `json:"format"`
}
func (n *Notifier) sendWebhook(ctx context.Context, c store.Channel, a store.Alert, text string) error {
var cfg webhookConfig
if err := json.Unmarshal(c.Config, &cfg); err != nil {
return fmt.Errorf("канал %s: config: %w", c.Name, err)
}
if err := validateHookURL(cfg.URL, n.allowPrivate); err != nil {
return fmt.Errorf("канал %s: %w", c.Name, err)
}
var payload []byte
var err error
if cfg.Format == "netpulse" {
payload, err = json.Marshal(map[string]any{"text": text, "alert": a})
} else {
payload, err = json.Marshal(map[string]any{"text": text})
}
if err != nil {
return err
}
method := cfg.Method
if method == "" {
method = http.MethodPost
}
req, err := http.NewRequestWithContext(ctx, method, cfg.URL, bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
for k, v := range cfg.Headers {
req.Header.Set(k, v)
}
if c.Secret != "" {
req.Header.Set("Authorization", "Bearer "+c.Secret)
}
res, err := n.hc.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode >= 300 {
return fmt.Errorf("webhook: код %d", res.StatusCode)
}
return nil
}
// validateHookURL не пускає сповіщення на внутрішні адреси.
//
// Адресу вебхука задає користувач тенанта, а запит іде з сервера. Без
// цієї перевірки будь-хто зі своїм акаунтом змусив би NetPulse стукати
// у внутрішню мережу хостера — класичний SSRF, і мовчазне сповіщення
// про алерт тут ідеальний носій.
//
// У self-hosted інсталяції перевірка знімається прапорцем: там внутрішня
// мережа належить тому самому, хто налаштовує вебхук, і заборона просто
// заважала б. Рішення свідомо приймає адміністратор сервера, а не
// користувач тенанта.
func validateHookURL(raw string, allowPrivate bool) error {
if raw == "" {
return fmt.Errorf("не задано url")
}
u, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("некоректний url")
}
if u.Scheme != "https" && u.Scheme != "http" {
return fmt.Errorf("дозволені лише http і https")
}
if allowPrivate {
return nil
}
host := u.Hostname()
ips, err := net.LookupIP(host)
if err != nil {
return fmt.Errorf("не вдалося розв'язати %s", host)
}
for _, ip := range ips {
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
ip.IsUnspecified() || ip.IsMulticast() {
return fmt.Errorf("адреса %s веде у внутрішню мережу", ip)
}
}
return nil
}
type emailConfig struct {
Host string `json:"host"`
Port int `json:"port"`
From string `json:"from"`
To []string `json:"to"`
User string `json:"user"`
}
func (n *Notifier) sendEmail(c store.Channel, a store.Alert, text string) error {
var cfg emailConfig
if err := json.Unmarshal(c.Config, &cfg); err != nil {
return fmt.Errorf("канал %s: config: %w", c.Name, err)
}
if cfg.Host == "" || cfg.From == "" || len(cfg.To) == 0 {
return fmt.Errorf("канал %s: потрібні host, from і to", c.Name)
}
if cfg.Port == 0 {
cfg.Port = 587
}
msg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: [%s] %s\r\n"+
"MIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s",
cfg.From, strings.Join(cfg.To, ", "),
strings.ToUpper(a.Severity), a.Title, text)
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
var auth smtp.Auth
if cfg.User != "" && c.Secret != "" {
auth = smtp.PlainAuth("", cfg.User, c.Secret, cfg.Host)
}
return smtp.SendMail(addr, auth, cfg.From, cfg.To, []byte(msg))
}
// TestChannel надсилає пробне повідомлення — кнопка «перевірити» в UI.
//
// Без неї єдиний спосіб дізнатися, що chat_id помилковий, — дочекатися
// справжньої аварії й не отримати про неї сповіщення.
func (n *Notifier) TestChannel(ctx context.Context, tenantID string, c store.Channel) error {
a := store.Alert{
Severity: "info",
Title: "Перевірка каналу " + c.Name,
Message: "Якщо ви це бачите — канал налаштовано правильно.",
StartedAt: time.Now(),
DeviceName: "netpulse",
}
text := renderMessage(a, c)
var err error
switch c.Kind {
case "telegram":
_, err = n.sendTelegram(ctx, c, a, text)
case "webhook", "slack", "discord", "mattermost":
err = n.sendWebhook(ctx, c, a, text)
case "email":
err = n.sendEmail(c, a, text)
default:
err = fmt.Errorf("канал %q поки не реалізовано", c.Kind)
}
status, errMsg := "sent", ""
if err != nil {
status, errMsg = "failed", err.Error()
}
_ = n.st.RecordNotification(ctx, tenantID, "", c.ID, status, errMsg, "",
map[string]any{"test": true})
return err
}
// Notifier доступний ззовні для кнопки «перевірити» в API.
func (e *Engine) Notifier() *Notifier { return e.notifier }