За другою рецензією:
* 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>
602 lines
24 KiB
Go
602 lines
24 KiB
Go
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 }
|