Files
Bridge-and-Join-s/internal/lkgateway/igate.go
fontvielle 1d8bb0c032 DEPO Firebird: путь к базе — и строкой подключения Fancy целиком (база на другом компьютере)
Поле пути в мастере и в «Таблицах обмена» принимает строку подключения из настройки Fancy: «сервер:путь»,
«сервер/порт:путь», «\\сервер\D:\путь», «\\сервер\D$\путь», «inet://сервер:порт/путь» — адрес и порт
bj берёт из неё (в мастере поля заполняются сразу при вставке). Сетевая папка «\\сервер\папка\…» — ошибка с
подсказкой: Firebird открывает базу только на своём диске, адрес — компьютер с сервером Firebird.
Проверено мастером на копии базы: пустой адрес + «127.0.0.1/3050:путь» — подготовка и BJ_PING.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eREaYwUt1i4fNLZcGnjn
2026-10-27 19:15:46 +03:00

536 lines
24 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 — ИШ ждёт флешку с ключом: переустановка не поможет
res["upgrade_to"] = igatePendingUpgrade()
if v, _ := res["version"].(string); v != "" && canonVer(systemVersions(rc).Get("ish").Version) != canonVer(v) {
go refreshVersions(rc) // ИШ обновили/откатили — строка версий над разделом не должна показывать прежнюю
}
if st := igateInstallStatus(); st.Busy {
res["installing"] = st.Msg
}
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/rollback", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
ctx, cancel := context.WithTimeout(r.Context(), time.Minute)
defer cancel()
jsonOut(w, opResult(runKeymediaOp(ctx, "igate-rollback", nil)))
})
// ход установки: этап, сообщение, журнал и сколько секунд идёт текущий этап (по времени записи файла)
mux.HandleFunc("/admin/setup/igate/install-status", func(w http.ResponseWriter, r *http.Request) {
jsonOut(w, igateInstallStatusJSON(rc))
})
// канал контура в ИШ (адрес, отпечаток УЦ, папка обмена); ключ — мастером установки ключа
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) && !(s.Fancy.IsFirebird() && validFBPath(depoDB)) { // у Firebird — путь к базе
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"
// igateInstallStatusJSON — ход установки/обновления ИШ для страниц: этап, сообщение, журнал, сколько
// секунд идёт этап (elapsed), идёт ли сейчас (busy). Как только установка закончилась, версии
// пересобираются тут же — строка «Интеграционный шлюз НРД (ИШ): …» не показывает прежнюю.
func igateInstallStatusJSON(rc *RuntimeConfig) map[string]any {
out := map[string]any{}
raw, err := os.ReadFile(igateInstallStatusPath)
if err != nil || json.Unmarshal(raw, &out) != nil {
return map[string]any{}
}
fi, err := os.Stat(igateInstallStatusPath)
if 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
}
out["busy"] = st.Busy
if !st.Busy && err == nil && fi.ModTime().After(systemVersions(rc).At) {
refreshVersions(rc)
}
return out
}
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 (обновление ядра перезапускает его до того, как успел бы сработать первый вызов).
// igateAutoUpgradeSkipPath — версия дистрибутива, с которой ИШ откатили (её автоматически не ставим).
var igateAutoUpgradeSkipPath = "/var/lib/bj/igate-autoupgrade-skip"
func igateUpgradeSkipped(dist string) bool {
b, err := os.ReadFile(igateAutoUpgradeSkipPath)
return err == nil && strings.TrimSpace(string(b)) == dist
}
// igatePendingUpgrade — версия ИШ, до которой bj обновит шлюз сам (пришла с обновлением bj); "" — нечего.
func igatePendingUpgrade() string {
cur := ishInstalledVersion()
_, dist := igateDistFile()
if !igateInstalled() || cur == "" || dist == "" || !release.IsNewer(dist, cur) || igateUpgradeSkipped(dist) {
return ""
}
return dist
}
func igateAutoUpgrade(ctx context.Context, rc *RuntimeConfig) {
cur := ishInstalledVersion()
_, dist := igateDistFile()
if !igateInstalled() || cur == "" || dist == "" || !release.IsNewer(dist, cur) || igateInstallStatus().Busy {
return
}
if igateUpgradeSkipped(dist) { // ИШ откатили с этой версии вручную — сам не ставим, пока не придёт следующая
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
}
}
}