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")}) }) }