package lkgateway // Интеграционный шлюз НРД (ИШ, служба igate) на этом сервере: состояние для мастера (глава «НРД»). import ( "context" "encoding/json" "errors" "fmt" "io" "log" "net/http" "os" "os/exec" "path/filepath" "regexp" "strings" "time" "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/release" ) const igateDefaultURL = "http://localhost:8090" // igateInstalled — пакет ИШ стоит на этом сервере. func igateInstalled() bool { _, err := os.Stat("/opt/nsd/igate") return err == nil } // igateState — отвечает ли ИШ и в каком состоянии его движок. func igateState(ctx context.Context, base string) (bool, string) { base = strings.TrimRight(pickStr(strings.TrimSpace(base), igateDefaultURL), "/") cctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() req, _ := http.NewRequestWithContext(cctx, http.MethodGet, base+"/api/admin/engine/state", nil) resp, err := http.DefaultClient.Do(req) if err != nil { if !igateInstalled() { return false, "интеграционный шлюз на этом сервере не установлен" } out, _ := exec.CommandContext(ctx, "systemctl", "is-active", "igate").Output() return false, "шлюз установлен, но не отвечает по адресу " + base + " (служба igate: " + pickStr(strings.TrimSpace(string(out)), "неизвестно") + ")" } defer resp.Body.Close() body, _ := io.ReadAll(io.LimitReader(resp.Body, 256)) st := strings.Trim(strings.TrimSpace(string(body)), `"`) if resp.StatusCode != http.StatusOK { return false, "шлюз отвечает с ошибкой HTTP " + resp.Status } if st != "Running" { return false, "шлюз отвечает, но его движок в состоянии «" + st + "» — нужен Running" } return true, "шлюз отвечает по адресу " + base + ", движок работает" } // igateChannelState — загрузил ли ИШ канал с этим составным кодом (<канал контура><код депонента>) и включён ли он. // Движок может быть Running и без единого канала: ИШ молча выкидывает канал, который не смог прочитать из базы // (06.10.2026 на .29: пустые строки вместо NULL в необязательных параметрах), — и тогда любой обмен даёт HTTP 500. func igateChannelState(ctx context.Context, base, code string) (found, enabled bool, err error) { base = strings.TrimRight(pickStr(strings.TrimSpace(base), igateDefaultURL), "/") cctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() req, _ := http.NewRequestWithContext(cctx, http.MethodGet, base+"/api/admin/channels/wsl", nil) resp, err := http.DefaultClient.Do(req) if err != nil { return false, false, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return false, false, fmt.Errorf("HTTP %s", resp.Status) } var list []struct { Code string `json:"code"` IsEnabled bool `json:"isEnabled"` } if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&list); err != nil { return false, false, err } for _, c := range list { if strings.EqualFold(c.Code, code) { return true, c.IsEnabled, nil } } return false, false, nil } func registerIgate(mux *http.ServeMux, rc *RuntimeConfig) { registerIgateAdmin(mux, rc) mux.HandleFunc("/admin/setup/igate/state", func(w http.ResponseWriter, r *http.Request) { ok, msg := igateState(r.Context(), rc.Snapshot().NSD.IGWBaseURL) jsonOut(w, map[string]any{"ok": ok, "msg": msg, "installed": igateInstalled()}) }) } // ---- контуры НРД: TEST3 и боевой ---- func currentNSDProfile() string { if globalRC == nil { return "" } return globalRC.Snapshot().NSD.Profile } // contourOf — контур по профилю bj: prod | guest | test3. func contourOf(profile string) string { switch { case strings.HasPrefix(profile, "prod"): return "prod" case strings.HasPrefix(profile, "guest"): return "guest" } return "test3" } // contourChannel — начало кода канала ИШ контура (канал = <код><депозитарный код>). func contourChannel(contour string) string { switch contour { case "prod": return "PROD" case "guest": return "GUEST" } return "TEST3" } // igateExchangeDir — папка обмена канала ИШ контура (у боевого — своя, чтобы входящие контуров не смешивались). func igateExchangeDir(profile string) string { if contourOf(profile) == "prod" { return "/var/lib/igate/exchange-prod" } return "/var/lib/igate/exchange" } const igateUploadDir = "/var/lib/bj/igate-upload" var igateVersionRe = regexp.MustCompile(`^\d+\.\d+\.\d+$`) func registerIgateAdmin(mux *http.ServeMux, rc *RuntimeConfig) { // состояние ИШ и каналов + что выбрано в bj mux.HandleFunc("/admin/setup/igate/status", func(w http.ResponseWriter, r *http.Request) { // пока записывается канал, root-обработчик занят им (до двух минут) и запрос состояния ждал бы в очереди — // страница после перезагрузки висела на «Проверяю…»; отдаём последнее известное состояние if v := igateContourJob.view(); v.Running { if last := igateLastStatus.get(); last != nil { last["busy"] = "идёт запись канала " + strings.ToUpper(v.Contour) jsonOut(w, last) return } } ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() res, err := runKeymediaOp(ctx, "igate-status", nil) if err != nil { jsonOut(w, map[string]any{"ok": false, "error": err.Error()}) return } defer igateLastStatus.set(res) s := rc.Snapshot() res["active_contour"] = "" if s.NSD.Profile != "" { res["active_contour"] = contourOf(s.NSD.Profile) } res["active_profile"] = s.NSD.Profile res["deponent"] = pickStr(s.NSD.DeponentCode, orgCode(s)) res["depo_db"] = s.Fancy.DepoDB() res["depo_db_test"], res["depo_db_prod"] = s.NSD.DepoDBTest, s.NSD.DepoDBProd res["bundled"] = igateBundledVersion() res["keymedia"] = keymediaState() // missing — ИШ ждёт флешку с ключом: переустановка не поможет jsonOut(w, res) }) // установка или обновление ИШ: загруженный архив НРД или версия для скачивания с nsd.ru mux.HandleFunc("/admin/setup/igate/install", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } params := map[string]string{} if f, hdr, err := r.FormFile("file"); err == nil { defer f.Close() name := strings.ToLower(hdr.Filename) if !strings.HasSuffix(name, ".zip") && !strings.HasSuffix(name, ".deb") { jsonOut(w, map[string]any{"ok": false, "error": "нужен дистрибутив ИШ от НРД: igate-…-amd64.zip (или .deb из него)"}) return } _ = os.MkdirAll(igateUploadDir, 0o750) dst := filepath.Join(igateUploadDir, "igate"+filepath.Ext(name)) out, err := os.Create(dst) if err != nil { jsonOut(w, map[string]any{"ok": false, "error": err.Error()}) return } _, err = io.Copy(out, io.LimitReader(f, 600<<20)) out.Close() if err != nil { jsonOut(w, map[string]any{"ok": false, "error": "загрузка: " + err.Error()}) return } params["file"] = dst } else if r.FormValue("bundled") == "1" { // проверенный дистрибутив на сервере (образ или обновление bj) file, _ := igateDistFile() if file == "" { jsonOut(w, map[string]any{"ok": false, "error": "дистрибутива ИШ на сервере нет — он приходит с обновлением bj"}) return } params["file"] = file } else if v := strings.TrimSpace(r.FormValue("version")); igateVersionRe.MatchString(v) && devMode(rc) { // с сайта НРД — только разработчикам params["version"] = v } else { jsonOut(w, map[string]any{"ok": false, "error": "загрузите архив ИШ или укажите версию вида 107.0.930"}) return } extra, _ := json.Marshal(params) ctx, cancel := context.WithTimeout(r.Context(), time.Minute) defer cancel() jsonOut(w, opResult(runKeymediaOp(ctx, "igate-install", map[string]string{"extra": string(extra)}))) }) // ход установки: этап, сообщение, журнал и сколько секунд идёт текущий этап (по времени записи файла) mux.HandleFunc("/admin/setup/igate/install-status", func(w http.ResponseWriter, r *http.Request) { raw, err := os.ReadFile(igateInstallStatusPath) out := map[string]any{} if err != nil || json.Unmarshal(raw, &out) != nil { jsonOut(w, map[string]any{}) return } if fi, err := os.Stat(igateInstallStatusPath); err == nil { out["elapsed"] = int(time.Since(fi.ModTime()).Seconds()) } st := igateInstallStatus() if st.Stage == "error" && out["stage"] != "error" { // молчит 20 минут — прервалась out["stage"], out["msg"] = "error", st.Msg } jsonOut(w, out) }) // канал контура в ИШ (адрес, отпечаток УЦ, папка обмена); ключ — мастером установки ключа mux.HandleFunc("/admin/setup/igate/contour", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } s := rc.Snapshot() contour, crypto := r.FormValue("contour"), r.FormValue("crypto") if contour != "prod" { contour = "test3" } if crypto != "rsa" { crypto = "gost" } dep := pickStr(s.NSD.DeponentCode, orgCode(s)) if dep == "" { jsonOut(w, map[string]any{"ok": false, "error": "сначала укажите депозитарный код организации (мастер, раздел «Организация»)"}) return } if !igateContourJob.start(contour) { jsonOut(w, map[string]any{"ok": true, "running": true}) // уже идёт — страница покажет ход return } go igateContourRun(rc, contour, crypto, dep) jsonOut(w, map[string]any{"ok": true, "started": true}) }) registerIgateShare(mux, rc) // папки обмена шлюза по сети (igateshare.go) // ход записи канала: страница показывает его и после перезагрузки mux.HandleFunc("/admin/setup/igate/contour-status", func(w http.ResponseWriter, r *http.Request) { jsonOut(w, igateContourJob.view()) }) // переключить bj на контур: профиль отправки, база DEPO контура; bj перезапускается mux.HandleFunc("/admin/setup/igate/switch", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } contour, crypto := r.FormValue("contour"), r.FormValue("crypto") if contour != "prod" { contour = "test3" } if crypto != "rsa" { crypto = "gost" } s := rc.Snapshot() dep := pickStr(s.NSD.DeponentCode, orgCode(s)) ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() // канал контура должен быть в ИШ и включён (ключ контура установлен) st, err := runKeymediaOp(ctx, "igate-status", nil) if err != nil { jsonOut(w, map[string]any{"ok": false, "error": "состояние ИШ: " + err.Error()}) return } want := contourChannel(contour) + dep ready := false if chans, ok := st["channels"].([]any); ok { for _, c := range chans { if m, ok := c.(map[string]any); ok && m["code"] == want && m["enabled"] == true { ready = true } } } if !ready { jsonOut(w, map[string]any{"ok": false, "error": "в ИШ нет включённого канала " + want + ": настройте канал контура и установите ключ этого контура"}) return } if contour == "prod" && r.FormValue("confirm") != "БОЕВОЙ" { jsonOut(w, map[string]any{"ok": false, "error": "для перехода на боевой контур введите слово БОЕВОЙ"}) return } depoDB := strings.TrimSpace(r.FormValue("depo_db")) if depoDB != "" && !validDBName(depoDB) { jsonOut(w, map[string]any{"ok": false, "error": "имя базы DEPO — латинские буквы, цифры и _"}) return } n, f := s.NSD, s.Fancy from := contourOf(n.Profile) // запоминаем базу DEPO контура, с которого уходим, и берём базу контура, на который переходим if from == "prod" { n.DepoDBProd = f.DepoDB() } else { n.DepoDBTest = f.DepoDB() } target := depoDB if target == "" { if contour == "prod" { target = n.DepoDBProd } else { target = n.DepoDBTest } } n.Profile = contour + "-" + crypto n.IGWBaseURL = pickStr(n.IGWBaseURL, igateDefaultURL) n.SwitchedAt = time.Now() nc := s.NSDCerts nc.LastStore, nc.LastRows, nc.LastProfile = nil, nil, "" // справочник другого контура — перечитается после перезапуска _ = rc.UpdateNSDCerts(nc) if target != "" && target != f.DepoDB() { f.DepoDatabase = target f.DepoTablesCheck, f.DepoTablesCheckOK = "", false // таблицы и обработку в другой базе — проверить заново f.DepoJobCheck, f.DepoJobCheckOK = "", false f.DepoProcCheck, f.DepoProcCheckOK = "", false if err := rc.UpdateFancy(f); err != nil { jsonOut(w, map[string]any{"ok": false, "error": err.Error()}) return } } if err := rc.UpdateNSD(n); err != nil { jsonOut(w, map[string]any{"ok": false, "error": err.Error()}) return } log.Printf("nsd: контур переключён %s → %s (профиль %s, база DEPO %s)", from, contour, n.Profile, rc.Snapshot().Fancy.DepoDB()) go func() { time.Sleep(2 * time.Second) if _, err := restartUnit(context.Background(), "bj-server"); err != nil { log.Printf("nsd: перезапуск bj-server после смены контура: %v", err) } }() name := map[string]string{"prod": "боевой", "test3": "тестовый TEST3"}[contour] msg := "bj переключён на " + name + " контур (база DEPO " + rc.Snapshot().Fancy.DepoDB() + ") и перезапускается — обновите страницу через 15 секунд" if !rc.Snapshot().Fancy.DepoTablesCheckOK { msg += ". База DEPO сменилась — пройдите в мастере раздел «Учёт DEPO» (таблицы обмена и обработка в этой базе)" } jsonOut(w, map[string]any{"ok": true, "output": msg}) }) } // refreshCertStore — перечитать справочник сертификатов ИШ сейчас (после установки ключа, настройки канала, // смены контура), не дожидаясь плановой сверки раз в 6 часов: от него зависят шаг «Сертификаты» и сроки. func refreshCertStore(rc *RuntimeConfig) { go func() { time.Sleep(20 * time.Second) // ИШ перезапускается после изменений // root-воркер обрабатывает запросы по одному и сразу после установки ключа // (перезапуск ИШ, повторная активация профиля) бывает занят — одной попытки // может не хватить, она отваливается по таймауту. Повторяем, пока сверка не // пройдёт: без свежего справочника шаг «Ключ и сертификаты» остаётся // невыполненным и в мастере нет кнопки «Далее», хотя ключ уже подписывает. for attempt := 1; attempt <= 5; attempt++ { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) _, err := nsdCertsCheck(ctx, rc) cancel() if err == nil { return } log.Printf("nsdcerts: перечитать справочник (попытка %d/5): %v", attempt, err) time.Sleep(30 * time.Second) } }() } // igateBundledVersion — версия ИШ, лежащего на сервере с образа (/var/lib/bj/igate-dist; "" — нет). // igateInstallState — ход установки ИШ (файл пишет установщик bj-igate-admin). type igateInstallState struct { Stage, Msg string Busy bool // установка идёт (не done/error и файл обновлялся недавно) } // igateInstallStatusPath — файл хода установки; var — для тестов. var igateInstallStatusPath = "/var/lib/bj/igate-install-status.json" func igateInstallStatus() igateInstallState { p := igateInstallStatusPath var st igateInstallState b, err := os.ReadFile(p) if err != nil { return st } var raw struct{ Stage, Msg string } if json.Unmarshal(b, &raw) != nil { return st } st.Stage, st.Msg = raw.Stage, raw.Msg if fi, err := os.Stat(p); err == nil && st.Stage != "done" && st.Stage != "error" { // установщик обновляет файл на каждом этапе; молчит 20 минут — установка прервалась st.Busy = time.Since(fi.ModTime()) < 20*time.Minute if !st.Busy { st.Stage, st.Msg = "error", "установка прервалась ("+st.Msg+")" } } return st } func igateBundledVersion() string { _, v := igateDistFile() return v } // igateDistFile — самый новый дистрибутив ИШ на сервере (файл и версия): с образа (igate-<версия>-amd64.zip) // или из обновления bj (компонент igate-dist, версия — в списке установленных компонентов). Это версии, // проверенные ZETIT; другой ИШ клиенту не ставится. func igateDistFile() (string, string) { best, file := "", "" m, _ := filepath.Glob("/var/lib/bj/igate-dist/igate-*-amd64.zip") for _, f := range m { v := strings.ReplaceAll(strings.TrimSuffix(strings.TrimPrefix(filepath.Base(f), "igate-"), "-amd64.zip"), "-", ".") if igateVersionRe.MatchString(v) && (best == "" || release.IsNewer(v, best)) { best, file = v, f } } if c, ok := release.ComponentByName("igate-dist"); ok { if inst, ok := release.LoadInstalled(release.InstalledFile)[c.Name]; ok && igateVersionRe.MatchString(inst.Version) { if _, err := os.Stat(c.Path); err == nil && (best == "" || release.IsNewer(inst.Version, best)) { best, file = inst.Version, c.Path } } } return file, best } // igateInstallDist — поставить (или обновить) ИШ из самого нового дистрибутива на сервере. func igateInstallDist(ctx context.Context) error { file, _ := igateDistFile() if file == "" { return errors.New("дистрибутива ИШ на сервере нет — он приходит с обновлением bj") } extra, _ := json.Marshal(map[string]string{"file": file}) res, err := runKeymediaOp(ctx, "igate-install", map[string]string{"extra": string(extra)}) if err == nil { if ok, _ := res["ok"].(bool); !ok { err = fmt.Errorf("%v", res["error"]) } } return err } // igateAutoUpgrade — ИШ на сервере старее дистрибутива, пришедшего с обновлением bj: обновить (настройки, // каналы и база ИШ сохраняются; прежняя версия копируется в /var/backups). Вызывается после обновления bj // и при запуске bj-server (обновление ядра перезапускает его до того, как успел бы сработать первый вызов). func igateAutoUpgrade(ctx context.Context, rc *RuntimeConfig) { cur := ishInstalledVersion() _, dist := igateDistFile() if !igateInstalled() || cur == "" || dist == "" || !release.IsNewer(dist, cur) || igateInstallStatus().Busy { return } log.Printf("lk-gateway: ИШ %s старее проверенного дистрибутива %s — обновляю", cur, dist) if err := igateInstallDist(ctx); err != nil { raiseSystemAlert(rc, "ИШ не обновлён до "+dist, "Пришёл дистрибутив ИШ "+dist+", но обновление не запустилось: "+err.Error()+". Повторить — мастер, шаг «Шлюз НРД».") return } for ctx.Err() == nil { time.Sleep(5 * time.Second) if st := igateInstallStatus(); !st.Busy { if st.Stage == "done" { raiseSystemAlert(rc, "ИШ обновлён до "+dist, "Интеграционный шлюз НРД обновлён с "+cur+" до "+dist+" (версия, проверенная ZETIT). Настройки и каналы сохранены.") } else { raiseSystemAlert(rc, "ИШ не обновлён до "+dist, "Обновление ИШ с "+cur+" до "+dist+" не прошло: "+st.Msg+". Прежняя версия сохранена в /var/backups; повторить — мастер, шаг «Шлюз НРД».") } return } } }