Files
Bridge-and-Join-s/internal/lkgateway/alerts.go
T
zuevav da12a9ec1c feat(bj): модуль ЛК на PostgreSQL, обмен с DEPO, боевой ЛК с имитатором НРД, QUIK, НПР
- bj_exchange и «кран» Fancy убраны: данные модуля ЛК в PostgreSQL (схема lk)
- трансферные таблицы FTV_M2M: создание при установке, события, справочник участников
- сверка с DEPO: счёт, ФИО, дата рождения, ИИС по договору, квал-инвестор из опции DEPO
- боевой ЛК ЕСИА Финанс при тестовом ИШ: журнал, отказ через DEPO, статусы в ЛК
- онлайн-лицензирование в bj, план тестирования M2M для контрагента (интерактивный HTML)
- модули QUIK МНП, экспорт QUIK, НПР/УДС; документы и тестовые материалы

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 19:41:12 +03:00

327 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
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 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, ", ")
}