Первичная настройка, часть 3: установка ИШ из bj (скачать с nsd.ru или из файла; без графики: keyring, xvfb, dbus; база, служба) — проверено чистой установкой на Ubuntu 22.04; два контура НРД: каналы TEST3 и боевой в ИШ (адреса и отпечатки УЦ из документа НРД об ИШ 107), свои ключи (мастер ключа — выбор контура, профиль только к каналу контура), переключение bj между контурами с базой DEPO контура и подтверждением для боевого; папка входящих и слежение за связью — по активному контуру; пакеты ИШ в установщике и образе

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
This commit is contained in:
zuevav
2026-10-05 16:35:53 +03:00
parent 17df53f25b
commit 4332e3309b
20 changed files with 709 additions and 46 deletions
+1
View File
@@ -60,6 +60,7 @@ var templateFuncs = template.FuncMap{
"backupWhen": backupWhen,
"depoAccPrefix": depoAccPrefix,
"certDaysLeft": certDaysLeft,
"currentContour": func() string { return contourOf(currentNSDProfile()) },
"intList": func(v ...int) []int { return v },
"nsdSegTip": nsdSegTip,
"devOn": authTemplateFuncs["devOn"],
+229
View File
@@ -4,10 +4,14 @@ package lkgateway
import (
"context"
"encoding/json"
"io"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
)
@@ -47,8 +51,233 @@ func igateState(ctx context.Context, base string) (bool, string) {
}
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) {
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
}
s := rc.Snapshot()
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
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 v := strings.TrimSpace(r.FormValue("version")); igateVersionRe.MatchString(v) {
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("/var/lib/bj/igate-install-status.json")
if err != nil {
jsonOut(w, map[string]any{})
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(raw)
})
// канал контура в ИШ (адрес, отпечаток УЦ, папка обмена); ключ — мастером установки ключа
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": "сначала укажите депозитарный код организации (шаг 4)"})
return
}
extra, _ := json.Marshal(map[string]string{"contour": contour, "crypto": crypto, "deponent": dep})
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
defer cancel()
jsonOut(w, opResult(runKeymediaOp(ctx, "igate-contour", map[string]string{"extra": string(extra)})))
})
// переключить 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()
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]
jsonOut(w, map[string]any{"ok": true, "output": "bj переключён на " + name + " контур (база DEPO " + rc.Snapshot().Fancy.DepoDB() + ") и перезапускается — обновите страницу через 15 секунд"})
})
}
+3 -2
View File
@@ -21,7 +21,7 @@ func findMF010Files(guid string) []string {
guidLower := strings.ToLower(strings.TrimSpace(guid))
var xmls, dirs []string
seen := map[string]bool{}
_ = filepath.Walk(igwInboxDir, func(p string, info os.FileInfo, err error) error {
_ = filepath.Walk(igwInboxDir(), func(p string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
@@ -50,7 +50,8 @@ func findMF010Files(guid string) []string {
return out
}
const igwInboxDir = "/var/lib/igate/exchange/INBOX"
// igwInboxDir() — папка входящих ИШ активного контура: у боевого канала своя папка обмена (exchange-prod).
func igwInboxDir() string { return filepath.Join(igateExchangeDir(currentNSDProfile()), "INBOX") }
// downloadIncomingDraft отдаёт ZIP с черновиком(ами) поручения 16/1 (MF010) по
// переводу — для загрузки в депозитарную учётную систему.
+6 -6
View File
@@ -270,7 +270,7 @@ func collectOrderResponseRaw(contrDID string) []respFile {
if contrDID == "" {
return nil
}
entries, err := os.ReadDir(igwInboxDir)
entries, err := os.ReadDir(igwInboxDir())
if err != nil {
return nil
}
@@ -283,16 +283,16 @@ func collectOrderResponseRaw(contrDID string) []respFile {
if strings.HasPrefix(dir, "#") || dir == "INBOX" { // наши M2M-пакеты — не ответ
continue
}
body, _ := readPkgText(filepath.Join(igwInboxDir, dir))
body, _ := readPkgText(filepath.Join(igwInboxDir(), dir))
if body == "" || !strings.Contains(body, contrDID) {
continue
}
files, _ := os.ReadDir(filepath.Join(igwInboxDir, dir))
files, _ := os.ReadDir(filepath.Join(igwInboxDir(), dir))
for _, f := range files {
if f.IsDir() {
continue
}
p := filepath.Join(igwInboxDir, dir, f.Name())
p := filepath.Join(igwInboxDir(), dir, f.Name())
low := strings.ToLower(f.Name())
switch {
case strings.HasSuffix(low, ".xml"), strings.HasSuffix(low, ".rpt"),
@@ -340,7 +340,7 @@ func pkgIndex() []pkgIndexEntry {
if pkgIndexCache.list != nil && time.Since(pkgIndexCache.at) < 3*time.Second {
return pkgIndexCache.list
}
entries, err := os.ReadDir(igwInboxDir)
entries, err := os.ReadDir(igwInboxDir())
if err != nil {
return nil
}
@@ -353,7 +353,7 @@ func pkgIndex() []pkgIndexEntry {
if strings.HasPrefix(dir, "#") || dir == "INBOX" { // наши M2M-пакеты — не ответ
continue
}
body, mt := readPkgText(filepath.Join(igwInboxDir, dir))
body, mt := readPkgText(filepath.Join(igwInboxDir(), dir))
if body == "" {
continue
}
+7 -1
View File
@@ -325,7 +325,13 @@ func (h *setupHandlers) keyWizardInstall(w http.ResponseWriter, r *http.Request)
defer cancel()
// Шаг 2-3: запись ключа на флешку + справочник + CRL (привилегированный воркер).
res, err := runKeymediaHelper(ctx, "install", staging, profileName, targetDev, targetMnt)
// контур ключа: профиль Валидаты привязывается только к каналу ИШ этого контура (у TEST3 и боевого — свои ключи)
contour := r.FormValue("contour")
if contour != "prod" {
contour = "test3"
}
res, err := runKeymediaOp(ctx, "install", map[string]string{"staging": staging, "profile": profileName,
"target_dev": targetDev, "target_mnt": targetMnt, "channel": contourChannel(contour)})
h.keyWiz.mu.Lock()
if err != nil || res["ok"] != true {
msg := errStr(err)
+2 -2
View File
@@ -256,7 +256,7 @@ func nsdPrettyDate(s string) string {
}
func workerCertsList(ctx context.Context) (string, []NSDStoreCert, error) {
res, err := runKeymediaOp(ctx, "certs-list", nil)
res, err := runKeymediaOp(ctx, "certs-list", map[string]string{"channel": contourChannel(contourOf(currentNSDProfile()))})
if err != nil {
return "", nil, err
}
@@ -434,7 +434,7 @@ func nsdCertsApply(ctx context.Context, rc *RuntimeConfig, trigger string) (stri
return "", false, fmt.Errorf("staging: %w", err)
}
}
res, err := runKeymediaOp(ctx, "certs-apply", map[string]string{"staging": dir, "extra": strings.Join(removes, ",")})
res, err := runKeymediaOp(ctx, "certs-apply", map[string]string{"staging": dir, "extra": strings.Join(removes, ","), "channel": contourChannel(contourOf(currentNSDProfile()))})
if err != nil {
msg := "НЕ ПРИМЕНЕНО (" + trigger + "): " + err.Error() + "\nплан: " + strings.Join(what, "; ")
nsdSaveState(rc, profile, rows, store, msg, false)
+2 -2
View File
@@ -213,7 +213,7 @@ func pkgFiles(dir string) []respFile {
// nsdOrderDocs — документы НРД по поручениям (contr_d_id), по времени.
func nsdOrderDocs(contrDIDs ...string) []nsdDoc {
entries, err := os.ReadDir(igwInboxDir)
entries, err := os.ReadDir(igwInboxDir())
if err != nil {
return nil
}
@@ -229,7 +229,7 @@ func nsdOrderDocs(contrDIDs ...string) []nsdDoc {
if !e.IsDir() || strings.HasPrefix(dir, "#") || dir == "INBOX" || seen[dir] {
continue
}
full := filepath.Join(igwInboxDir, dir)
full := filepath.Join(igwInboxDir(), dir)
body, mt := readPkgText(full)
if body == "" || !strings.Contains(body, cd) {
continue
+8 -2
View File
@@ -38,9 +38,15 @@ type nsdPoll struct {
// nsdLastPolls — итоги опросов НРД из хвоста журнала ИШ: последний опрос и первый неудачный после
// последнего успешного (начало текущего сбоя; nil — сбоя нет).
func nsdLastPolls(tail []byte) (last, firstFail *nsdPoll) {
return nsdLastPollsFor(tail, "")
}
// nsdLastPollsFor — то же для канала одного контура: при двух каналах в ИШ (TEST3 и боевой) опросы чужого
// контура не учитываются. prefix — начало имени канала в журнале («TEST3», «PROD»); пусто — все.
func nsdLastPollsFor(tail []byte, prefix string) (last, firstFail *nsdPoll) {
for _, ln := range strings.Split(string(tail), "\n") {
m := nsdPollRe.FindStringSubmatch(strings.TrimRight(ln, "\r"))
if m == nil {
if m == nil || (prefix != "" && !strings.HasPrefix(strings.ToUpper(strings.TrimSpace(m[2])), prefix)) {
continue
}
at, err := time.ParseInLocation("2006-01-02 15:04:05", m[1], time.Local)
@@ -105,7 +111,7 @@ func nsdLinkTick(rc *RuntimeConfig, now time.Time) {
if err != nil {
return
}
last, firstFail := nsdLastPolls(tail)
last, firstFail := nsdLastPollsFor(tail, contourChannel(contourOf(rc.Snapshot().NSD.Profile)))
if last == nil {
return
}
+1 -1
View File
@@ -199,7 +199,7 @@ func scanForm16Drafts(store *OutgoingStore) []*OutgoingDeal {
// по сделкам в работе, а не по всем, что когда-либо были.
func scanForm16DraftsOpt(store *OutgoingStore, withResponses bool) []*OutgoingDeal {
byGUID := map[string]*OutgoingDeal{}
_ = filepath.Walk(igwInboxDir, func(p string, info os.FileInfo, err error) error {
_ = filepath.Walk(igwInboxDir(), func(p string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
+6
View File
@@ -255,6 +255,12 @@ type NSDSettings struct {
// версиях младше — НЕ допускается. По умолчанию false (совместимо со старым
// ИШ — элемент не добавляется).
IGWv904 bool `json:"igw_v904"`
// Контуры (05.10.2026): база DEPO для TEST3 и для боевого — на тесте часто копия учёта. Запоминаются при
// переключении контура, чтобы вернуться без перенастройки.
DepoDBTest string `json:"depo_db_test,omitempty"`
DepoDBProd string `json:"depo_db_prod,omitempty"`
SwitchedAt time.Time `json:"switched_at,omitempty"`
}
// LKSettings — настройки callback в ЛК клиента.
+1
View File
@@ -715,6 +715,7 @@ func (h *setupHandlers) saveNSD(w http.ResponseWriter, r *http.Request) {
IGWv904: r.FormValue("igw_v904") == "on",
}
cur := h.rc.Snapshot().NSD
s.DepoDBTest, s.DepoDBProd, s.SwitchedAt = cur.DepoDBTest, cur.DepoDBProd, cur.SwitchedAt
if r.FormValue("keep_requisites") == "1" { // мастер: реквизиты НРД — на шаге «Организация»
s.DeponentCode, s.AccountID, s.SectionID = cur.DeponentCode, cur.AccountID, cur.SectionID
}
@@ -74,6 +74,13 @@
{{end}}
</div>
<div>
<label style="font-weight:600;display:block;margin-bottom:6px">Для какого контура НРД этот ключ</label>
<label style="display:flex;gap:8px;align-items:center;font-weight:400"><input type="radio" name="contour" value="test3" style="width:auto" {{if ne currentContour "prod"}}checked{{end}}> Тестовый TEST3 <span class="muted" style="font-size:12px">— сертификат от «Тестовый УЦ»</span></label>
<label style="display:flex;gap:8px;align-items:center;font-weight:400"><input type="radio" name="contour" value="prod" style="width:auto" {{if eq currentContour "prod"}}checked{{end}}> Боевой <span class="muted" style="font-size:12px">— выдан УЦ Московской биржи для работы</span></label>
<span class="muted" style="font-size:12px">У тестового и боевого контуров разные ключи: ключ подключается только к каналу ИШ своего контура.</span>
</div>
<div>
<label style="font-weight:600;display:block;margin-bottom:6px">Имя профиля в справочнике (необязательно)</label>
<input type="text" name="profile_name" placeholder="Авто из архива (напр. PrUser1046)" autocomplete="off"
@@ -460,40 +460,23 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }
<strong>Что это.</strong> Интеграционный шлюз — программа НРД на этом же сервере: через неё bj отправляет пакеты M2M в НРД и получает ответы. bj работает со шлюзом по его адресу на сервере (обычно <code>http://localhost:8090</code>).<br>
<strong>К кому обратиться.</strong> Дистрибутив ИШ выпускает НРД (<a href="https://www.nsd.ru/workflow/system/programs/" target="_blank" rel="noopener">nsd.ru/workflow/system/programs</a>). Доступ к контурам — сначала тестовому TEST3, затем боевому — НРД открывает по заявке: обратитесь к своему менеджеру НРД или в поддержку электронного документооборота.
</div>
<p id="igw-state" class="muted" style="margin:8px 0">Проверяю шлюз…</p>
{{template "igateInstall" .}}
<form method="post" action="/admin/setup/nsd" style="margin-top:12px;display:grid;gap:10px;max-width:680px">
<details style="margin-top:8px"><summary style="cursor:pointer;color:var(--accent);font-size:13px">Дополнительно: адрес шлюза (обычно не нужно)</summary>
<form method="post" action="/admin/setup/nsd" style="margin-top:10px;display:grid;gap:10px;max-width:680px">
<input type="hidden" name="return_to" value="/admin/wizard?step=8">
{{if .Settings.NSD.IGWv904}}<input type="hidden" name="igw_v904" value="on">{{end}}
<input type="hidden" name="keep_requisites" value="1">
<label style="margin:0">Контур НРД
<select name="profile" style="padding:8px;width:100%;box-sizing:border-box;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;margin-top:4px">
<option value="test3-gost" {{if eq .Settings.NSD.Profile "test3-gost"}}selected{{end}}>Тестовый TEST3 · ГОСТ</option>
<option value="test3-rsa" {{if eq .Settings.NSD.Profile "test3-rsa"}}selected{{end}}>Тестовый TEST3 · RSA</option>
{{if or devOn (eq .Settings.NSD.Profile "guest-gost") (eq .Settings.NSD.Profile "guest-rsa")}} <option value="guest-gost" {{if eq .Settings.NSD.Profile "guest-gost"}}selected{{end}}>GUEST · ГОСТ (для разработки)</option>
<option value="guest-rsa" {{if eq .Settings.NSD.Profile "guest-rsa"}}selected{{end}}>GUEST · RSA (для разработки)</option>
{{end}} <option value="prod-gost" {{if or (eq .Settings.NSD.Profile "prod-gost") (eq .Settings.NSD.Profile "prod")}}selected{{end}}>Боевой · ГОСТ</option>
<option value="prod-rsa" {{if eq .Settings.NSD.Profile "prod-rsa"}}selected{{end}}>Боевой · RSA</option>
</select></label>
<input type="hidden" name="profile" value="{{if .Settings.NSD.Profile}}{{.Settings.NSD.Profile}}{{else}}test3-gost{{end}}">
<label style="margin:0">Адрес шлюза на этом сервере
<input type="text" name="igw_base_url" value="{{if .Settings.NSD.IGWBaseURL}}{{.Settings.NSD.IGWBaseURL}}{{else}}http://localhost:8090{{end}}" placeholder="http://localhost:8090" style="padding:8px;width:100%;box-sizing:border-box;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;margin-top:4px"></label>
<p id="igw-onyx" style="display:none;color:var(--err);font-size:13px;margin:0">Это адрес веб-сервиса НРД (ONYX), а не шлюза: bj работает через ИШ на этом сервере. Укажите адрес шлюза — обычно <a href="#" id="igw-def">http://localhost:8090</a>.</p>
<p class="muted" style="font-size:12.5px;margin:0">По «Сохранить» bj проверит, что шлюз отвечает по этому адресу.</p>
<script>(function(){var u=document.querySelector('[name=igw_base_url]'),w=document.getElementById('igw-onyx');
function chk(){w.style.display=/onyx|nsd\.ru/i.test(u.value)?'block':'none'} u.addEventListener('input',chk); chk();
document.getElementById('igw-def').onclick=function(e){e.preventDefault();u.value='http://localhost:8090';chk()};})();</script>
<div><button type="submit" class="btn">Сохранить и проверить</button></div>
</form>
</details>
<p class="muted" style="font-size:13px;margin-top:12px">Депозитарные реквизиты в НРД: {{if and .Settings.NSD.DeponentCode .Settings.NSD.AccountID .Settings.NSD.SectionID}}код <code>{{.Settings.NSD.DeponentCode}}</code>, счёт <code>{{.Settings.NSD.AccountID}}</code>, раздел <code>{{.Settings.NSD.SectionID}}</code>{{else}}<span style="color:var(--err)">не заданы</span>{{end}} — на <a href="/admin/wizard?step=4#nsd-req">шаге 4</a>.</p>
<script>
(function(){
var el=document.getElementById('igw-state');
fetch('/admin/setup/igate/state',{cache:'no-store'}).then(function(r){return r.json()}).then(function(d){
el.className=''; el.innerHTML=d.ok?'<span style="color:var(--ok)">✓ '+d.msg+'</span>':'<span style="color:var(--err)">✗ '+d.msg+'</span>';
document.dispatchEvent(new CustomEvent('igate-state',{detail:d}));
}).catch(function(){ el.textContent='Состояние шлюза не получено.'; });
})();
</script>
<div style="margin-top:20px;display:flex;justify-content:space-between">
<a href="/admin/wizard?step=7" class="btn" style="background:var(--card);color:var(--text);border:1px solid var(--border-strong,#c9cdd8);text-decoration:none">← К шагу 7</a>
{{if .Done.NSD}}<a href="/admin/wizard?step=9" class="btn" style="text-decoration:none">К шагу 9 →</a>{{else}}<a href="/admin/wizard?step=9&skip=nsd" class="btn" style="background:var(--card);color:var(--text);border:1px solid var(--border-strong,#c9cdd8);text-decoration:none">Пропустить ({{if devOn}}mock-режим{{else}}обмен с НРД не настроен{{end}}) →</a>{{end}}
@@ -1 +1,115 @@
{{define "igateInstall"}}{{end}}
{{/* Шлюз НРД: установка ИШ, каналы контуров TEST3 и боевого, переключение bj между контурами (мастер, шаг 8). */}}
{{define "igateInstall"}}
<style>
#ig-box h3{margin:0 0 6px;font-size:15px}
#ig-box label.f{display:block;margin:0;font-size:13px}
#ig-box label.f input,#ig-box label.f select{display:block;margin-top:4px;padding:7px 9px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px}
#ig-ct{width:100%;margin-top:8px}
#ig-ct td{vertical-align:top;padding:6px 8px 6px 0}
#ig-box .btn.sm{padding:4px 10px;font-size:12.5px}
#ig-box .btn.ghost{background:var(--card);color:var(--text);border:1px solid var(--border-strong,#c9cdd8)}
</style>
<div id="ig-box">
<div class="card" style="margin:10px 0">
<h3>Шлюз на этом сервере</h3>
<div id="ig-head" class="muted">Проверяю…</div>
<details id="ig-inst" style="margin-top:8px">
<summary style="cursor:pointer;color:var(--accent)" id="ig-inst-sum">Установить или обновить ИШ</summary>
<p class="muted" style="font-size:13px;margin:8px 0">Дистрибутив ИШ для Linux выпускает НРД. bj скачает его с сайта НРД сам или возьмёт загруженный архив <code>igate-…-amd64.zip</code>. Установка занимает 2–5 минут: пакеты для работы без графики, пользователь igate, база ИШ, служба. Настройки ИШ сохраняются, прежняя версия копируется в /var/backups.</p>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:end">
<label class="f">Версия на сайте НРД<input id="ig-ver" value="107.0.930" style="width:140px"></label>
<button type="button" class="btn" id="ig-dl">Скачать с сайта НРД и установить</button>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-top:10px">
<input type="file" id="ig-file" accept=".zip,.deb">
<button type="button" class="btn ghost" id="ig-up">Установить из файла</button>
</div>
<div id="ig-out" style="margin-top:8px;font-size:13px"></div>
<pre id="ig-log" style="display:none;max-height:180px;overflow:auto;font-size:11px;margin-top:6px"></pre>
</details>
</div>
<div class="card" style="margin:10px 0">
<h3>Контуры НРД</h3>
<p class="muted" style="font-size:13px;margin:0 0 8px">Сначала работают на тестовом контуре TEST3; когда переводы на нём проходят — переходят на боевой. У каждого контура свой ключ (в мастере установки ключа выберите контур) и свой канал в ИШ. bj работает с одним контуром — активным.</p>
<label class="f" style="max-width:220px">Криптография<select id="ig-crypto"><option value="gost">ГОСТ</option><option value="rsa">RSA</option></select></label>
<table id="ig-ct"><tbody></tbody></table>
<div id="ig-prod" style="display:none;border:1px solid var(--warn);border-radius:8px;padding:10px 12px;margin-top:10px">
<b>Переход на боевой контур</b>
<p class="muted" style="font-size:13px;margin:6px 0">После перехода bj отправляет в НРД настоящие переводы. На боевом контуре обычно работает настоящий учёт DEPO — укажите его базу (на TEST3 часто использовали копию). Вернуться на TEST3 можно здесь же.</p>
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:end">
<label class="f">База DEPO для боевого контура<input id="ig-depo" placeholder="как сейчас" style="width:220px"></label>
<label class="f">Для подтверждения введите БОЕВОЙ<input id="ig-confirm" autocomplete="off" style="width:160px"></label>
<button type="button" class="btn" id="ig-goprod" style="background:var(--err);border-color:var(--err)">Перейти на боевой</button>
</div>
</div>
<div id="ig-cout" style="margin-top:8px;font-size:13px"></div>
</div>
</div>
<script>
(function(){
var head=document.getElementById('ig-head'), tb=document.querySelector('#ig-ct tbody'), cout=document.getElementById('ig-cout'), out=document.getElementById('ig-out'), lg=document.getElementById('ig-log');
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,function(c){return{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]})}
function ok(t){return '<span style="color:var(--ok)">✓ '+esc(t)+'</span>'} function bad(t){return '<span style="color:var(--err)">✗ '+esc(t)+'</span>'}
function post(u,p){return fetch(u,{method:'POST',body:p,cache:'no-store'}).then(function(r){return r.json()})}
var st=null;
function load(){
fetch('/admin/setup/igate/status',{cache:'no-store'}).then(function(r){return r.json()}).then(function(d){
st=d;
if(!d.ok){ head.innerHTML=bad(d.error); return; }
if(!d.installed){ head.innerHTML=bad('ИШ на этом сервере не установлен')+' — установите его ниже.'; document.getElementById('ig-inst').open=true; }
else head.innerHTML=(d.engine==='Running'?ok('ИШ '+d.version+' работает'):bad('ИШ '+d.version+' установлен, движок: '+(d.engine||'не отвечает')+', служба: '+d.service));
if(d.installed) document.getElementById('ig-inst-sum').textContent='Обновить ИШ (сейчас '+d.version+')';
var dep=d.deponent||'', cr=document.getElementById('ig-crypto').value;
var rows=[['test3','Тестовый TEST3'],['prod','Боевой']].map(function(x){
var code=(x[0]==='prod'?'PROD':'TEST3')+dep, ch=(d.channels||[]).filter(function(c){return c.code===code})[0], act=d.active_contour===x[0];
var s=[], a=[];
if(!dep) s.push(bad('нет депозитарного кода (шаг 4)'));
else if(!ch){ s.push('<span class="muted">канала в ИШ нет</span>'); if(d.installed) a.push('<button type="button" class="btn sm" data-act="contour" data-c="'+x[0]+'">Настроить канал</button>'); }
else {
s.push(ok('канал '+ch.code)+' <span class="muted" style="font-size:12px">'+esc(ch.crypto+' · '+ch.endpoint)+'</span>');
s.push(ch.profile?ok('ключ: профиль '+ch.profile):bad('ключ этого контура не установлен'));
s.push(ch.enabled?ok('канал включён'):'<span class="muted">канал выключен до установки ключа</span>');
a.push('<button type="button" class="btn sm ghost" data-act="contour" data-c="'+x[0]+'">Обновить канал</button>');
if(!ch.profile) a.push('<a class="btn sm" href="/admin/setup/keywizard" style="text-decoration:none">Установить ключ</a>');
if(ch.enabled && !act) a.push(x[0]==='prod'?'<button type="button" class="btn sm" data-act="showprod">Перейти на боевой…</button>':'<button type="button" class="btn sm" data-act="switch" data-c="test3">Сделать активным</button>');
}
return '<tr><td style="white-space:nowrap;width:130px"><b>'+x[1]+'</b>'+(act?'<br><span style="color:var(--accent);font-size:12px">● активный</span>':'')+'</td><td>'+s.join('<br>')+(a.length?'<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:6px">'+a.join('')+'</div>':'')+'</td></tr>';
});
tb.innerHTML=rows.join('');
document.getElementById('ig-depo').placeholder=d.depo_db_prod||d.depo_db||'как сейчас';
tb.querySelectorAll('button[data-act]').forEach(function(b){ b.onclick=function(){ act(b.dataset.act,b.dataset.c,b); }; });
}).catch(function(){ head.textContent='Состояние ИШ не получено.'; });
}
function act(a,c,b){
if(a==='showprod'){ document.getElementById('ig-prod').style.display='block'; return; }
var p=new URLSearchParams(); p.set('contour',c); p.set('crypto',document.getElementById('ig-crypto').value);
if(a==='contour'){ b.disabled=true; cout.textContent='Записываю канал и перезапускаю ИШ (до 2 минут)…';
post('/admin/setup/igate/contour',p).then(function(d){ b.disabled=false; cout.innerHTML=d.ok?ok(d.output):bad(d.error); load(); }); }
if(a==='switch'){ if(!confirm('Переключить bj на тестовый контур TEST3?')) return; cout.textContent='Переключаю…';
post('/admin/setup/igate/switch',p).then(function(d){ cout.innerHTML=d.ok?ok(d.output):bad(d.error); if(d.ok) setTimeout(function(){location.reload()},15000); }); }
}
document.getElementById('ig-goprod').onclick=function(){
var p=new URLSearchParams(); p.set('contour','prod'); p.set('crypto',document.getElementById('ig-crypto').value);
p.set('depo_db',document.getElementById('ig-depo').value.trim()); p.set('confirm',document.getElementById('ig-confirm').value.trim());
if(!confirm('Перейти на БОЕВОЙ контур? bj начнёт отправлять в НРД настоящие переводы.')) return;
cout.textContent='Переключаю…';
post('/admin/setup/igate/switch',p).then(function(d){ cout.innerHTML=d.ok?ok(d.output):bad(d.error); if(d.ok) setTimeout(function(){location.reload()},15000); });
};
document.getElementById('ig-crypto').onchange=load;
function poll(){ var t0=Date.now(), iv=setInterval(function(){
fetch('/admin/setup/igate/install-status',{cache:'no-store'}).then(function(r){return r.json()}).then(function(s){
if(!s.stage) return; out.textContent='Установка: '+s.msg; if(s.log){ lg.style.display='block'; lg.textContent=s.log; lg.scrollTop=lg.scrollHeight; }
if(s.stage==='done'){ clearInterval(iv); out.innerHTML=ok(s.msg); load(); }
if(s.stage==='error'){ clearInterval(iv); out.innerHTML=bad(s.msg); }
}).catch(function(){});
if(Date.now()-t0>1200000) clearInterval(iv); },3000); }
document.getElementById('ig-dl').onclick=function(){ var p=new URLSearchParams(); p.set('version',document.getElementById('ig-ver').value.trim());
out.textContent='Запускаю…'; post('/admin/setup/igate/install',p).then(function(d){ if(!d.ok){out.innerHTML=bad(d.error);return} out.textContent=d.output; poll(); }); };
document.getElementById('ig-up').onclick=function(){ var f=document.getElementById('ig-file').files[0]; if(!f){ out.textContent='Выберите файл igate-…-amd64.zip'; return; }
var fd=new FormData(); fd.append('file',f); out.textContent='Загружаю '+f.name+'…';
fetch('/admin/setup/igate/install',{method:'POST',body:fd}).then(function(r){return r.json()}).then(function(d){ if(!d.ok){out.innerHTML=bad(d.error);return} out.textContent=d.output; poll(); }); };
load();
})();
</script>
{{end}}