Files
Bridge-and-Join-s/internal/lkgateway/readiness.go
T
zuevav bf7c7b53bd Рабочее время организации: запросы M2M вне него — отказ M2M06 (в любом режиме)
Настройка в шаге «Время и сеть» и Настройка → Сервер: дни недели, часы (по поясу сервера), включение
правила. Запрос, полученный вне рабочего времени (и после включения правила), bj сразу отклоняет по всем
бумагам кодом M2M06 «Приостановление операций по счёту депо зачисления» — кода «вне рабочего времени» в
перечне НРД (инструкция по обмену M2M, раздел 7.4) нет, решение депозитария. Строка «Рабочее время» в
проверке готовности; сквозной тест отказа через ИШ. Файл входящих — переменная (тесты не трогают сервер).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
2026-10-06 23:35:50 +03:00

486 lines
21 KiB
Go

package lkgateway
// Готовность к работе — итоговый шаг мастера (05.10.2026): все проверки на одной странице, каждая со
// ссылкой на шаг или раздел, где это исправляется. Обязательные (без них обмен с НРД не работает) —
// красные; желательные (оповещения, копии, время, HTTPS) — жёлтые. Проверки идут параллельно, медленные
// (процедуры DEPO с BJ_PING) не запускаются — берётся последний результат.
import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"os"
"os/exec"
"sort"
"strconv"
"strings"
"sync"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/release"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter"
)
// cryptoJarPath — модуль проверки ключа подписи bj-crypto (компонент crypto-service.jar).
const cryptoJarPath = "/opt/bj/crypto-service.jar"
// ruPlural — форма слова для числа: 1 участник, 2 участника, 5 участников.
func ruPlural(n int, one, few, many string) string {
n %= 100
switch {
case n >= 11 && n <= 14:
return many
case n%10 == 1:
return one
case n%10 >= 2 && n%10 <= 4:
return few
}
return many
}
// ReadyCheck — одна строка итоговой проверки.
type ReadyCheck struct {
Order int `json:"-"`
Title string `json:"title"`
State string `json:"state"` // ok | warn | err | skip
Detail string `json:"detail"`
Link string `json:"link,omitempty"`
Required bool `json:"required"`
}
func readinessChecks(ctx context.Context, rc *RuntimeConfig) []ReadyCheck {
s := rc.Snapshot()
mnp := moduleAllowed(rc, "quik-mnp")
var (
mu sync.Mutex
out []ReadyCheck
wg sync.WaitGroup
)
add := func(c ReadyCheck) {
mu.Lock()
out = append(out, c)
mu.Unlock()
}
run := func(f func() ReadyCheck) {
wg.Add(1)
go func() {
defer wg.Done()
add(f())
}()
}
ok := func(b bool) string {
if b {
return "ok"
}
return "err"
}
run(func() ReadyCheck {
l := licenseStatus(rc)
c := ReadyCheck{Order: 1, Title: "Лицензия", Link: "/admin/wizard?step=1", Required: true, State: ok(l.Valid), Detail: l.Message}
if l.Valid {
c.Detail = pickStr(l.Tenant, "активирована")
if !l.ContractUntil.IsZero() {
c.Detail += ", поддержка до " + l.ContractUntil.Format("02.01.2006")
}
}
return c
})
run(func() ReadyCheck {
st := checkPostgres(ctx, CheckOptions{PostgresDSN: s.Postgres.DSN, Timeout: 3 * time.Second})
c := ReadyCheck{Order: 2, Title: "База bj", Link: "/admin/wizard?step=2", Required: true, State: ok(st.OK && s.Postgres.DSN != ""), Detail: pickStr(st.Message, dsnSummary(s.Postgres.DSN))}
if s.Postgres.DSN == "" {
c.Detail = "база не подключена — заявки не сохраняются"
}
return c
})
if mnp {
run(func() ReadyCheck { return readyDepo(ctx, rc) })
run(func() ReadyCheck {
c := ReadyCheck{Order: 5, Title: "QUIK — модуль неторговых поручений", Link: "/admin/wizard?step=5", Required: true}
cs := quikMNPCheck(ctx, s.Quik)
c.State = mnpWorst(cs)
var bad []string
for _, x := range cs {
if x.State != "ok" {
bad = append(bad, x.Title+": "+x.Detail)
}
}
if len(bad) == 0 {
c.Detail = "подключение, объекты M2M, инструменты, справочник получателей и автоматический цикл — в порядке"
} else {
c.Detail = strings.Join(bad, "; ")
}
return c
})
}
run(func() ReadyCheck {
c := ReadyCheck{Order: 4, Title: "Организация и реквизиты НРД", Link: "/admin/wizard?step=4", Required: true, State: ok(doneOrg(s))}
if doneOrg(s) {
c.Detail = fmt.Sprintf("%s, ИНН %s, код %s; счёт %s, раздел %s", pickStr(s.NSD.OrgName, "—"), orgINN(s), orgCode(s), s.NSD.AccountID, s.NSD.SectionID)
} else {
c.Detail = "не заданы организация или депозитарные реквизиты в НРД"
}
return c
})
run(func() ReadyCheck {
cctx, cancel := context.WithTimeout(ctx, 4*time.Second)
defer cancel()
ver, _, _ := validataVersion(cctx)
c := ReadyCheck{Order: 6, Title: "СКЗИ «Валидата»", Link: "/admin/wizard?step=6", Required: true}
if ver == "" {
c.State, c.Detail = "err", "Валидата на сервере не найдена — без неё шлюз не подпишет пакеты для НРД"
return c
}
c.State, c.Detail = "ok", "Валидата "+ver
// Служба bj-crypto — проверка ключа подписи из bj (работает ли ключ, его сертификат); пакеты для НРД подписывает шлюз, поэтому
// её остановка обмену не мешает. Модуль приезжает с обновлениями — до них службы нет, это не ошибка.
if _, err := os.Stat(cryptoJarPath); err == nil {
if u := unitRunState("bj-crypto"); !(u.State == "active" && u.Up >= 5*time.Second) {
c.State, c.Link = "warn", "/admin/setup#sec-crypto"
c.Detail += "; проверка ключа подписи из bj не работает (служба bj-crypto: " + pickStr(u.State, "нет") + ") — обмену не мешает: пакеты для НРД подписывает шлюз"
if cc, ok := release.ComponentByName("crypto-service.jar"); ok && (u.Restarts >= 3 || u.State == "failed") {
c.Detail += ". Причина: " + unitFailCause(cc)
}
} else {
c.Detail += ", проверка ключа подписи работает"
}
}
return c
})
run(func() ReadyCheck {
c := ReadyCheck{Order: 8, Title: "Сертификат ключа организации", Link: "/admin/wizard?step=8", Required: true}
own := ownValidCerts(storeCertsForUI(s))
if len(own) == 0 {
c.State, c.Detail = "err", "в справочнике ИШ нет действующего сертификата организации — установите ключ мастером установки ключа"
if len(s.NSDCerts.LastStore) == 0 {
c.Detail = "справочник ИШ ещё не прочитан (появится после установки ключа и настройки шлюза)"
}
return c
}
sort.Slice(own, func(i, j int) bool { return own[i].NotAfter.Before(own[j].NotAfter) })
last := own[len(own)-1]
d := certDaysLeft(last.NotAfter)
c.State, c.Detail = "ok", fmt.Sprintf("«%s» до %s (осталось %d дн.)", last.SubjectCN, last.NotAfter.Format("02.01.2006"), d)
if d <= 30 {
c.State, c.Detail = "warn", c.Detail+" — закажите новый ключ в УЦ Московской биржи"
}
return c
})
run(func() ReadyCheck {
c := ReadyCheck{Order: 7, Title: "Шлюз НРД", Link: "/admin/wizard?step=7", Required: true}
up, msg := igateState(ctx, s.NSD.IGWBaseURL)
switch {
case !up:
c.State, c.Detail = "err", msg
case !doneNSD(s):
c.State, c.Detail = "err", "шлюз отвечает, но контур НРД в bj не выбран"
default:
c.State, c.Detail = "ok", msg+"; контур "+s.NSD.Profile
if prof, err := nsdadapter.LookupProfile(s.NSD.Profile); err == nil && orgCode(s) != "" {
code := prof.Channel + orgCode(s)
switch found, enabled, err := igateChannelState(ctx, s.NSD.IGWBaseURL, code); {
case err != nil:
case !found:
c.State, c.Detail = "err", "движок шлюза работает, но канала "+code+" в нём нет — шлюз не загрузил канал: пересоздайте его на шаге «Шлюз НРД» (кнопка «Создать канал»)"
return c
case !enabled:
c.State, c.Detail = "err", "канал "+code+" в шлюзе выключен — он включается, когда установлен ключ подписи этого контура (шаг «Ключ подписи»)"
return c
default:
c.Detail += "; канал " + code + " работает"
}
}
nsdWatch.Lock()
okAt := nsdWatch.LastPollOK
nsdWatch.Unlock()
if !okAt.IsZero() {
c.Detail += "; последний успешный обмен с НРД " + okAt.Format("02.01 15:04")
}
if down, det := nsdLinkStatus(); down {
c.State, c.Detail = "err", det
}
}
return c
})
run(func() ReadyCheck {
c := ReadyCheck{Order: 9, Title: "Оповещения", Link: "/admin/wizard?step=9"}
smtp := s.Alerts.SMTP
switch {
case smtp.Enabled && smtp.Host != "" && smtp.To != "":
c.State, c.Detail = "ok", "почта на "+smtp.To
if smtp.LastMsg != "" {
c.Detail += " (последняя проверка: " + smtp.LastMsg + ")"
}
if strings.HasPrefix(smtp.LastMsg, "ошибка") {
c.State = "warn"
}
case s.Alerts.Zabbix.Enabled:
c.State, c.Detail = "ok", "Zabbix "+s.Alerts.Zabbix.Server
default:
depo := ""
if moduleAllowed(rc, "quik-mnp") {
depo = ", ошибки DEPO"
}
c.State, c.Detail = "warn", "не настроены: о сбоях (НРД не отвечает, недоставленные переводы"+depo+", срок сертификата) узнают только открыв bj"
}
return c
})
run(func() ReadyCheck {
c := ReadyCheck{Order: 10, Title: "Резервные копии", Link: "/admin/wizard?step=9"}
b := s.Backup
st := backupStatus()
switch {
case !b.Enabled:
c.State, c.Detail = "warn", "выключены — при сбое сервера настройки, заявки и данные шлюза не восстановить"
case st == nil:
c.State, c.Detail = "warn", fmt.Sprintf("включены (каждые %d ч), копий ещё не было", b.every())
case !st.OK:
c.State, c.Detail = "err", "последняя копия не удалась: "+st.Msg
default:
c.State, c.Detail = "ok", fmt.Sprintf("каждые %d ч, хранение %d дн.; последняя %s", b.every(), b.keep(), backupWhen(st.At))
if t, err := time.Parse(time.RFC3339, st.At); err == nil && time.Since(t) > time.Duration(2*b.every()+1)*time.Hour {
c.State, c.Detail = "warn", c.Detail+" — давно, проверьте таймер bj-backup"
}
}
return c
})
run(func() ReadyCheck {
c := ReadyCheck{Order: 12, Title: "HTTPS", Link: "/admin/setup#server"}
cn, until, err := tlsCertInfo()
switch {
case err != nil:
c.State, c.Detail = "warn", "сертификат HTTPS: "+err.Error()
case certDaysLeft(until) < 14:
c.State, c.Detail = "warn", fmt.Sprintf("сертификат «%s» истекает %s — замените", cn, until.Format("02.01.2006"))
case !httpsUp.Load():
c.State, c.Detail = "warn", "HTTPS не запущен (порт "+strconv.Itoa(s.HTTPS.port())+" занят или нет сертификата) — вход только по http://, пароли идут по сети открытым текстом; см. журнал bj-server"
case !s.HTTPS.RedirectOn():
c.State, c.Detail = "warn", "перенаправление с http:// на https:// выключено в настройках — пароли могут идти по сети открытым текстом"
default:
c.State, c.Detail = "ok", fmt.Sprintf("вход только по HTTPS, сертификат «%s» до %s", cn, until.Format("02.01.2006"))
}
return c
})
run(func() ReadyCheck {
c := ReadyCheck{Order: 11, Title: "Рабочее время", Link: "/admin/wizard?step=12"}
w := s.WorkHours
switch {
case w.SavedAt.IsZero():
c.State, c.Detail = "warn", "рабочее время организации не задано — запросы на перевод M2M принимаются круглосуточно"
case !w.Enabled:
c.State, c.Detail = "ok", w.Text()+"; ограничение выключено — запросы M2M принимаются круглосуточно"
default:
c.State, c.Detail = "ok", w.Text()+" ("+time.Local.String()+"): запрос на перевод M2M, пришедший вне этого времени, отклоняется кодом "+workHoursRejectCode+" «"+m2mCodeText(workHoursRejectCode)+"»"
if !w.Open(time.Now()) {
c.Detail += "; сейчас нерабочее время"
}
}
return c
})
run(func() ReadyCheck {
c := ReadyCheck{Order: 11, Title: "Время сервера", Link: "/admin/setup#server"}
cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
out, err := exec.CommandContext(cctx, "timedatectl", "show", "-p", "NTPSynchronized", "--value").Output()
switch {
case err != nil:
c.State, c.Detail = "warn", "состояние синхронизации не получено"
case strings.TrimSpace(string(out)) == "yes":
c.State, c.Detail = "ok", "синхронизировано с сервером времени"
default:
c.State, c.Detail = "warn", "время не синхронизируется — часы уплывут, а это важно для подписей и обмена с НРД; укажите сервер времени (NTP)"
}
return c
})
run(func() ReadyCheck {
c := ReadyCheck{Order: 13, Title: "Справочник участников M2M НРД", Link: "/admin/handbook"}
raw, _ := readHandbook()
at := handbookAt(raw)
var hb struct {
Participants []HandbookEntry `json:"participants"`
}
_ = json.Unmarshal(raw, &hb)
switch {
case len(hb.Participants) == 0:
c.State, c.Detail = "err", "справочника нет — получателя перевода не определить"
case !at.IsZero() && time.Since(at) > 14*24*time.Hour:
c.State, c.Detail = "warn", fmt.Sprintf("%d %s, от %s — давно не обновлялся (обновляется по связи с НРД)", len(hb.Participants), ruPlural(len(hb.Participants), "участник", "участника", "участников"), at.Format("02.01.2006"))
default:
c.State, c.Detail = "ok", fmt.Sprintf("%d %s, от %s", len(hb.Participants), ruPlural(len(hb.Participants), "участник", "участника", "участников"), at.Format("02.01.2006"))
}
return c
})
run(func() ReadyCheck { return readyNetwork(ctx, s) })
for i, m := range wizModuleViews(rc, s) { // модули по лицензии (без лицензии — нет и строки)
m := m
add(ReadyCheck{Order: 20 + i, Title: "Модуль: " + m.Title, Link: m.Link, State: map[bool]string{true: "ok", false: "warn"}[m.Ready], Detail: m.Status})
}
run(func() ReadyCheck {
c := ReadyCheck{Order: 15, Title: "Учётные записи", Link: "/admin/setup#users"}
var admins, others int
for _, u := range globalUsers.List() {
if !u.Active {
continue
}
if u.Role == "admin" {
admins++
} else {
others++
}
}
switch {
case s.Auth.ADEnabled:
c.State, c.Detail = "ok", fmt.Sprintf("вход через Active Directory; локальных: администраторов %d, других %d", admins, others)
case others == 0:
c.State, c.Detail = "warn", fmt.Sprintf("только администратор (%d) — заведите учётные записи операторов: у каждого свой вход, в журнале видно, кто что сделал", admins)
default:
c.State, c.Detail = "ok", fmt.Sprintf("администраторов %d, других пользователей %d", admins, others)
}
return c
})
wg.Wait()
sort.Slice(out, func(i, j int) bool { return out[i].Order < out[j].Order })
return out
}
// readyNetwork — сетевые доступы, без которых обмен не заработает: контур НРД (ИШ ходит туда сам по ГОСТ-TLS —
// здесь только TCP), справочник сертификатов Мосбиржи (LDAP) и сервер лицензий (ключ, продление, обновления).
func readyNetwork(ctx context.Context, s Settings) ReadyCheck {
c := ReadyCheck{Order: 14, Title: "Сетевые доступы", Link: "/admin/setup#server"}
type target struct{ name, addr string }
var ts []target
switch contourOf(s.NSD.Profile) {
case "prod":
if strings.HasSuffix(s.NSD.Profile, "rsa") {
ts = append(ts, target{"НРД (боевой, RSA)", "edor.nsd.ru:443"}, target{"справочник Мосбиржи (LDAP)", "vcert.pki.moex.com:50003"})
} else {
ts = append(ts, target{"НРД (боевой, ГОСТ)", "edog.nsd.ru:443"}, target{"справочник Мосбиржи (LDAP)", "vcert.pki.moex.com:50001"})
}
default:
if strings.HasSuffix(s.NSD.Profile, "rsa") {
ts = append(ts, target{"НРД (TEST3, RSA)", "rsa-t3.nsd.ru:443"}, target{"справочник Мосбиржи (LDAP)", "vcert.pki.moex.com:50007"})
} else {
ts = append(ts, target{"НРД (TEST3, ГОСТ)", "gost-t3.nsd.ru:443"}, target{"справочник Мосбиржи (LDAP)", "vcert.pki.moex.com:50005"})
}
}
ts = append(ts, target{"сервер лицензий", "lic.zetit.ru:443"})
var bad, good []string
var mu sync.Mutex
var wg sync.WaitGroup
for _, t := range ts {
wg.Add(1)
go func(t target) {
defer wg.Done()
d := net.Dialer{Timeout: 4 * time.Second}
conn, err := d.DialContext(ctx, "tcp", t.addr)
mu.Lock()
defer mu.Unlock()
if err != nil {
bad = append(bad, t.name+" ("+t.addr+")")
return
}
conn.Close()
good = append(good, t.name)
}(t)
}
wg.Wait()
sort.Strings(bad)
if len(bad) > 0 {
c.State, c.Detail = "err", "нет доступа: "+strings.Join(bad, ", ")+" — откройте эти адреса на межсетевом экране организации"
return c
}
c.State, c.Detail = "ok", "доступны: "+strings.Join(good, ", ")
return c
}
func readyDepo(ctx context.Context, rc *RuntimeConfig) ReadyCheck {
f := rc.Snapshot().Fancy
c := ReadyCheck{Order: 3, Title: "Учёт DEPO", Link: "/admin/wizard?step=3&sub=done", Required: true}
if strings.TrimSpace(f.Server) == "" {
c.State, c.Detail = "err", "база DEPO не подключена"
return c
}
cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
db, err := fancyOpen(f, f.DepoDB())
if err == nil {
err = db.PingContext(cctx)
}
if err != nil {
c.State, c.Detail = "err", "подключение: "+quikErrorDetail(err)
return c
}
defer db.Close()
var notes []string
c.State = "ok"
if !f.DepoTablesCheckOK {
c.State = "err"
notes = append(notes, "таблицы обмена FTV_M2M: "+pickStr(f.DepoTablesCheck, "не проверены"))
}
procsPending := false
if msg := depoTablesMissing(cctx, db, f); msg != "" {
c.State = "err"
notes = append(notes, msg)
} else if msg := depoProcsMissing(cctx, db, f); msg != "" {
// процедуры bj2depo ещё не установлены — не блокирует: формат обмена с учётной системой готовится, их
// доустанавливают отдельно; пока их нет, события bj в DEPO копятся необработанными
procsPending = true
notes = append(notes, "процедуры обработки bj2depo ещё не установлены — не забудьте их доустановить. Пока их нет, события bj в базе DEPO ждут обработки")
} else if !f.DepoProcCheckOK && f.DepoProcCheck != "" {
c.State = "err"
notes = append(notes, "обработка событий: "+f.DepoProcCheck)
}
if jmsg, jok := depoJobCheck(cctx, rc); !jok && !procsPending {
c.State = "err"
notes = append(notes, "запуск обработки: "+jmsg)
}
if procsPending && c.State == "ok" {
c.State = "warn"
}
if n, _, err := depoAccPrefixInfo(cctx, f); err == nil && n == 0 && !f.IsPostgres() {
c.State = "err"
notes = append(notes, "открытых торговых счетов «"+depoAccPrefix()+"…» в учёте нет — проверьте префикс счёта")
}
if len(notes) == 0 {
c.Detail = f.Server + ", база " + f.DepoDB() + ": таблицы, обработка и счета клиентов на месте"
} else {
c.Detail = strings.Join(notes, "; ")
}
return c
}
// lastReadiness — итог последней проверки готовности: по нему отмечается шаг 10 мастера.
var lastReadiness struct {
sync.Mutex
ready bool
at time.Time
}
func readinessDone() bool {
lastReadiness.Lock()
defer lastReadiness.Unlock()
return lastReadiness.ready && time.Since(lastReadiness.at) < 24*time.Hour
}
func registerReadiness(mux *http.ServeMux, rc *RuntimeConfig) {
mux.HandleFunc("/admin/setup/readiness", func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
checks := readinessChecks(ctx, rc)
ready := true
for _, c := range checks {
if c.Required && c.State != "ok" && c.State != "warn" {
ready = false
}
}
lastReadiness.Lock()
lastReadiness.ready, lastReadiness.at = ready, time.Now()
lastReadiness.Unlock()
jsonOut(w, map[string]any{"checks": checks, "ready": ready, "at": time.Now().Format("02.01.2006 15:04:05")})
})
}