Netpulse_SasS/server/internal/alerting/notify.go
byrsapty 954be1d643
All checks were successful
CI / hygiene (push) Successful in 9s
CI / web (push) Successful in 1m15s
CI / server (push) Successful in 1m34s
CI / agent (push) Successful in 2m59s
Ескалації: закриваю те, що минулого разу закрив наполовину
За другою рецензією:

* 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>
2026-08-28 17:44:38 +03:00

602 lines
24 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 := 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
}
// Відлік драбини — від МОМЕНТУ ПЕРШОГО СПОВІЩЕННЯ, а не від початку
// алерту. Для щойно піднятого це той самий тік, різниці немає. А от
// для алерту, з якого щойно зняли заглушення, різниця вирішальна:
// відлік від 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 — сам вирішувач, окремо від запису в базу.
//
// Виділено в чисту функцію навмисно: усі три його «ні» коштують чийогось
// сну, а перевірити їх інакше можна лише піднявши базу. Тест на «драбину
// взведено» доводить мало; тести на «драбину НЕ взведено, бо каналів не
// знайшлось / алерт придушено / політики немає» — це і є те, заради чого
// функція існує.
func shouldArmEscalation(a store.Alert, act store.RuleAction, hasAct bool, sent int) bool {
return a.State == "firing" && sent > 0 && 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 {
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
}
var out []store.Channel
for _, id := range act.ChannelIDs {
if c, ok := byID[id]; ok && allow(c) {
out = append(out, c)
}
}
return out
}
// Тенант без жодного маршруту — найчастіший випадок: підключили
// Telegram і чекають, що працюватиме. Вимагати ще й маршрут означало б
// мовчати саме там, де налаштування щойно зроблене й здається повним.
if len(routes) == 0 {
var out []store.Channel
for _, c := range all {
if allow(c) {
out = append(out, c)
}
}
return out
}
seen := map[string]bool{}
var out []store.Channel
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)
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
}
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 }