b4cfa65699
- «Подготовка модулей»: ИШ нет на сервере — bj ставит его из дистрибутива на сервере (образ или обновление bj — версия, проверенная ZETIT), ход — в списке. - Шаг «Шлюз НРД»: установлен и работает — версия; не стоит или не работает — одна кнопка «Установить ИШ» / «Установить ИШ заново» (систему переставлять не нужно). «Скачать с сайта НРД» и «Установить из файла» — только в режиме разработки: версию ИШ меняем после своей проверки, вместе с обновлением bj. - Каналы TEST3 и боевого: до установки ИШ — «канал создаётся после установки ИШ», после — «Создать канал». - «Письма в НРД о доступе к контурам» убраны: НРД подключает заявкой на сайте — ссылка на «MOEX МОСТ для M2M», раздел «Как подключиться». Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
379 lines
16 KiB
Go
379 lines
16 KiB
Go
package lkgateway
|
||
|
||
// «Подготовка модулей» (05.10.2026) — экран между вводом ключа продукта и мастером настройки. Образ ставит
|
||
// базовый набор (ядро); после ввода ключа bj узнаёт состав лицензии, ставит модули, которые в неё входят
|
||
// (файлы — из комплекта образа, если там тот же выпуск, иначе с сервера обновлений), запускает их службы
|
||
// и только потом открывает мастер: мастер настраивает уже установленное.
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"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"}
|
||
|
||
// 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 {
|
||
res, err := runKeymediaOp(ctx, "igate-install", map[string]string{"extra": `{"bundled":"1"}`})
|
||
if err == nil {
|
||
if ok, _ := res["ok"].(bool); !ok {
|
||
err = fmt.Errorf("%v", res["error"])
|
||
}
|
||
}
|
||
if 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)
|
||
})
|
||
}
|