c021fb8273
Требование НРД при подключении к сервису 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
350 lines
12 KiB
Go
350 lines
12 KiB
Go
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, ", ")
|
||
}
|