Files
Bridge-and-Join-s/internal/lkgateway/readiness.go
T
fontvielle 5c4a12fb0b Шаг «Проверка» не сбрасывается после перезапуска bj
Итог проверки готовности (шаг 10 мастера) жил только в памяти и 24 ч: после
каждого перезапуска (обновления) главная снова показывала «Требуется
настройка — Проверка», пока не откроешь мастер. Теперь итог в
readiness.json и держится до следующей проверки; если файла ещё нет —
проверка в фоне через минуту после запуска.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eREaYwUt1i4fNLZcGnjn
2026-10-22 12:24:23 +03:00

527 lines
23 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 мастера. Хранится в
// файле: после перезапуска bj (обновления) шаг не должен снова становиться «не выполнен», а главная —
// показывать «Требуется настройка». Пройденная проверка держится, пока следующая не покажет ошибку.
var lastReadiness struct {
sync.Mutex
ready bool
at time.Time
loaded bool
}
const readinessFile = "/var/lib/bj/.bj/readiness.json"
func readinessDone() bool {
lastReadiness.Lock()
defer lastReadiness.Unlock()
if !lastReadiness.loaded {
lastReadiness.loaded = true
var f struct {
Ready bool `json:"ready"`
At time.Time `json:"at"`
}
if b, err := os.ReadFile(readinessFile); err == nil && json.Unmarshal(b, &f) == nil && lastReadiness.at.IsZero() {
lastReadiness.ready, lastReadiness.at = f.Ready, f.At
}
}
return lastReadiness.ready
}
// readinessSave — запомнить итог проверки готовности (память и файл).
func readinessSave(ready bool, at time.Time) {
lastReadiness.Lock()
lastReadiness.ready, lastReadiness.at, lastReadiness.loaded = ready, at, true
lastReadiness.Unlock()
if b, err := json.Marshal(map[string]any{"ready": ready, "at": at}); err == nil {
tmp := readinessFile + ".tmp"
if os.WriteFile(tmp, b, 0o640) == nil {
_ = os.Rename(tmp, readinessFile)
}
}
}
func registerReadiness(mux *http.ServeMux, rc *RuntimeConfig) {
go func() { // итога ещё нет (первый запуск с файлом) — проверить в фоне, не дожидаясь мастера
time.Sleep(time.Minute)
if _, err := os.Stat(readinessFile); err == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
ready := true
for _, c := range readinessChecks(ctx, rc) {
if c.Required && c.State != "ok" && c.State != "warn" {
ready = false
}
}
readinessSave(ready, time.Now())
}()
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
}
}
readinessSave(ready, time.Now())
jsonOut(w, map[string]any{"checks": checks, "ready": ready, "at": time.Now().Format("02.01.2006 15:04:05")})
})
}