package lkgateway // «Подготовка модулей» (05.10.2026) — экран между вводом ключа продукта и мастером настройки. Образ ставит // базовый набор (ядро); после ввода ключа bj узнаёт состав лицензии, ставит модули, которые в неё входят // (файлы — из комплекта образа, если там тот же выпуск, иначе с сервера обновлений), запускает их службы // и только потом открывает мастер: мастер настраивает уже установленное. import ( "context" "net/http" "os" "os/exec" "strconv" "strings" "sync" "time" "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/release" ) // PrepareItem — строка экрана: что входит в лицензию и в каком оно состоянии. type PrepareItem struct { Title string `json:"title"` State string `json:"state"` // ok | run | wait | warn | err | info Detail string `json:"detail,omitempty"` } var prepareRun struct { sync.Mutex Running, Finished bool Result, Err string UpdState, UpdText string // этап «Обновление bj»: пусто — ещё не проверялось в этом запуске bj IgateErr string // установку ИШ не удалось даже запустить } // prepareBoot — метка запуска bj: страница видит перезапуск (после обновления ядра) и продолжает подготовку. var prepareBoot = time.Now().UnixNano() // prepareCoreFeatures — признаки лицензии, которые работают внутри ядра (отдельных файлов нет). var prepareCoreFeatures = []string{"quik-mnp", "esia-finance", "onec-back"} // prepareItems — состав лицензии этой установки и состояние каждого модуля. func prepareItems(rc *RuntimeConfig, running, finished bool, runErr string) []PrepareItem { prepareRun.Lock() us, ut := prepareRun.UpdState, prepareRun.UpdText prepareRun.Unlock() if us == "" { us, ut = "wait", "проверю, есть ли обновление" } out := []PrepareItem{{Title: "Обновление bj", State: us, Detail: ut}, {Title: "Ядро bj: обмен с НРД, переводы M2M", State: "ok", Detail: "установлено, версия " + BuildVersion}} for _, f := range prepareCoreFeatures { if moduleAllowed(rc, f) { out = append(out, PrepareItem{Title: moduleTitles[f], State: "ok", Detail: "входит в ядро — устанавливать нечего"}) } } for _, c := range release.Components { if !c.FromCore || !moduleAllowed(rc, c.Feature) { continue } it := PrepareItem{Title: prepareTitle(c)} _, statErr := os.Stat(c.Path) present := statErr == nil var u unitRun if present && c.Unit != "" { u = unitRunState(c.Unit) } switch { case u.State == "active" && u.Up >= 5*time.Second: it.State, it.Detail = "ok", "установлен, служба работает" case (u.State == "activating" || u.State == "active") && u.Restarts >= 3: it.State, it.Detail = "err", "установлен, но служба падает при запуске: "+unitFailCause(c) case u.State == "activating" || u.State == "reloading" || u.State == "active": it.State, it.Detail = "run", "установлен, служба запускается…" case present && c.Unit == "bj-crypto": it.State, it.Detail = "warn", "установлен, служба не запущена ("+u.State+") — обмену не мешает: пакеты для НРД подписывает шлюз" case present: it.State, it.Detail = "ok", "установлен; служба заработает, когда модуль будет настроен (глава «Запуск»)" case running: it.State, it.Detail = "run", "устанавливается…" case runErr != "": it.State, it.Detail = "err", "не установлен — "+runErr case finished: it.State, it.Detail = "warn", "не установлен: в текущем выпуске на сервере обновлений его нет — поставится обновлением" default: it.State, it.Detail = "wait", "будет установлен" } out = append(out, it) } out = append(out, prepareIgateItem()) if moduleAllowed(rc, "web-cabinet") { out = append(out, PrepareItem{Title: moduleTitles["web-cabinet"], State: "info", Detail: "ставится на веб-сервер организации: дистрибутив и ключ кабинета — в мастере, глава «Запуск»"}) } return out } // unitRun — служба: состояние, сколько работает после последнего запуска, сколько раз systemd её перезапускал. type unitRun struct { State string Up time.Duration Restarts int } func unitRunState(unit string) unitRun { r := unitRun{State: "unknown"} ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() out, err := exec.CommandContext(ctx, "systemctl", "show", unit, "--property=ActiveState,NRestarts,ActiveEnterTimestampMonotonic").Output() if err != nil { return r } var since int64 for _, line := range strings.Split(string(out), "\n") { k, v, _ := strings.Cut(strings.TrimSpace(line), "=") switch k { case "ActiveState": r.State = v case "NRestarts": r.Restarts, _ = strconv.Atoi(v) case "ActiveEnterTimestampMonotonic": since, _ = strconv.ParseInt(v, 10, 64) } } if b, err := os.ReadFile("/proc/uptime"); err == nil && since > 0 { if f := strings.Fields(string(b)); len(f) > 0 { if up, err := strconv.ParseFloat(f[0], 64); err == nil { r.Up = time.Duration(up*float64(time.Second)) - time.Duration(since)*time.Microsecond } } } return r } // unitFailCause — причина падения службы модуля по последней строке её журнала ошибок — понятными словами. func unitFailCause(c release.Component) string { logs := map[string]string{"bj-crypto": "/var/log/bj/crypto-service.err", "bj-npr": "/var/log/bj/bj-npr.err", "bj-quikexport": "/var/log/bj/bj-quikexport.err"} b, err := os.ReadFile(logs[c.Unit]) if err != nil { return "подробности — в журнале службы " + c.Unit } lines := strings.Split(strings.TrimSpace(string(b)), "\n") last := strings.TrimSpace(lines[len(lines)-1]) if strings.Contains(string(b[max(0, len(b)-4000):]), "UnsupportedClassVersionError") { return "на сервере слишком старая Java (нужна 17 или новее): sudo apt-get install openjdk-17-jre-headless — служба запустится сама" } if len(last) > 240 { last = last[:240] + "…" } return last } // prepareTitle — название модуля для экрана. func prepareTitle(c release.Component) string { switch c.Name { case "crypto-service.jar": return "Проверка ключа подписи (Валидата)" } if t := moduleTitles[c.Feature]; t != "" { return t } return c.Title } // prepareStart — поставить модули лицензии (один запуск за раз; повторный — пока идёт — ничего не делает). func prepareStart(rc *RuntimeConfig) { prepareRun.Lock() if prepareRun.Running { prepareRun.Unlock() return } prepareRun.Running, prepareRun.Finished, prepareRun.Result, prepareRun.Err = true, false, "", "" prepareRun.Unlock() go func() { ctx, cancel := context.WithTimeout(context.Background(), 25*time.Minute) defer cancel() prepareUpdates(ctx, rc) // сначала — обновления (при обновлении ядра bj перезапустится, страница продолжит) var msg, errText string if _, err := os.Stat(keymediaReqDir); err != nil { errText = "на сервере нет службы установки (bj-keymedia)" } else if m, err := NewUpdater(rc).ensureSplitModules(ctx); err != nil { errText = strings.TrimPrefix(err.Error(), "Установка модулей ") } else { msg = m } prepareIgate(ctx) prepareRun.Lock() prepareRun.Running, prepareRun.Finished, prepareRun.Result, prepareRun.Err = false, true, msg, errText prepareRun.Unlock() }() } // prepareIgate — интеграционный шлюз НРД при подготовке: нет на сервере — поставить из дистрибутива, который // лежит на сервере (образ или обновление bj): это версия, проверенная ZETIT вместе с bj. С сайта НРД ИШ не // качается — новые версии приходят только с обновлением bj. Ход установки пишет установщик (igateInstallStatus). func prepareIgate(ctx context.Context) { if igateInstalled() || igateBundledVersion() == "" { return } if st := igateInstallStatus(); !st.Busy { if err := igateInstallDist(ctx); err != nil { prepareRun.Lock() prepareRun.IgateErr = err.Error() prepareRun.Unlock() return } } for ctx.Err() == nil { // 2–5 минут: пакеты, пользователь igate, база, служба time.Sleep(3 * time.Second) if st := igateInstallStatus(); !st.Busy && st.Stage != "" { return } } } // prepareIgateItem — строка «Интеграционный шлюз НРД» на экране подготовки. func prepareIgateItem() PrepareItem { it := PrepareItem{Title: "Интеграционный шлюз НРД (ИШ)"} st := igateInstallStatus() prepareRun.Lock() igErr := prepareRun.IgateErr prepareRun.Unlock() switch { case st.Busy: it.State, it.Detail = "run", "устанавливается: "+st.Msg+" (вся установка — 2–5 минут)" case igateInstalled(): it.State, it.Detail = "ok", "установлен"+pickStr(prefixed(", версия ", ishInstalledVersion()), "") case st.Stage == "error": it.State, it.Detail = "err", "не установился: "+st.Msg+" — повторить можно в мастере, шаг «Шлюз НРД», кнопка «Установить ИШ заново»" case igErr != "": it.State, it.Detail = "err", "установка не запустилась: "+igErr+" — повторить можно в мастере, шаг «Шлюз НРД»" case igateBundledVersion() == "": it.State, it.Detail = "warn", "дистрибутива ИШ на сервере нет — он приходит с обновлением bj" default: it.State, it.Detail = "wait", "будет установлен, версия "+igateBundledVersion() } return it } func prefixed(p, v string) string { if v == "" { return "" } return p + v } // prepareUpdates — первый этап подготовки: есть обновление — поставить, нет — дальше. func prepareUpdates(ctx context.Context, rc *RuntimeConfig) { set := func(state, text string) { prepareRun.Lock() prepareRun.UpdState, prepareRun.UpdText = state, text prepareRun.Unlock() } set("run", "проверяю, есть ли обновление…") if _, err := os.Stat(keymediaReqDir); err != nil { set("warn", "на сервере нет службы установки (bj-keymedia) — обновления не ставятся") return } u := NewUpdater(rc) cctx, cancel := context.WithTimeout(ctx, 90*time.Second) st, err := u.CheckForUpdate(cctx) cancel() switch { case err != nil: set("warn", "проверить обновления не удалось ("+err.Error()+") — продолжаю с установленной версией") case !st.Configured: set("warn", st.Message) case !st.HasUpdate: set("ok", "обновлений нет, установлена последняя версия "+BuildVersion) case prepareTried(st.Available): // уже ставили этот выпуск при подготовке (bj перезапускался) — не по кругу set("warn", "выпуск "+st.Available+" поставлен не полностью: "+st.Message+" — доустановить можно в «Настройка → Обновления»") default: set("run", "есть обновление "+st.Available+" — ставлю; если обновится ядро, bj перезапустится, страница подождёт") if msg, err := u.ApplyUpdate(ctx, nil); err != nil { set("warn", "обновление не установилось: "+err.Error()+" — продолжаю с установленной версией") } else { set("ok", "обновление "+st.Available+" установлено: "+msg) } } } // prepareTriedFile — какой выпуск подготовка уже ставила: после перезапуска bj тот же выпуск не ставится снова. var prepareTriedFile = "/var/lib/bj/.bj/prepare-update-tried" func prepareTried(ver string) bool { b, _ := os.ReadFile(prepareTriedFile) if strings.TrimSpace(string(b)) == ver { return true } _ = os.WriteFile(prepareTriedFile, []byte(ver+"\n"), 0o640) return false } // safeNext — куда идти после подготовки: только свои страницы (как return_to). func safeNext(v string) string { if strings.HasPrefix(v, "/admin/") && !strings.HasPrefix(v, "//") && !strings.ContainsAny(v, "\r\n") { return v } return "/admin/wizard?step=12" } // PrepareData — данные страницы «Подготовка модулей». type PrepareData struct { page Tenant, Plan, Until string Next string Flash string } func registerPrepare(mux *http.ServeMux, h *setupHandlers) { mux.HandleFunc("/admin/setup/prepare", func(w http.ResponseWriter, r *http.Request) { st := licenseStatus(h.rc) if !st.Valid { http.Redirect(w, r, "/admin/wizard?step=1", http.StatusSeeOther) return } prepareRun.Lock() if !prepareRun.Running { // новый заход (ключ сменили, модуль добавили) — прошлый итог не в счёт prepareRun.Finished, prepareRun.Result, prepareRun.Err = false, "", "" } prepareRun.Unlock() d := PrepareData{page: nowPage("Подготовка модулей", "wizard"), Tenant: st.Tenant, Plan: st.Plan, Until: st.Message, Next: safeNext(r.URL.Query().Get("next")), Flash: r.URL.Query().Get("flash")} render(w, h.tpl.a.prepare, d) }) mux.HandleFunc("/admin/setup/prepare/start", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } if !licenseStatus(h.rc).Valid { jsonOut(w, map[string]any{"ok": false, "error": "лицензия не активирована"}) return } prepareStart(h.rc) jsonOut(w, map[string]any{"ok": true}) }) mux.HandleFunc("/admin/setup/prepare/status", func(w http.ResponseWriter, r *http.Request) { prepareRun.Lock() running, finished, res, errText := prepareRun.Running, prepareRun.Finished, prepareRun.Result, prepareRun.Err prepareRun.Unlock() items := prepareItems(h.rc, running, finished, errText) pending := false // модули лицензии, которых на сервере ещё нет for _, c := range release.Components { if c.FromCore && moduleAllowed(h.rc, c.Feature) { if _, err := os.Stat(c.Path); err != nil { pending = true } } } updProg.Lock() pg := updProg.s updProg.Unlock() busy := false // служба модуля ещё запускается for _, it := range items { if it.State == "run" { busy = true } } prepareRun.Lock() if prepareRun.UpdState == "" { pending = true // обновления в этом запуске bj ещё не проверялись } prepareRun.Unlock() out := map[string]any{"running": running, "finished": finished, "items": items, "pending": pending, "busy": busy, "result": res, "error": errText, "boot": prepareBoot} if running && pg.Active { out["stage"], out["done"], out["total"] = pg.Stage, pg.Done, pg.Total } jsonOut(w, out) }) }