ba1aae705d
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
438 lines
18 KiB
Go
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")})
|
|
})
|
|
}
|