Files
Bridge-and-Join-s/internal/lkgateway/alerts.go
T
fontvielle c021fb8273 Анкета участника M2M: сформировать → проверить → отправить в НРД (обычный канал)
Требование НРД при подключении к сервису M2M (МОСТ): зарегистрировать организацию
в справочнике участников анкетой M2MTransferParticipantForm, иначе сервис отбивает
запросы кодом M2M14. Регистрация идёт ОБЫЧНЫМ каналом (письмом на M2MOST@nsd.ru с
вложением), не через M2M-шлюз.

- Новая страница /admin/setup/participant-form (participant_form.go):
  предзаполнение из автоопределения DEPO (ИНН, наименования, код депозитария),
  правка оператором, валидация (формат ИНН/кодов + m2m.HandbookParticipant.Validate),
  предпросмотр анкеты, «Скачать» (windows-1251) и «Отправить в НРД» письмом с
  вложением-анкетой.
- Генерация XML строкой-шаблоном по образцу НРД (namespace participant/form,
  CreationTimestamp по МСК, блоки Eng/BrokerPlace — опциональны), кодировка
  windows-1251 через nsdxml.EncodeWindows1251; экранирование текста только & < >.
- Почта: выделен транспорт smtpDeliver из sendSMTP; добавлены buildMIMEAttach
  (multipart/mixed + base64-вложение) и sendSMTPAttach.
- Тесты: валидация, генерация XML (Eng/BrokerPlace), cp1251 round-trip, MIME-вложение.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XG3zNMMf2LMoDpXo22mwMX
2026-10-15 18:20:03 +03:00

350 lines
12 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"
"strconv"
"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 = notifyRecipients(cfg, 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)")
}
return smtpDeliver(s, rcpts, buildMIME(s.From, rcpts, subject, body))
}
// smtpDeliver — транспорт SMTP: соединение, TLS, аутентификация и отправка
// готового MIME-сообщения. Выделено из sendSMTP для переиспользования в
// письмах с вложением (sendSMTPAttach, participant_form.go).
func smtpDeliver(s SMTPSettings, rcpts []string, msg []byte) error {
port := s.Port
if port == 0 {
port = 587
}
addr := net.JoinHostPort(s.Host, strconv.Itoa(port))
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 {
has := func(prefixes ...string) bool {
for _, p := range prefixes {
if strings.HasPrefix(title, p) {
return true
}
}
return false
}
switch {
case has("НРД: депо-поручение", "НРД: истёк срок"):
return "orders"
case strings.Contains(title, "QUIK"):
return "quik"
case has("ИШ НРД", "Перезапуск ИШ НРД", "НРД не отвечает", "НРД всё ещё не отвечает", "НРД снова на связи",
"Проверка связи с НРД", "Сбой компонента", "Компонент не отвечает", "Служба ", "DEPO: bj не может",
"DEPO: вход логином", "DEPO: обработчик", "Резервная копия"):
return "infra"
case has("Доступно обновление", "ИШ обновлён", "ИШ не обновлён", "Лицензия", "Поддержка bj", "Модуль «",
"Сертификат", "Версия внешней системы"):
return "updates"
}
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, ", ")
}