package lkgateway // Готовность к работе — итоговый шаг мастера (05.10.2026): все проверки на одной странице, каждая со // ссылкой на шаг или раздел, где это исправляется. Обязательные (без них обмен с НРД не работает) — // красные; желательные (оповещения, копии, время, HTTPS) — жёлтые. Проверки идут параллельно, медленные // (процедуры DEPO с BJ_PING) не запускаются — берётся последний результат. import ( "context" "fmt" "net/http" "os/exec" "sort" "strings" "sync" "time" ) // 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) out, _ := exec.CommandContext(cctx, "systemctl", "is-active", "bj-crypto.service").Output() svc := strings.TrimSpace(string(out)) c := ReadyCheck{Order: 6, Title: "СКЗИ «Валидата» и служба криптографии", Link: "/admin/wizard?step=6", Required: true} switch { case ver == "": c.State, c.Detail = "err", "Валидата на сервере не найдена" case svc != "active": c.State, c.Detail = "err", "Валидата "+ver+", но служба bj-crypto не запущена ("+pickStr(svc, "нет")+")" default: c.State, c.Detail = "ok", "Валидата "+ver+", служба bj-crypto работает" } return c }) run(func() ReadyCheck { c := ReadyCheck{Order: 7, Title: "Сертификат ключа организации", Link: "/admin/wizard?step=7", 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: 8, Title: "Шлюз НРД", Link: "/admin/wizard?step=8", 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 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: 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 }) wg.Wait() sort.Slice(out, func(i, j int) bool { return out[i].Order < out[j].Order }) return out } 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 } 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 } } jsonOut(w, map[string]any{"checks": checks, "ready": ready, "at": time.Now().Format("02.01.2006 15:04:05")}) }) }