Files
Bridge-and-Join-s/internal/lkgateway/readiness.go
T

438 lines
18 KiB
Go

package lkgateway
// Готовность к работе — итоговый шаг мастера (05.10.2026): все проверки на одной странице, каждая со
// ссылкой на шаг или раздел, где это исправляется. Обязательные (без них обмен с НРД не работает) —
// красные; желательные (оповещения, копии, время, HTTPS) — жёлтые. Проверки идут параллельно, медленные
// (процедуры DEPO с BJ_PING) не запускаются — берётся последний результат.
import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"os"
"os/exec"
"sort"
"strings"
"sync"
"time"
)
// 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 {
out, _ := exec.CommandContext(cctx, "systemctl", "is-active", "bj-crypto.service").Output()
if svc := strings.TrimSpace(string(out)); svc != "active" {
c.State, c.Link = "warn", "/admin/setup#sec-crypto"
c.Detail += "; служба пробной подписи bj-crypto не запущена (" + pickStr(svc, "нет") + ") — обмену не мешает: пакеты для НРД подписывает шлюз"
} else {
c.Detail += ", служба пробной подписи bj-crypto работает"
}
}
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
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 !s.HTTPS.Redirect:
c.State, c.Detail = "warn", "вход в bj по 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/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, "не проверены"))
}
if msg := depoObjectsMissing(cctx, db, f); msg != "" {
c.State = "err"
notes = append(notes, msg)
} else if !f.DepoProcCheckOK && f.DepoProcCheck != "" {
c.State = "err"
notes = append(notes, "обработка событий: "+f.DepoProcCheck)
}
if jmsg, jok := depoJobCheck(cctx, rc); !jok {
c.State = "err"
notes = append(notes, "запуск обработки: "+jmsg)
}
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")})
})
}