package lkgateway import ( "context" "crypto/tls" "encoding/binary" "encoding/json" "fmt" "log" "mime" "net" "net/smtp" "strings" "time" ) // alerts.go — внешняя доставка системных оповещений сверх ленты «Новости». // Единая точка врезки — dispatchAlert, вызывается из raiseSystemAlert. // Два канала: SMTP (почта оператору) и Zabbix trapper (хук в мониторинг). // dispatchAlert рассылает оповещение по включённым внешним каналам. // Best-effort: ошибки логируются, но НЕ поднимают новый алерт (иначе рекурсия). func dispatchAlert(rc *RuntimeConfig, title, body string) { if rc == nil { return } cfg := rc.Snapshot() a := cfg.Alerts if a.SMTP.Enabled { // адрес алертов + группы по маршруту типа (сроки поручений / QUIK / системные) s := a.SMTP s.To = mergeEmails(a.SMTP.To, cfg.Incoming.Recipients(alertKind(title))) if strings.TrimSpace(s.To) != "" { if err := sendSMTP(s, "[bj] "+title, body); err != nil { log.Printf("lk-gateway: алерт SMTP не доставлен: %v", err) } } } if a.Zabbix.Enabled { if info, err := sendZabbix(a.Zabbix, title+" — "+body); err != nil { log.Printf("lk-gateway: алерт Zabbix не доставлен: %v", err) } else { log.Printf("lk-gateway: алерт Zabbix отправлен: %s", info) } } } // --- SMTP --- // sendSMTP отправляет письмо согласно настройкам (STARTTLS/SSL/без шифрования, // опциональная аутентификация). Таймаут 15 с. func sendSMTP(s SMTPSettings, subject, body string) error { if strings.TrimSpace(s.Host) == "" { return fmt.Errorf("не задан хост SMTP") } if strings.TrimSpace(s.From) == "" { return fmt.Errorf("не задан отправитель (From)") } rcpts := splitRecipients(s.To) if len(rcpts) == 0 { return fmt.Errorf("не заданы получатели (To)") } port := s.Port if port == 0 { port = 587 } addr := fmt.Sprintf("%s:%d", s.Host, port) msg := buildMIME(s.From, rcpts, subject, body) dialer := &net.Dialer{Timeout: 15 * time.Second} var client *smtp.Client var err error if strings.EqualFold(s.Security, "ssl") { // Неявный TLS (обычно порт 465). conn, e := tls.DialWithDialer(dialer, "tcp", addr, &tls.Config{ServerName: s.Host}) if e != nil { return fmt.Errorf("TLS-соединение: %w", e) } client, err = smtp.NewClient(conn, s.Host) } else { conn, e := dialer.Dial("tcp", addr) if e != nil { return fmt.Errorf("соединение: %w", e) } client, err = smtp.NewClient(conn, s.Host) } if err != nil { return fmt.Errorf("SMTP-клиент: %w", err) } defer client.Close() if !strings.EqualFold(s.Security, "ssl") && !strings.EqualFold(s.Security, "none") { // STARTTLS (по умолчанию, обычно порт 587). if ok, _ := client.Extension("STARTTLS"); ok { if err := client.StartTLS(&tls.Config{ServerName: s.Host}); err != nil { return fmt.Errorf("STARTTLS: %w", err) } } else { return fmt.Errorf("сервер не поддерживает STARTTLS — выберите «без шифрования» или «SSL»") } } if strings.TrimSpace(s.Username) != "" { if err := smtpAuthenticate(client, s.Username, s.Password, s.Host); err != nil { return err } } if err := client.Mail(s.From); err != nil { return fmt.Errorf("MAIL FROM: %w", err) } for _, r := range rcpts { if err := client.Rcpt(r); err != nil { return fmt.Errorf("RCPT TO %s: %w", r, err) } } wc, err := client.Data() if err != nil { return fmt.Errorf("DATA: %w", err) } if _, err := wc.Write(msg); err != nil { return fmt.Errorf("запись тела: %w", err) } if err := wc.Close(); err != nil { return fmt.Errorf("завершение тела: %w", err) } return client.Quit() } // smtpAuthenticate выбирает механизм по объявленным сервером (PLAIN/LOGIN/ // CRAM-MD5) и пробует их по очереди — многие серверы не поддерживают PLAIN // (ошибка «504 Unrecognized authentication type»), а только LOGIN. func smtpAuthenticate(client *smtp.Client, user, pass, host string) error { ok, mechs := client.Extension("AUTH") if !ok { return fmt.Errorf("сервер не поддерживает аутентификацию (AUTH), но задан логин") } var tried []smtp.Auth if strings.Contains(mechs, "PLAIN") { tried = append(tried, smtp.PlainAuth("", user, pass, host)) } if strings.Contains(mechs, "LOGIN") { tried = append(tried, loginAuth(user, pass)) } if strings.Contains(mechs, "CRAM-MD5") { tried = append(tried, smtp.CRAMMD5Auth(user, pass)) } if len(tried) == 0 { return fmt.Errorf("сервер предлагает механизмы аутентификации, не поддерживаемые bj: [%s]", mechs) } var lastErr error for _, a := range tried { if err := client.Auth(a); err == nil { return nil } else { lastErr = err } } return fmt.Errorf("аутентификация не удалась (механизмы сервера: [%s]): %w", mechs, lastErr) } // loginAuth — механизм AUTH LOGIN (не входит в stdlib). Сервер шлёт запросы // «Username:»/«Password:», отвечаем логином и паролем. type loginAuthenticator struct{ user, pass string } func loginAuth(user, pass string) smtp.Auth { return &loginAuthenticator{user, pass} } func (a *loginAuthenticator) Start(_ *smtp.ServerInfo) (string, []byte, error) { return "LOGIN", nil, nil } func (a *loginAuthenticator) Next(fromServer []byte, more bool) ([]byte, error) { if !more { return nil, nil } p := strings.ToLower(string(fromServer)) switch { case strings.Contains(p, "user"): return []byte(a.user), nil case strings.Contains(p, "pass"): return []byte(a.pass), nil default: return nil, fmt.Errorf("неожиданный запрос сервера при LOGIN: %q", fromServer) } } func splitRecipients(s string) []string { var out []string for _, p := range strings.FieldsFunc(s, func(r rune) bool { return r == ',' || r == ';' || r == ' ' || r == '\n' }) { if p = strings.TrimSpace(p); p != "" { out = append(out, p) } } return out } // buildMIME собирает простое UTF-8 письмо (заголовки + текст). func buildMIME(from string, to []string, subject, body string) []byte { var b strings.Builder b.WriteString("From: " + from + "\r\n") b.WriteString("To: " + strings.Join(to, ", ") + "\r\n") // RFC 2047 — тема на кириллице в base64 UTF-8. b.WriteString("Subject: " + encodeHeaderWord(subject) + "\r\n") b.WriteString("MIME-Version: 1.0\r\n") b.WriteString("Content-Type: text/plain; charset=UTF-8\r\n") b.WriteString("Content-Transfer-Encoding: 8bit\r\n") b.WriteString("\r\n") b.WriteString(body) b.WriteString("\r\n") return []byte(b.String()) } // encodeHeaderWord кодирует заголовок как =?UTF-8?B?...?= (для кириллицы). func encodeHeaderWord(s string) string { return mime.BEncoding.Encode("UTF-8", s) } // --- Zabbix trapper (протокол zabbix_sender) --- // sendZabbix шлёт значение элемента-trapper на сервер/прокси Zabbix. // Возвращает строку info из ответа сервера (напр. "processed 1; failed 0"). func sendZabbix(z ZabbixSettings, value string) (string, error) { if strings.TrimSpace(z.Server) == "" { return "", fmt.Errorf("не задан адрес сервера Zabbix") } if strings.TrimSpace(z.Host) == "" || strings.TrimSpace(z.Key) == "" { return "", fmt.Errorf("не заданы host/key элемента Zabbix") } port := z.Port if port == 0 { port = 10051 } payload, _ := json.Marshal(map[string]any{ "request": "sender data", "data": []map[string]any{{ "host": z.Host, "key": z.Key, "value": value, }}, }) // Заголовок протокола: "ZBXD" \x01 + 8 байт длины (LE) + данные. header := make([]byte, 13) copy(header, "ZBXD") header[4] = 0x01 binary.LittleEndian.PutUint64(header[5:], uint64(len(payload))) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() d := &net.Dialer{Timeout: 10 * time.Second} conn, err := d.DialContext(ctx, "tcp", fmt.Sprintf("%s:%d", z.Server, port)) if err != nil { return "", fmt.Errorf("соединение: %w", err) } defer conn.Close() _ = conn.SetDeadline(time.Now().Add(10 * time.Second)) if _, err := conn.Write(append(header, payload...)); err != nil { return "", fmt.Errorf("отправка: %w", err) } // Ответ: 13-байтный заголовок + JSON. respHdr := make([]byte, 13) if _, err := readFull(conn, respHdr); err != nil { return "", fmt.Errorf("ответ (заголовок): %w", err) } if string(respHdr[:4]) != "ZBXD" { return "", fmt.Errorf("неожиданный ответ сервера Zabbix") } n := binary.LittleEndian.Uint64(respHdr[5:]) if n == 0 || n > 1<<20 { return "принято", nil } buf := make([]byte, n) if _, err := readFull(conn, buf); err != nil { return "", fmt.Errorf("ответ (тело): %w", err) } var r struct { Response string `json:"response"` Info string `json:"info"` } if json.Unmarshal(buf, &r) == nil { if r.Response != "success" { return "", fmt.Errorf("Zabbix отклонил: %s", r.Info) } if strings.Contains(r.Info, "failed 1") || strings.Contains(r.Info, "failed: 1") { return "", fmt.Errorf("Zabbix не принял значение (%s) — проверьте host/key в Zabbix", r.Info) } return r.Info, nil } return "принято", nil } func readFull(conn net.Conn, buf []byte) (int, error) { total := 0 for total < len(buf) { n, err := conn.Read(buf[total:]) total += n if err != nil { return total, err } } return total, nil } // alertKind — тип оповещения по заголовку (для маршрутизации по группам адресов). func alertKind(title string) string { switch { case strings.HasPrefix(title, "НРД: депо-поручение") || strings.HasPrefix(title, "НРД: истёк срок"): return "orders" case strings.Contains(title, "QUIK"): return "quik" } return "system" } // mergeEmails — объединение списков адресов без дублей. func mergeEmails(lists ...string) string { seen := map[string]bool{} var out []string for _, l := range lists { for _, e := range strings.Split(l, ",") { e = strings.TrimSpace(e) if e == "" || seen[strings.ToLower(e)] { continue } seen[strings.ToLower(e)] = true out = append(out, e) } } return strings.Join(out, ", ") }