Files
Bridge-and-Join-s/internal/lkgateway/setup.go
T
fontvielle a0ffbfa721 ЛК: портфель ДЕПО «для всех клиентов» — одно соединение и один запрос к DEPO, свой цикл раз в 5 мин; итог в настройках свёрнут
На 9 после включения «для всех» (23.10.2026) каждый проход открывал по два
соединения с DEPO на каждый из 1158 договоров — около 50 входов в MSSQL в
секунду без перерыва, а проход шёл в цикле опроса заявок ЛК и задерживал его.

- Остатки всех клиентов — одним запросом (fsql.Dp03), одно соединение с DEPO
  на проход; по списку — запрос по клиенту на том же соединении. На боевой
  DEPO: общий запрос 127 мс, совпадает с запросом по клиенту (58 договоров).
- Свой цикл lkPortfolioLoop (здоровье «lk-portfolio»): по списку — каждые
  10 с, для всех клиентов — раз в 5 мин. Опрос заявок ЛК больше не ждёт.
- Проходы по очереди: фоновый и «Заполнить сейчас» не шлют один портфель
  дважды; принудительный проход пропускает отправленные за последнюю минуту.
- ЛК не отвечает — проход прерывается; одна и та же ошибка отправки не
  повторяется в журнале каждый проход. Ошибка чтения DEPO снимается, когда
  портфель снова читается без изменений.
- Настройки: итог (договоров, отправлено, нечего отправлять, с ошибкой),
  последние 5 отправок; договоры с ошибкой и все договоры — под раскрытием.
  «Отправлять нечего» — не ошибка (поле Nothing).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eREaYwUt1i4fNLZcGnjn
2026-10-23 14:45:16 +03:00

1956 lines
88 KiB
Go
Raw 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
import (
"context"
"encoding/json"
"errors"
"fmt"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/release"
"io"
"log"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/cryptocli"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter"
)
// crypto-сертификаты на текущих токенах (для отображения на странице).
// В gRPC-режиме сертификатами управляет сам сайдкар через профиль
// Валидаты — здесь временно возвращаем пустой список, пока
// crypto-service не получит метод ListCertificates.
func (h *setupHandlers) listCertsForUI() []cryptocli.Certificate {
return storeCertsForUI(h.rc.Snapshot())
}
// setupHandlers — обработчики /admin/setup/*.
type setupHandlers struct {
rc *RuntimeConfig
tpl *adminTemplates
svc *Service
keyWiz *keyWizardState // состояние мастера установки ключа
}
// adminTemplates — обёртка, чтобы передать набор шаблонов в setup.
type adminTemplates struct {
a *admin
}
// registerSetup вешает /admin/setup и /admin/setup/* (POST) на mux.
func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service) {
h := &setupHandlers{rc: rc, tpl: &adminTemplates{a: a}, svc: svc}
registerLKPortfolioSync(mux, rc) // портфель ДЕПО в ЛК: настройки, выбор клиента, «Залить сейчас»
go func() { // прогрев: версии компонентов, пользователи ЛК, раздел QUIK-экспорта
time.Sleep(5 * time.Second)
systemVersions(rc)
lkUsersForSetup(rc)
quikExportView(rc, false)
}()
mux.HandleFunc("/admin/setup", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
h.renderSetup(w, r, "")
})
mux.HandleFunc("/admin/setup/postgres", h.savePostgres)
registerWizDefer(mux) // «Настроить позже» в мастере
mux.HandleFunc("/admin/setup/postgres/quick-start", h.quickStartPostgres)
h.registerPGMove(mux)
h.registerFancy(mux) // вкладка «Обмен Fancy (MSSQL)»
h.registerQuik(mux) // вкладка «QUIK МНП» (блокировка бумаг)
mux.HandleFunc("/admin/setup/crypto", h.saveCrypto)
mux.HandleFunc("/admin/setup/crypto/check", h.checkCrypto)
mux.HandleFunc("/admin/setup/crypto/test-sign", h.testSignCrypto)
mux.HandleFunc("/admin/setup/test-nsd", h.testNSD)
mux.HandleFunc("/admin/setup/update", h.saveUpdate)
mux.HandleFunc("/admin/setup/update/check", h.checkUpdate)
mux.HandleFunc("/admin/setup/update/apply", h.applyUpdate)
mux.HandleFunc("/admin/setup/update/upload", h.uploadUpdate)
mux.HandleFunc("/admin/setup/update/progress", h.updateProgress)
mux.HandleFunc("/admin/setup/wizard-depo/conn", h.wizardDepoConn)
mux.HandleFunc("/admin/setup/wizard-depo/tables", h.wizardDepoTables)
mux.HandleFunc("/admin/setup/wizard-depo/job", h.wizardDepoJob)
mux.HandleFunc("/admin/setup/wizard-depo/recheck", h.wizardDepoRecheck)
mux.HandleFunc("/admin/setup/wizard-depo/setup", h.wizardDepoSetup) // подготовка базы одним действием
mux.HandleFunc("/admin/setup/wizard-depo/script.sql", h.wizardDepoScript) // один скрипт для администратора
mux.HandleFunc("/admin/setup/fancy/acc-prefix", h.accPrefix)
mux.HandleFunc("/admin/setup/license", h.saveLicense)
mux.HandleFunc("/admin/setup/license/activate", h.licenseActivate)
registerPrepare(mux, h) // «Подготовка модулей»: после ключа — модули лицензии, потом мастер
mux.HandleFunc("/admin/setup/license/renew", h.licenseRenewNow)
mux.HandleFunc("/admin/setup/logo", h.saveLogo)
mux.HandleFunc("/admin/setup/logo/reset", h.resetLogo)
mux.HandleFunc("/admin/setup/nsd", h.saveNSD)
mux.HandleFunc("/admin/setup/org", h.saveOrg)
mux.HandleFunc("/admin/setup/nsd-requisites", h.saveNSDRequisites)
mux.HandleFunc("/admin/setup/lk", h.saveLK)
mux.HandleFunc("/admin/setup/lk/dist", h.lkSaveDist)
mux.HandleFunc("/admin/setup/lk/dist.tar.gz", h.lkDistDownload)
mux.HandleFunc("/admin/setup/lk/users", h.lkUsersSave)
mux.HandleFunc("/admin/setup/lk/relay-php.zip", h.lkPHPRelayDownload)
mux.HandleFunc("/admin/setup/lk/preview", h.lkPreviewSave)
mux.HandleFunc("/admin/setup/lk/data", h.lkDataSave)
mux.HandleFunc("/admin/setup/units/restart", h.unitRestart)
mux.HandleFunc("/admin/setup/lk-integration", h.saveLKIntegration)
mux.HandleFunc("/admin/setup/lk-check", h.checkLKConn)
mux.HandleFunc("/admin/setup/lk-simulate", h.lkSimulate)
mux.HandleFunc("/admin/setup/lk-pull", h.lkPull)
mux.HandleFunc("/admin/setup/workhours", h.saveWorkHours)
mux.HandleFunc("/admin/setup/onec", h.saveOneC)
mux.HandleFunc("/admin/setup/onec/check", h.saveOneCCheck)
mux.HandleFunc("/admin/setup/onec/access", h.saveOneCAccess)
mux.HandleFunc("/admin/setup/onec/revoke", h.saveOneCRevoke)
mux.HandleFunc("/admin/setup/alerts-smtp", h.saveAlertsSMTP)
mux.HandleFunc("/admin/setup/alerts-smtp-test", h.testAlertsSMTP)
mux.HandleFunc("/admin/setup/alerts-zabbix", h.saveAlertsZabbix)
mux.HandleFunc("/admin/setup/alerts-zabbix-test", h.testAlertsZabbix)
mux.HandleFunc("/admin/setup/incoming-notify", h.saveIncomingNotify)
mux.HandleFunc("/admin/setup/incoming-notify-test", h.testIncomingNotify)
mux.HandleFunc("/admin/setup/advanced-unlock", h.unlockAdvanced)
mux.HandleFunc("/admin/setup/advanced-lock", h.lockAdvanced)
mux.HandleFunc("/admin/setup/test-run", h.testRun)
// Авто-загрузка сертификатов УЦ НРД и нашего УЦ.
mux.HandleFunc("/admin/setup/cacerts", h.saveCACerts)
mux.HandleFunc("/admin/setup/cacerts/fetch", h.fetchCACertsNow)
// Доверенные сертификаты НРД в справочнике ИШ (вшитый комплект + ручные).
registerQuikExportSetup(mux, h)
mux.HandleFunc("/admin/setup/versions/refresh", h.refreshVersionsHandler)
mux.HandleFunc("/admin/setup/m2m-auto", h.saveM2MAuto)
mux.HandleFunc("/admin/setup/accept-iis", h.saveAcceptIIS)
registerUsersSetup(mux, h)
mux.HandleFunc("/admin/setup/npr", h.saveNPR)
mux.HandleFunc("/admin/setup/npr/test", h.testNPR)
mux.HandleFunc("/admin/setup/nsdcerts", h.saveNSDCerts)
mux.HandleFunc("/admin/setup/nsdcerts/check", h.checkNSDCerts)
mux.HandleFunc("/admin/setup/nsdcerts/apply", h.applyNSDCerts)
// Перезапуск сервисов из UI.
mux.HandleFunc("/admin/setup/restart-crypto", h.restartCrypto)
mux.HandleFunc("/admin/setup/restart-server", h.restartServer)
// Новости / события системы.
mux.HandleFunc("/admin/news", h.renderNews)
mux.HandleFunc("/admin/news/add", h.addManualNews)
mux.HandleFunc("/admin/news/dismiss", h.dismissNews)
mux.HandleFunc("/admin/news/check-docs", h.checkDocsNow)
// Пошаговый мастер настройки для нетехнических пользователей.
mux.HandleFunc("/admin/wizard", h.renderWizard)
// Пошаговый мастер установки ключа Валидаты на флешку.
h.registerKeyWizard(mux)
h.registerValidataRNG(mux)
}
// renderNews — GET /admin/news.
func (h *setupHandlers) renderNews(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
s := h.rc.Snapshot()
s.News.Items = visibleNews(h.rc, s.News.Items)
data := struct {
page
Settings Settings
Flash string
}{
page: nowPage("Новости", "news"),
Settings: s,
Flash: r.URL.Query().Get("flash"),
}
render(w, h.tpl.a.news, data)
}
// WizardData — данные для шаблона /admin/wizard.
type WizardData struct {
page
Step int
Settings Settings
Suggest *DepoRequisites // предложение реквизитов из DEPO (после «Определить из DEPO»)
Lic LicenseStatus
DepoLicensed bool
DepoDB string
DepoTables DepoTablesView
DepoJob DepoJobView
DepoEdition string // редакция SQL Server клиента (пусто — не определена)
DepoAgent bool // у SQL Server есть SQL Agent (не Express, не PostgreSQL)
DepoJobDef string // способ обработки по умолчанию для мастера
Fancy *SetupData // шаг 3: общий блок «Обмен Fancy» (тот же, что в настройках)
Sub string // экран шага 3 (учёт DEPO): intro|conn|tables|job|done
DepoUser string // шаг 3, «скрипт администратору»: логин bj, который создаст скрипт
DepoPass string // и его пароль (сохранённый или придуманный сейчас — сохраняется со скриптом)
DepoMode string // выбранный способ подготовки базы: auto|script|existing
DepoJobName string // имя задания SQL Server Agent для этой базы (пусто — по имени базы, считает экран)
ValidataVer string // версия установленной «Валидаты Клиент L» (пусто — не установлена)
CryptoSvc string // состояние службы bj-crypto (systemctl is-active)
PGSummary string // куда подключена база bj, без пароля
Update *UpdateStatus // шаг 1: есть ли на сервере обновлений версия новее, чем в образе
AutoDone []string // шаги до текущего, уже выполненные без участия пользователя, — с пояснением
Certs []cryptocli.Certificate
Flash string
KeyWiz *keyWizardData // шаг «Ключ подписи»: установка ключа прямо в мастере
IgateOK bool // ИШ на сервере установлен (ключ ставится в его профиль)
// главы мастера (wizchapters.go): путь этой установки, соседние шаги, шапка
Path []int
Chapters []WizChapter
Prev, Next int
StepDone bool
Unfinished int // обязательный несделанный шаг (последний шаг ведёт к нему, а не «к работе»)
UnfinishedT string // его название
StepRequired bool
StepTitle string
ChapterTitle string
ChaptersLine string // «Четыре главы: сервер, организация, НРД, запуск.»
Modules []WizModule // глава «Запуск»: модули по лицензии
Deferred map[int]bool // шаги, отложенные кнопкой «Настроить позже» (и ещё не настроенные)
Done struct {
License bool
Depo bool
Org bool // организация (код участника, ИНН) задана
Quik bool // QUIK МНП проверен (или модуль не в лицензии)
Postgres bool
Crypto bool
Certs bool
NSD bool
TestRun bool
Alerts bool // оповещения настроены и резервные копии включены
Modules bool // модули по лицензии настроены
Server bool // время сервера синхронизируется
Users bool // кроме администратора заведены учётные записи сотрудников
}
}
// renderWizard рисует одну из 5 страниц мастера. Шаг управляется query
// параметром ?step=N (1..5). По умолчанию шаг определяется автоматически
// по первому незавершённому пункту — это даёт «продолжить с того места».
func (h *setupHandlers) renderWizard(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
s := h.rc.Snapshot()
d := WizardData{
page: nowPage("Мастер настройки", "wizard"),
Settings: s,
Suggest: depoDetectResult(),
Certs: h.listCertsForUI(),
Flash: r.URL.Query().Get("flash"),
}
d.Lic = licenseStatus(h.rc)
d.Done.License = d.Lic.Valid
d.DepoLicensed = moduleAllowed(h.rc, "quik-mnp")
d.DepoDB = s.Fancy.DepoDB()
d.DepoTables = depoTablesView(s.Fancy)
d.DepoJob = depoJobView(s.Fancy)
// Шаг 3 выполнен, когда есть подключение, трансферные таблицы и работающая обработка (job).
d.Done.Depo = !d.DepoLicensed || (strings.TrimSpace(s.Fancy.Server) != "" && s.Fancy.DepoTablesCheckOK && s.Fancy.DepoJobCheckOK)
d.Done.Postgres = s.Postgres.DSN != ""
{
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
d.ValidataVer, _, _ = validataVersion(ctx)
out, _ := exec.CommandContext(ctx, "systemctl", "is-active", "bj-crypto.service").Output()
cancel()
d.CryptoSvc = strings.TrimSpace(string(out))
}
d.PGSummary = dsnSummary(s.Postgres.DSN)
if d.ValidataVer != "" && (s.Crypto.Provider == "" || (s.Crypto.Provider == "stub" && !d.DevMode)) {
// Валидата на месте, а провайдер не выбран — выбираем её сами: от этого зависят список
// сертификатов (шаг 5) и проверки СКЗИ.
c := s.Crypto
c.Provider = "validata"
if c.SocketPath == "" {
c.SocketPath = "/run/bj/crypto.sock"
}
if err := h.rc.UpdateCrypto(c); err == nil {
s = h.rc.Snapshot()
d.Settings = s
d.Certs = h.listCertsForUI()
}
}
// Валидата ставится с образа/установщиком: если она на месте, шаг выполнен без ввода параметров
// (bj работает с ней через свой криптосервис, путь к модулю не нужен).
d.Done.Crypto = d.ValidataVer != "" || (s.Crypto.Provider != "" && s.Crypto.Provider != "stub" && s.Crypto.ModulePath != "")
d.Done.Certs = len(ownValidCerts(d.Certs)) > 0
d.Done.NSD = doneNSD(s)
d.Done.TestRun = s.LastTest != nil || (!d.DevMode && readinessDone()) // вне режима разработки — итоговая проверка готовности пройдена
d.Done.Org = doneOrg(s)
d.Done.Alerts = doneAlerts(s)
d.Done.Quik = !d.DepoLicensed || doneQuik(s)
d.Modules = wizModuleViews(h.rc, s)
d.Done.Server, _ = timeStatus(r.Context())
d.Done.Server = d.Done.Server && !s.WorkHours.SavedAt.IsZero() // и рабочее время организации задано
def := loadWizDeferred()
d.Done.Modules = true
for i := range d.Modules {
d.Modules[i].Deferred = !d.Modules[i].Ready && def.Modules[d.Modules[i].Key]
if !d.Modules[i].Ready {
d.Done.Modules = false
}
}
// Главы (wizchapters.go): в пути только шаги, нужные этой установке — по лицензии и без сделанного
// установщиком. Без ?step — первый несделанный обязательный шаг, а если всё сделано — проверка.
d.Done.Users = doneUsers()
// Выполнено ли — одно правило с плашкой на главной (wizDone), с учётом «Настроить позже».
done := wizDone(r.Context(), h.rc, d.ValidataVer, d.Done.Certs || h.keyWiz.installed() || keyWizInstalled.Load())
// «отложено» показываем, только пока шаг не настроен по-настоящему
d.Deferred = map[int]bool{9: def.Steps[9] && !d.Done.Alerts, 11: def.Steps[11] && !d.Done.Modules, 13: def.Steps[13] && !d.Done.Users}
path, _ := wizPath(h.rc, done, 0)
step := wizDefaultStep(path, done)
if v := r.URL.Query().Get("step"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 1 && n <= wizardSteps {
step = n
}
}
d.Step = step
d.Path, d.Chapters = wizPath(h.rc, done, step)
d.Prev, d.Next = wizNeighbours(d.Path, step)
d.StepDone, d.StepRequired, d.StepTitle = done[step], wizRequired[step], wizStepTitles[step]
if u := wizDefaultStep(path, done); wizRequired[u] && !done[u] && u != step {
d.Unfinished, d.UnfinishedT = u, wizStepTitles[u]
}
for _, c := range d.Chapters {
if c.Current {
d.ChapterTitle = c.Title
}
}
d.ChaptersLine = wizChaptersLine(d.Chapters)
if done[2] {
d.AutoDone = append(d.AutoDone, "база bj")
}
if done[6] {
d.AutoDone = append(d.AutoDone, "СКЗИ «Валидата» "+d.ValidataVer)
}
if step == 4 && strings.TrimSpace(s.Fancy.Server) != "" && !doneOrg(s) && depoDetectResult() == nil {
// организация ещё не задана, а база DEPO подключена — определяем реквизиты сами: карточка
// покажет их заполненными, пользователю остаётся проверить и сохранить
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
if res, err := depoDetectRequisites(ctx, s.Fancy); err == nil {
lastDepoDetect.mu.Lock()
lastDepoDetect.r = res
lastDepoDetect.mu.Unlock()
d.Suggest = res
} else {
log.Printf("wizard: реквизиты из DEPO не определены: %v", err)
}
cancel()
}
if (step >= 3 && step <= 5) || step == 9 || step == 11 || step == 13 || step == 14 { // DEPO, организация, QUIK МНП, оповещения и копии, модули, учётные записи, обновление — общие блоки из «Настройки»
fd := h.setupData(w, r, "")
fd.InWizard = true
d.Fancy = &fd
}
if step == 8 {
d.KeyWiz, d.IgateOK = h.keyWizView(r.URL.Query().Get("contour"), true), igateInstalled()
}
if step == 3 {
d.Sub = depoWizardSub(r.URL.Query().Get("sub"))
d.DepoUser, d.DepoPass = depoSetupLogin(s.Fancy), s.Fancy.Password
if s.Fancy.DepoNewUser != "" { // скрипт уже выдан, вход ещё не проверен — те же логин и пароль, что в нём
d.DepoUser, d.DepoPass = s.Fancy.DepoNewUser, s.Fancy.DepoNewPass
} else if s.Fancy.User != d.DepoUser || d.DepoPass == "" {
d.DepoPass = depoGenPassword()
}
d.DepoJobName = s.Fancy.DepoJobName
if d.DepoJobName == "" && s.Fancy.DepoJobMode == "sql-auto" && s.Fancy.User != "" {
d.DepoJobName = depoJobNameLegacy // установка старше имён по базе: её задание — bj2depo
}
d.DepoMode = "auto"
if s.Fancy.DepoTablesMode == "script" {
d.DepoMode = "script"
}
if r.URL.Query().Get("sub") == "" && d.Done.Depo {
d.Sub = "done"
}
}
if step == 3 && d.DepoLicensed && strings.TrimSpace(s.Fancy.Server) != "" {
// Есть ли у сервера SQL Agent: у Express его нет, у PostgreSQL — тоже; тогда обработку
// запускает сам bj. Иначе по умолчанию джобу SQL Agent создаёт bj.
d.DepoJobDef = "bj"
if !s.Fancy.IsPostgres() {
ctx, cancel := context.WithTimeout(r.Context(), 4*time.Second)
d.DepoEdition, d.DepoAgent = depoServerEdition(ctx, s.Fancy)
cancel()
if d.DepoAgent || d.DepoEdition == "" {
d.DepoJobDef = "sql-auto"
}
}
}
if step == 1 && d.Done.License {
// Образ ставит ядро той версии, с которой собран; всё, что вышло позже (исправления ядра,
// установщика и служб), приезжает обновлением — предлагаем его сразу после ввода ключа.
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
if st, err := NewUpdater(h.rc).CheckForUpdate(ctx); err == nil {
d.Update = &st
}
cancel()
}
render(w, h.tpl.a.wizard, d)
}
// depoServerEdition — редакция SQL Server и есть ли у неё SQL Agent (EngineEdition 4 — Express).
func depoServerEdition(ctx context.Context, f FancyMSSQLSettings) (string, bool) {
db, err := fancyOpen(f, "master")
if err != nil {
return "", false
}
defer db.Close()
var eng int
var ed string
if err := db.QueryRowContext(ctx, "SELECT CAST(SERVERPROPERTY('EngineEdition') AS int), CAST(SERVERPROPERTY('Edition') AS nvarchar(128))").Scan(&eng, &ed); err != nil {
return "", false
}
return ed, eng != 4
}
// wizardSteps — шагов в мастере: 1 лицензия, 2 база bj, 3 учёт DEPO, 4 наша организация, 5 QUIK МНП,
// 6 Валидата, 7 шлюз НРД, 8 ключ и сертификаты (ключ ставится в профиль ИШ — после шлюза),
// 9 оповещения и резервные копии, 10 проверка готовности.
const wizardSteps = 14 // 11 — модули, 12 — время и сеть, 13 — учётные записи, 14 — обновление (первый пункт, глава «Обновление»)
// dsnSummary — «host:port, база name» из строки подключения PostgreSQL, без логина и пароля.
func dsnSummary(dsn string) string {
u, err := url.Parse(strings.TrimSpace(dsn))
if err != nil || u.Host == "" {
return ""
}
host := u.Host
if h := u.Hostname(); h == "127.0.0.1" || h == "localhost" || h == "::1" {
host = "PostgreSQL на этом сервере"
}
if db := strings.TrimPrefix(u.Path, "/"); db != "" {
return host + ", база " + db
}
return host
}
// runCmd выполняет команду и возвращает stdout+stderr строкой.
func runCmd(ctx context.Context, name string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, name, args...)
out, err := cmd.CombinedOutput()
return string(out), err
}
// checkCrypto — POST /admin/setup/crypto/check. Делает Health-вызов
// к crypto-service через UDS-сокет.
func (h *setupHandlers) checkCrypto(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
s := h.rc.Snapshot()
// Подписывает документы для НРД шлюз (ИШ) профилем Валидаты, который создал шаг «Ключ подписи»: проверяем
// именно его — версия СКЗИ, датчик случайных чисел, подпись проверочного документа этим профилем.
if !devMode(h.rc) || igateInstalled() {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
defer cancel()
ver, _, _ := validataVersion(ctx)
if ver == "" {
setupFlash(w, r, "СКЗИ «Валидата» на сервере не найдена — её ставит установщик bj")
return
}
res, err := runKeymediaOp(ctx, "keycheck", map[string]string{"channel": contourChannel(contourOf(s.NSD.Profile))})
if err != nil {
setupFlash(w, r, "СКЗИ «Валидата» "+ver+": ключ не проверен — "+err.Error())
return
}
prof, _ := res["profile"].(string)
kc, _ := res["keycheck"].(string)
rng := "датчик случайных чисел готов"
if res["rng"] == "false" {
rng = "датчик случайных чисел не инициализирован (шаг «Ключ подписи»)"
}
if kc == "ok" {
setupFlash(w, r, "✓ СКЗИ «Валидата» "+ver+", "+rng+"; ключ организации (профиль ИШ «"+prof+"») подписывает проверочный документ")
return
}
diag, _ := res["keydiag"].(string)
setupFlash(w, r, "✗ СКЗИ «Валидата» "+ver+", "+rng+"; ключ профиля «"+prof+"» не подписывает: "+strings.TrimPrefix(kc, "fail: ")+prefixed(". ", diag))
return
}
cli := cryptocli.New(cryptocli.Config{
Provider: cryptocli.Provider(s.Crypto.Provider),
SocketPath: s.Crypto.SocketPath,
ModulePath: s.Crypto.ModulePath,
})
defer cli.Close()
info, err := cli.Health(r.Context())
if err != nil {
setupFlash(w, r, "СКЗИ: проверка не прошла — "+err.Error())
return
}
msg := fmt.Sprintf("СКЗИ %s: %s", info.Provider, info.Message)
if info.CryptokiVersion != "" {
msg += fmt.Sprintf(" (PKCS#11 v%s, %s)", info.CryptokiVersion, info.ManufacturerID)
}
if len(info.Tokens) > 0 {
msg += ". Токены:"
for _, t := range info.Tokens {
msg += fmt.Sprintf(" «%s» (%s);", t.Label, t.Model)
}
}
setupFlash(w, r, msg)
}
// testSignCrypto — POST /admin/setup/crypto/test-sign. Подписывает фиксированный
// тестовый XML через активный профиль Валидаты и возвращает в toast длину
// + первые байты base64 CMS. Файл сохраняется в /var/lib/bj/.bj/test-sign.p7s
// чтобы оператор мог проверить сам через openssl/zpki1utl.
func (h *setupHandlers) testSignCrypto(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
s := h.rc.Snapshot()
cli := cryptocli.New(cryptocli.Config{
Provider: cryptocli.Provider(s.Crypto.Provider),
SocketPath: s.Crypto.SocketPath,
ModulePath: s.Crypto.ModulePath,
})
defer cli.Close()
payload := []byte("<bj-test ts=\"" + time.Now().UTC().Format(time.RFC3339) + "\">Bridge-and-Join-s sign smoke test</bj-test>")
sig, err := cli.SignXMLDSig(r.Context(), payload, "", s.Crypto.Profile)
if err != nil {
setupFlash(w, r, "Ключ не смог подписать проверочный документ: "+err.Error())
return
}
outPath := "/var/lib/bj/.bj/test-sign.p7s"
if err := os.MkdirAll(filepath.Dir(outPath), 0o700); err == nil {
_ = os.WriteFile(outPath, sig, 0o600)
}
setupFlash(w, r, fmt.Sprintf("Ключ работает: проверочный документ подписан (подпись %d байт сохранена в %s).", len(sig), outPath))
}
// SetupData — данные для шаблона admin_setup.html.
type SetupData struct {
page
FromWizard bool // раздел открыт из мастера (настройка модуля) — показать «Вернуться в мастер»
InWizard bool // общий блок показан внутри шага мастера — заголовок и пояснение даёт сам шаг
Settings Settings
Suggest *DepoRequisites // предложение реквизитов из DEPO
Readiness []Readiness
ReadyCount int
TotalCount int
Certificates []cryptocli.Certificate
CurrentVersion string // версия bj-server (для раздела «Обновления»)
Components []release.ComponentState // компоненты и их версии (раздел «Обновления»)
UpdatesAvailable bool
License LicenseStatus // статус лицензии
Health []HealthStatus // здоровье фоновых компонентов
AdvancedUnlocked bool // открыты ли перспективные модули (QUIK, Fancy)
NSDCerts NSDCertsView // сертификаты НРД в справочнике ИШ
Participants []HandbookEntry // справочник участников M2M (выбор своей организации)
OrgINN, OrgName string // наша организация (с учётом справочника)
OrgCode string
NotifyGroups []struct{ Key, Title string }
NotifyKinds []struct{ Key, Title string }
DepoJob DepoJobView
DepoTables DepoTablesView // трансферные таблицы FTV_M2M* в базе DEPO
QuikExport QuikExportView // модуль «Экспорт биржевой информации»
LKAgent LKAgentStatus // канал кабинета клиента (режим ожидания)
LKKeyMask string // ключ кабинета в маскированном виде
LKUsers []LKUser // учётные записи кабинета
LKUsersError string
LKPreview LKPreviewStatus // кабинет на этом сервере для просмотра
LKData LKDataView // курсы валют и ежедневный срез стоимости
Units []UnitView // службы bj и её модулей
UpdateFailed bool // последняя попытка обновления закончилась отказом
Versions SystemVersions // версии компонентов bj и внешних систем
Modules map[string]bool // какие лицензируемые модули разрешены (ключ = признак)
Features []LicenseFeatureView // признаки лицензии для карточки
Users UsersView // пользователи, роли, сессии
OneCCheck OneCCheckView // проверка поручений в 1С
Flash string
Error string
}
// HealthLabel — человекочитаемое имя компонента для UI.
func HealthLabel(name string) string {
switch name {
case "poll-lk":
return "Опрос заявок ЛК ЕСИА Финанс"
case "lk-portfolio":
return "Заполнение портфеля ДЕПО в ЛК ЕСИА Финанс"
case "poll-incoming":
return "Опрос ответов НРД (ИШ)"
case "consume-decisions":
return "Приём решений"
case "poll-quik":
return "Цикл QUIK МНП (подпись / снятие / зачисление)"
case "order-watch":
return "Сроки депо-поручений НРД (MS010 5 мин / 30 дней)"
case "depo-events":
return "Обмен с DEPO (bj2depo): события и справочник"
case "m2m-auto":
return "Автоматический режим M2M (решения, 16/1, форма 16)"
case "depo-job":
return "Обработка трансферных таблиц DEPO силами bj"
case "onec-check":
return "Проверка поручений в 1С"
}
return name
}
func (h *setupHandlers) renderSetup(w http.ResponseWriter, req *http.Request, flash string) {
render(w, h.tpl.a.setup, h.setupData(w, req, flash))
}
// setupData — данные страницы «Настройка»; их же получает общий блок «Обмен Fancy» в мастере.
func (h *setupHandlers) setupData(w http.ResponseWriter, req *http.Request, flash string) SetupData {
s := h.rc.Snapshot()
r := h.rc.ReadinessSummary()
ready := 0
for _, x := range r {
if x.Configured {
ready++
}
}
comps, anyUpdate := updateComponentRows(h.rc)
data := SetupData{
page: nowPage("Настройка", "setup"),
Components: comps,
UpdatesAvailable: anyUpdate,
Settings: s,
Suggest: depoDetectResult(),
Readiness: r,
ReadyCount: ready,
TotalCount: len(r),
Certificates: h.listCertsForUI(),
CurrentVersion: BuildVersion,
License: licenseStatus(h.rc),
Health: HealthSnapshot(),
AdvancedUnlocked: advancedUnlocked(req),
FromWizard: req.URL.Query().Get("from") == "wizard",
NSDCerts: nsdCertsView(s),
Participants: handbookParticipants(),
OrgINN: orgINN(s),
OrgName: orgName(s),
OrgCode: orgCode(s),
NotifyGroups: notifyGroups,
NotifyKinds: visibleNotifyKinds(h.rc),
DepoJob: depoJobView(s.Fancy),
DepoTables: depoTablesView(s.Fancy),
QuikExport: quikExportView(h.rc, req.URL.Query().Get("reveal_ro") == "1"),
LKAgent: lkAgentStatus(s),
LKPreview: lkPreviewStatus(h.rc, req),
LKData: lkDataView(h.rc),
UpdateFailed: updateResultFailed(s.Update.LastResult),
LKKeyMask: lkKeyForPage(s.LK.APIKey),
Versions: systemVersions(h.rc),
Modules: modulesAllowed(h.rc),
Features: licenseFeatureViews(h.rc),
Users: usersView(w),
Flash: flash,
}
data.LKUsers, data.LKUsersError = lkUsersForSetup(h.rc)
if moduleAllowed(h.rc, "onec-back") {
data.OneCCheck = onecCheckView(req.Context(), h.rc)
}
if errVal := errMsgFromQuery(_q(w)); errVal != "" {
data.Error = errVal
}
return data
}
func (h *setupHandlers) savePostgres(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
dsn := strings.TrimSpace(r.FormValue("dsn"))
if dsn != "" {
// Лёгкая проверка: попытка sql.Open и Ping (без драйвера дальше
// просто ошибка — это нормально, важно показать что DSN сохранён).
if err := tryPingPostgres(dsn); err != nil {
setupFlash(w, r, "postgres: проверка соединения не прошла: "+err.Error())
return
}
}
if err := h.rc.UpdatePostgres(PostgresSettings{DSN: dsn}); err != nil {
setupFlash(w, r, "postgres: ошибка сохранения: "+err.Error())
return
}
setupFlash(w, r, "PostgreSQL настройки сохранены")
}
// quickStartPostgres — POST /admin/setup/postgres/quick-start.
// «Большая зелёная кнопка» для пользователя без IT-навыков: поднимает
// локальный postgres-контейнер через podman-compose, ждёт pg_isready,
// накатывает все миграции (fansy-store + m2m-core), сохраняет дефолтный
// DSN в runtime-конфиге. После этого пользователю остаётся перезапустить
// bj-server (или мы сделаем это автоматически в дальнейших версиях).
func (h *setupHandlers) quickStartPostgres(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel()
// 1. Поднимаем postgres контейнер через podman-compose.
composePath := "deploy/docker-compose/docker-compose.yml"
if out, err := runCmd(ctx, "podman-compose", "-f", composePath, "up", "-d", "postgres"); err != nil {
setupFlash(w, r, "Шаг 1/3: podman-compose не смог поднять контейнер. "+
"Установите podman-compose или проверьте docker-compose.yml. Подсказка: "+
"sudo dnf install -y podman-compose. Вывод: "+strings.TrimSpace(out))
return
}
// 2. Ждём pg_isready.
dsn := "postgres://bj:bj_dev@127.0.0.1:5432/bj?sslmode=disable"
deadline := time.Now().Add(30 * time.Second)
for time.Now().Before(deadline) {
if err := tryPingPostgres(dsn); err == nil {
break
}
time.Sleep(time.Second)
}
if err := tryPingPostgres(dsn); err != nil {
setupFlash(w, r, "Шаг 2/3: контейнер запущен, но БД не отвечает за 30 сек. Ошибка: "+err.Error())
return
}
// 3. Применяем миграции через podman exec.
migrations := []string{
"migrations/fansy-store/000__roles.sql",
"migrations/fansy-store/001__schemas.sql",
"migrations/fansy-store/002__working.sql",
"migrations/fansy-store/003__staging.sql",
"migrations/fansy-store/004__seed_participants.sql",
"migrations/m2m-core/001__deals.sql",
"migrations/m2m-core/002__stages.sql",
}
for _, mig := range migrations {
if err := applyMigration(ctx, mig); err != nil {
// Миграция могла быть уже применена ранее (например, ROLE уже
// существует) — это не критично, продолжаем.
log.Printf("quick-start: миграция %s: %v (продолжаем)", mig, err)
}
}
// 4. Сохраняем DSN в runtime-конфиг.
if err := h.rc.UpdatePostgres(PostgresSettings{DSN: dsn}); err != nil {
setupFlash(w, r, "Шаг 3/3: не получилось сохранить DSN: "+err.Error())
return
}
setupFlash(w, r, "Локальный PostgreSQL поднят и настроен. DSN сохранён. "+
"Перезапустите bj-server (или подождите пока systemd сам перезапустит сервис), "+
"чтобы Repository подключился к БД. После этого статус PostgreSQL будет зелёным.")
}
// applyMigration выполняет одну SQL-миграцию через podman exec в bj-postgres.
func applyMigration(ctx context.Context, path string) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
cmd := exec.CommandContext(ctx, "podman", "exec", "-i", "bj-postgres",
"psql", "-U", "bj", "-d", "bj", "-v", "ON_ERROR_STOP=1")
cmd.Stdin = strings.NewReader(string(data))
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%w / output: %s", err, strings.TrimSpace(string(out)))
}
return nil
}
func (h *setupHandlers) saveCrypto(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
s := CryptoSettings{
Provider: strings.TrimSpace(r.FormValue("provider")),
SocketPath: strings.TrimSpace(r.FormValue("socket_path")),
ModulePath: strings.TrimSpace(r.FormValue("module_path")),
}
if s.Provider == "" {
s.Provider = "stub"
}
if s.SocketPath == "" {
s.SocketPath = "/run/bj/crypto.sock"
}
if s.ModulePath != "" {
if _, err := os.Stat(s.ModulePath); err != nil {
setupFlash(w, r, "crypto: module_path не найден: "+err.Error())
return
}
}
if err := h.rc.UpdateCrypto(s); err != nil {
setupFlash(w, r, "crypto: ошибка сохранения: "+err.Error())
return
}
setupFlash(w, r, "Криптография: настройки сохранены ("+s.Provider+")")
}
func (h *setupHandlers) saveNSD(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
s := NSDSettings{
Profile: strings.TrimSpace(r.FormValue("profile")),
IGWBaseURL: strings.TrimSpace(r.FormValue("igw_base_url")),
KeyContainer: strings.TrimSpace(r.FormValue("key_container")),
OrgCode: h.rc.Snapshot().NSD.OrgCode, // организация — отдельной формой (saveOrg)
OrgINN: h.rc.Snapshot().NSD.OrgINN,
OrgName: h.rc.Snapshot().NSD.OrgName,
DeponentCode: strings.TrimSpace(r.FormValue("deponent_code")),
AccountID: strings.TrimSpace(r.FormValue("account_id")),
SectionID: strings.TrimSpace(r.FormValue("section_id")),
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
}
if _, ok := r.Form["key_container"]; !ok {
s.KeyContainer = cur.KeyContainer
}
if s.Profile == "prod" { // старый пункт мастера: такого профиля нет — bj ушёл бы в имитацию
s.Profile = "prod-gost"
}
if s.Profile != "" {
if _, err := nsdadapter.LookupProfile(s.Profile); err != nil {
setupFlash(w, r, "НРД: неизвестный контур "+s.Profile)
return
}
}
if s.IGWBaseURL != "" {
if _, err := url.ParseRequestURI(s.IGWBaseURL); err != nil {
setupFlash(w, r, "nsd: невалидный URL: "+err.Error())
return
}
// У ИШ нет /healthz — проверяем рабочий эндпоинт Web API.
if err := tryHTTPHealth(s.IGWBaseURL + "/api/admin/engine/state"); err != nil {
setupFlash(w, r, "Шлюз НРД не отвечает по адресу "+s.IGWBaseURL+": "+err.Error()+". Проверьте, что ИШ установлен и запущен (служба igate).")
return
}
}
if err := h.rc.UpdateNSD(s); err != nil {
setupFlash(w, r, "nsd: ошибка сохранения: "+err.Error())
return
}
msg := "Шлюз НРД: настройки сохранены"
if s.Profile != cur.Profile || s.IGWBaseURL != cur.IGWBaseURL {
// отправитель в НРД создаётся при запуске — перезапускаем bj, чтобы контур и адрес применились сразу
msg += ". bj перезапускается, чтобы применить контур и адрес, — обновите страницу через 10–15 секунд."
go func() {
time.Sleep(2 * time.Second)
if _, err := restartUnit(context.Background(), "bj-server"); err != nil {
log.Printf("setup: перезапуск bj-server после смены контура НРД: %v", err)
}
}()
}
setupFlash(w, r, msg)
}
func (h *setupHandlers) saveLK(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
// Стартуем от текущих настроек, чтобы не затереть APIKey/PublicURL.
s := h.rc.Snapshot().LK
s.CallbackURL = strings.TrimSpace(r.FormValue("callback_url"))
s.PublicURL = strings.TrimSpace(r.FormValue("lk_public_url"))
if s.PublicURL != "" {
if _, err := url.ParseRequestURI(s.PublicURL); err != nil {
setupFlash(w, r, "lk: невалидный адрес ЛК: "+err.Error())
return
}
}
if s.CallbackURL != "" {
if _, err := url.ParseRequestURI(s.CallbackURL); err != nil {
setupFlash(w, r, "lk: невалидный URL: "+err.Error())
return
}
if err := tryHTTPHealth(s.CallbackURL + "/healthz"); err != nil {
setupFlash(w, r, "lk: callback URL не отвечает на /healthz: "+err.Error())
return
}
}
if err := h.rc.UpdateLK(s); err != nil {
setupFlash(w, r, "lk: ошибка сохранения: "+err.Error())
return
}
if s.CallbackURL != "" {
h.svc.callbackURL = s.CallbackURL
}
setupFlash(w, r, "Callback в ЛК сохранён и применён")
}
// lkEnvPtr возвращает окружение ЛК по имени (test/prod) внутри LKSettings.
func lkEnvPtr(s *LKSettings, env string) *LKEndpoint {
if env == "prod" {
return &s.Prod
}
return &s.Test
}
// saveLKIntegration — сохранение реквизитов подключения к API ЛК (тест/бой).
func (h *setupHandlers) saveLKIntegration(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
env := r.FormValue("env")
if env != "test" && env != "prod" {
setupFlash(w, r, "lk: неизвестное окружение")
return
}
s := h.rc.Snapshot().LK
ep := lkEnvPtr(&s, env)
ep.BaseURL = strings.TrimRight(strings.TrimSpace(r.FormValue("base_url")), "/")
ep.Login = strings.TrimSpace(r.FormValue("login"))
if pw := r.FormValue("password"); pw != "" {
ep.Password = pw // пустое поле не затирает сохранённый пароль
}
if ep.BaseURL != "" {
if _, err := url.ParseRequestURI(ep.BaseURL); err != nil {
setupFlash(w, r, "lk: невалидный адрес API: "+err.Error())
return
}
}
if a := r.FormValue("active_env"); a == "test" || a == "prod" {
s.ActiveEnv = a
}
if r.FormValue("emulate_nsd_form") != "" && devMode(h.rc) { // флажок — в форме активного окружения, в режиме разработки
s.EmulateNSD = r.FormValue("emulate_nsd") != ""
}
if err := h.rc.UpdateLK(s); err != nil {
setupFlash(w, r, "lk: ошибка сохранения: "+err.Error())
return
}
setupFlash(w, r, "Интеграция с ЛК ("+lkEnvName(env)+") сохранена")
}
// checkLKConn — «Проверить связь»: GET к BaseURL с Basic-авторизацией.
// Различает: связи нет / авторизация не прошла / связь есть.
func (h *setupHandlers) checkLKConn(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
env := r.FormValue("env")
s := h.rc.Snapshot().LK
ep := lkEnvPtr(&s, env)
if ep.BaseURL == "" {
setupFlash(w, r, "lk: сначала укажите адрес API "+lkEnvName(env))
return
}
ok, msg := probeLK(*ep)
ep.LastOK, ep.LastMsg, ep.LastAt = ok, msg, time.Now()
_ = h.rc.UpdateLK(s)
setupFlash(w, r, "Проверка связи с ЛК ("+lkEnvName(env)+"): "+msg)
}
// lkAPIBase возвращает базу back_office API ЛК ЕСИА Финанс из адреса стенда.
// Пользователь вводит корень (https://host), API живёт на /api/v1/back_office.
func lkAPIBase(baseURL string) string {
b := strings.TrimRight(baseURL, "/")
if strings.Contains(b, "/api/v1/back_office") {
return b
}
return b + "/api/v1/back_office"
}
// probeLK проверяет связь по реальному эндпоинту API с Basic-авторизацией —
// так 401 честно отличается от «связь есть» (а не корневой SPA, отдающий 200).
func probeLK(ep LKEndpoint) (bool, string) {
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
u := lkAPIBase(ep.BaseURL) + "/notifications/flat?per_page=1"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return false, "неверный адрес: " + err.Error()
}
req.Header.Set("Accept", "application/json")
if ep.Login != "" {
req.SetBasicAuth(ep.Login, ep.Password)
}
resp, err := (&http.Client{Timeout: 8 * time.Second}).Do(req)
if err != nil {
return false, "связи нет — сервер недоступен (" + err.Error() + ")"
}
defer resp.Body.Close()
switch {
case resp.StatusCode == 401 || resp.StatusCode == 403:
return false, fmt.Sprintf("сервер отвечает, но авторизация не прошла (HTTP %d) — проверьте логин/пароль", resp.StatusCode)
case resp.StatusCode >= 500:
return false, fmt.Sprintf("сервер вернул ошибку (HTTP %d)", resp.StatusCode)
default:
return true, fmt.Sprintf("связь есть (HTTP %d)", resp.StatusCode)
}
}
func lkEnvName(env string) string {
if env == "prod" {
return "бой"
}
return "тест"
}
// lkSimulate — симулятор подачи заявки: bj собирает depo_m2m и POST-ит в API ЛК
// ЕСИА Финанс (пока подача через веб-форму у них не готова). Показывает сырой
// ответ, чтобы докрутить контракт и затем «поймать» заявку опросом.
func (h *setupHandlers) lkSimulate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
s := h.rc.Snapshot().LK
ep := s.Test
if s.ActiveEnv == "prod" {
ep = s.Prod
}
if ep.BaseURL == "" || ep.Login == "" {
setupFlash(w, r, "Симулятор: сначала заполните и проверьте подключение к API ЛК")
return
}
// Реальная структура стенда (проверено): kind=depo_m2m, поля бумаги ПЛОСКО в data.
body := map[string]any{"claim": map[string]any{
"kind": "depo_m2m",
"user_id": atoiDefault(r.FormValue("user_id"), 0),
"owner_type": "Contract",
"owner_id": atoiDefault(r.FormValue("contract_id"), 0),
// Структура по swagger (test/prod): paper{} вложен (name/isin/type/reg_number),
// paper_count — ПЛОСКО в data (float), review_links — массив {text,url}.
"data": map[string]any{
"account": r.FormValue("account"),
"section": r.FormValue("section"),
"paper": map[string]any{
"name": r.FormValue("paper_name"),
"isin": r.FormValue("paper_isin"),
"type": nonEmpty(r.FormValue("paper_type"), "share"),
"registration_number": r.FormValue("paper_reg"),
},
"paper_count": atofDefault(r.FormValue("paper_count"), 1),
"contragent_inn": r.FormValue("contragent_inn"),
"contragent_name": r.FormValue("contragent_name"),
"contragent_place_of_payment": nonEmpty(r.FormValue("place_of_payment"), "nrd"),
"expense_information": r.FormValue("expense_information") == "on",
"review_links": []any{},
},
}}
raw, _ := json.Marshal(body)
code, respBody := lkPostJSON(lkAPIBase(ep.BaseURL)+"/claims", ep, raw)
snip := respBody
if len(snip) > 400 {
snip = snip[:400] + "…"
}
setupFlash(w, r, fmt.Sprintf("Симулятор → POST /claims: HTTP %d · %s", code, snip))
}
// lkPull — «Забрать заявки из ЛК»: один проход коннектора (GET in_queue →
// сборка M2M → отправка в НРД → PATCH «В работе»). Ручной триггер; штатно
// это делает фоновый поллер, но кнопка нужна для первичного теста.
func (h *setupHandlers) lkPull(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel()
res, err := PullLKClaims(ctx, h.rc, h.svc)
if err != nil {
log.Printf("lk-gateway: ручной забор ЛК: %v", err)
setupFlash(w, r, "Забор заявок из ЛК: "+err.Error())
return
}
msg := fmt.Sprintf("Забор заявок из ЛК: получено %d, отправлено в НРД %d, отклонено %d", res.Fetched, res.Sent, res.Rejected)
if res.Emulated {
_, why := lkEmulateNSD(h.rc)
if devMode(h.rc) {
msg = fmt.Sprintf("Забор заявок из ЛК (имитация НРД — %s): получено %d, прошли сверку и переданы имитатору %d, отклонены сверкой %d", why, res.Fetched, res.Sent, res.Rejected)
} else {
msg = fmt.Sprintf("Забор заявок из ЛК (без отправки в НРД — %s): получено %d, прошли сверку %d, отклонены сверкой %d", why, res.Fetched, res.Sent, res.Rejected)
}
}
if len(res.Errors) > 0 {
msg += " · ошибки: " + strings.Join(res.Errors, "; ")
}
log.Printf("lk-gateway: ручной забор ЛК: %s", msg)
setupFlash(w, r, msg)
}
// saveAlertsSMTP сохраняет настройки почтовых уведомлений.
func (h *setupHandlers) saveAlertsSMTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
a := h.rc.Snapshot().Alerts
a.SMTP = SMTPSettings{
Enabled: r.FormValue("enabled") == "on",
Host: strings.TrimSpace(r.FormValue("host")),
Port: atoiDefault(r.FormValue("port"), 587),
Security: nonEmpty(r.FormValue("security"), "starttls"),
Username: strings.TrimSpace(r.FormValue("username")),
Password: pickStr(r.FormValue("password"), a.SMTP.Password), // пароль в страницу не выводится: пусто — прежний
From: strings.TrimSpace(r.FormValue("from")),
To: a.SMTP.To, // адрес администратора системы — в «Кому писать» (notifyGroupsCard)
TestTo: smtpTestTo(r, a.SMTP),
LastMsg: a.SMTP.LastMsg,
LastAt: a.SMTP.LastAt,
}
if err := h.rc.UpdateAlerts(a); err != nil {
setupFlash(w, r, "SMTP: ошибка сохранения: "+err.Error())
return
}
setupFlash(w, r, "Настройки SMTP сохранены")
}
// smtpTestTo — адрес проверочного письма из формы «Почта»; совпадает с адресом администратора — не храним отдельно
// (поле показывает его по умолчанию), поля в запросе нет — прежний.
func smtpTestTo(r *http.Request, s SMTPSettings) string {
_ = r.ParseForm()
if _, ok := r.Form["test_to"]; !ok {
return s.TestTo
}
v := strings.TrimSpace(r.FormValue("test_to"))
if v == strings.TrimSpace(s.To) {
return ""
}
return v
}
// testAlertsSMTP шлёт пробное письмо по текущим (сохранённым) настройкам.
func (h *setupHandlers) testAlertsSMTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
cfg := h.rc.Snapshot()
a := cfg.Alerts
// кому: адрес из поля «Проверочное письмо — кому», иначе администратор системы, а пока его адреса нет —
// все адреса отделов из «Кому писать»
a.SMTP.TestTo = smtpTestTo(r, a.SMTP)
test := a.SMTP
test.To = pickStr(a.SMTP.TestTo, a.SMTP.To)
if strings.TrimSpace(test.To) == "" {
var all []string
for _, k := range notifyKinds {
all = append(all, cfg.Incoming.Recipients(k.Key))
}
test.To = mergeEmails(all...)
}
if strings.TrimSpace(test.To) == "" {
setupFlash(w, r, "Проверка SMTP: укажите, куда отправить проверочное письмо")
return
}
err := sendSMTP(test, "[bj] Проверка уведомлений", "Это проверочное письмо от bj-server. Если вы его получили — SMTP-канал оповещений работает.")
a.SMTP.LastAt = time.Now()
if err != nil {
a.SMTP.LastMsg = "ошибка: " + err.Error()
_ = h.rc.UpdateAlerts(a)
setupFlash(w, r, "Проверка SMTP: "+err.Error())
return
}
a.SMTP.LastMsg = "проверочное письмо отправлено на " + test.To
_ = h.rc.UpdateAlerts(a)
setupFlash(w, r, "Проверка SMTP: письмо отправлено на "+test.To)
}
// saveAlertsZabbix сохраняет настройки Zabbix-хука.
func (h *setupHandlers) saveAlertsZabbix(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
a := h.rc.Snapshot().Alerts
a.Zabbix = ZabbixSettings{
Enabled: r.FormValue("enabled") == "on",
Server: strings.TrimSpace(r.FormValue("server")),
Port: atoiDefault(r.FormValue("port"), 10051),
Host: strings.TrimSpace(r.FormValue("host")),
Key: nonEmpty(strings.TrimSpace(r.FormValue("key")), "bj.alert"),
LastMsg: a.Zabbix.LastMsg,
LastAt: a.Zabbix.LastAt,
}
if err := h.rc.UpdateAlerts(a); err != nil {
setupFlash(w, r, "Zabbix: ошибка сохранения: "+err.Error())
return
}
setupFlash(w, r, "Настройки Zabbix сохранены")
}
// testAlertsZabbix шлёт пробное значение на trapper-элемент Zabbix.
func (h *setupHandlers) testAlertsZabbix(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
a := h.rc.Snapshot().Alerts
info, err := sendZabbix(a.Zabbix, "bj: проверка хука оповещений")
a.Zabbix.LastAt = time.Now()
if err != nil {
a.Zabbix.LastMsg = "ошибка: " + err.Error()
_ = h.rc.UpdateAlerts(a)
setupFlash(w, r, "Проверка Zabbix: "+err.Error())
return
}
a.Zabbix.LastMsg = "принято: " + info
_ = h.rc.UpdateAlerts(a)
setupFlash(w, r, "Проверка Zabbix: сервер принял значение ("+info+")")
}
// saveIncomingNotify сохраняет настройки уведомлений депозитарию о входящих.
func (h *setupHandlers) saveIncomingNotify(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
// какие уведомления кому — только таблица: прежняя галочка «уведомления о переводах» — строка «Входящие»
s := IncomingNotifySettings{
Enabled: true,
Emails: strings.TrimSpace(r.FormValue("emails")),
EmailsBack: strings.TrimSpace(r.FormValue("emails_back")),
EmailsMiddle: strings.TrimSpace(r.FormValue("emails_middle")),
Routes: map[string]string{},
RoutesV: 2,
}
cur := h.rc.Snapshot()
for _, k := range notifyKinds { // скрытые в форме типы (модуль не в лицензии) — прежний маршрут
var gs []string
for _, g := range notifyGroups {
if cur.Incoming.RouteHas(k.Key, g.Key) {
gs = append(gs, g.Key)
}
}
s.Routes[k.Key] = strings.Join(gs, ",")
}
for _, k := range visibleNotifyKinds(h.rc) {
var gs []string
for _, g := range notifyGroups {
if r.FormValue("route_"+k.Key+"_"+g.Key) != "" {
gs = append(gs, g.Key)
}
}
s.Routes[k.Key] = strings.Join(gs, ",")
}
if err := h.rc.UpdateIncoming(s); err != nil {
setupFlash(w, r, "Уведомления: ошибка сохранения: "+err.Error())
return
}
// адрес администратора системы хранится в настройках почты (Alerts.SMTP.To) — им же писали всегда
if _, ok := r.Form["emails_admin"]; ok {
a := cur.Alerts
a.SMTP.To = strings.TrimSpace(r.FormValue("emails_admin"))
if err := h.rc.UpdateAlerts(a); err != nil {
setupFlash(w, r, "Уведомления: ошибка сохранения адреса администратора: "+err.Error())
return
}
}
setupFlash(w, r, "Адреса и маршруты уведомлений сохранены")
}
// testIncomingNotify шлёт пробное письмо-уведомление депозитарию по образцу
// реального входящего перевода.
func (h *setupHandlers) testIncomingNotify(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
cfg := h.rc.Snapshot()
if strings.TrimSpace(notifyRecipients(cfg, "incoming")) == "" {
setupFlash(w, r, "Проверка: сначала укажите адреса и отметьте в таблице, кому идут «Входящие переводы»")
return
}
smtp := cfg.Alerts.SMTP
if !smtp.Enabled || strings.TrimSpace(smtp.Host) == "" {
setupFlash(w, r, "Проверка: сначала настройте и включите SMTP (раздел «Почта»)")
return
}
sample := &IncomingTransfer{
GUID: "00000000-0000-0000-0000-000000000000",
SenderCode: "MC0000000000", SenderINN: "0000000000", // обезличенный образец
ReceivedAt: time.Now(),
Securities: []IncomingSecurity{{SecurityCode: "RU0009029540", ISIN: "RU0009029540", Quantity: "10"}},
}
smtp.To = notifyRecipients(cfg, "incoming")
if err := sendSMTP(smtp, "[ПРОВЕРКА] MOEX МОСТ (M2M): к вам переведены ценные бумаги — требуется учёт", buildIncomingEmailBody(sample)); err != nil {
setupFlash(w, r, "Проверка уведомления депозитарию: "+err.Error())
return
}
setupFlash(w, r, "Проверка: письмо-уведомление отправлено на "+notifyRecipients(cfg, "incoming"))
}
// lkPostJSON — POST JSON к API ЛК с Basic; возвращает (код, тело).
func lkPostJSON(u string, ep LKEndpoint, body []byte) (int, string) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, strings.NewReader(string(body)))
if err != nil {
return 0, err.Error()
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if ep.Login != "" {
req.SetBasicAuth(ep.Login, ep.Password)
}
resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req)
if err != nil {
return 0, "нет связи: " + err.Error()
}
defer resp.Body.Close()
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return resp.StatusCode, strings.TrimSpace(string(b))
}
func atoiDefault(s string, d int) int {
if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil {
return n
}
return d
}
func atofDefault(s string, d float64) float64 {
if f, err := strconv.ParseFloat(strings.TrimSpace(strings.ReplaceAll(s, ",", ".")), 64); err == nil {
return f
}
return d
}
// testRun запускает тестовую заявку с предустановленными данными,
// ждёт изменения статуса до confirmed/rejected/timed_out и сохраняет
// результат в RuntimeConfig.LastTest.
func (h *setupHandlers) testRun(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
go h.runTestClaim()
setupFlash(w, r, "Тестовая заявка запущена, обновите страницу через 5 секунд")
}
// robotScenarios — тестовые сценарии робота НРД (код → описание).
// Код сценария задаётся в DocumentSeries (серия ДУЛ инвестора).
var robotScenarios = map[string]string{
"2001": "Принять все бумаги",
"2002": "Принять бумаги частично",
"1111": "Ответ с отказом",
"3333": "Робот — принимающая сторона",
}
// NSDRobotCode — код робота-тестировщика НРД (получатель тестовых пакетов).
const NSDRobotCode = "MC0012500000"
// robotDocumentNumbers — DocumentNumber (номер ДУЛ) по сценарию, как в
// официальных образцах НРД (m2m_robot_samples). Пара DocumentSeries (=код
// сценария) + DocumentNumber выбирает конкретное поведение робота.
var robotDocumentNumbers = map[string]string{
"2001": "111111", // принять все бумаги (all_to_1st_depo)
"2002": "100000", // принять частично (accept1st)
"1111": "111101", // ошибочная ситуация (error01)
"3333": "100000", // робот — принимающая сторона (one_settle_depo, шаг 1)
}
// robotReferenceSecurity — одна бумага эталонного набора робота НРД.
// code идёт в SecurityCode (КОД НРД бумаги — может отличаться от ISIN!),
// isin — в SecurityDetails. frac=true для бумаг с дробным количеством (паи ПИФ):
// у них количество указывается в Quantity/Fractional, а не Whole (иначе M2M27
// «количество указано не в соответствующем поле»).
type robotReferenceSecurity struct {
code string
isin string
frac bool
}
// robotReferenceSecurities — АКТУАЛЬНЫЙ список тест-бумаг TEST3 (ответ НРД от
// 15.08.2026 на обращение по M2M43 «Ценная бумага не учитывается в НРД»).
// ВАЖНО: у Сбербанка Код НРД (SecurityCode) = "SBER/03" ≠ ISIN "RU0009029540";
// у паёв и облигаций код НРД совпадает с ISIN. Прежние бумаги (RU0007661625 и
// др.) сняты с учёта в TEST3 — давали M2M43.
var robotReferenceSecurities = []robotReferenceSecurity{
{code: "SBER/03", isin: "RU0009029540"}, // Акции ПАО Сбербанк (целое)
{code: "RU000A0JUR61", isin: "RU000A0JUR61", frac: true}, // Паи ИПИФ «КОНСЕРВАТОРия» — ДРОБНОЕ
{code: "RU000A102QL3", isin: "RU000A102QL3"}, // Облигации ВТБ СУБ-Т1-2 (целое)
}
// buildRobotSecurities собирает count бумаг из эталонного набора с НАШИМИ
// депо-реквизитами списания. count<=0 → весь набор. Если count больше набора,
// бумаги берутся циклически (ReferenceID у каждой всё равно уникальный).
// SettlementRequisites.INN эталонного набора робота (ключ 1). А само место
// расчётов (SettlementDepositoryLocation) — НАШ счёт депо (depCode/accID/sectID
// из конфига НРД): именно так был построен подтверждённый роботом запрос
// c662544e (22.06.2026), где DeponentCode=MC0413600000. Реквизиты робота
// (MC0012500000/HL2603250011) в место расчётов НЕ ставим.
const robotSettleReqINN = "7702165310"
func buildRobotSecurities(depCode, accID, sectID string, count int) []ClaimSec {
if count <= 0 {
count = len(robotReferenceSecurities)
}
out := make([]ClaimSec, 0, count)
for i := 0; i < count; i++ {
s := robotReferenceSecurities[i%len(robotReferenceSecurities)]
qty := Quantity{Whole: 1}
if s.frac {
qty = Quantity{Fractional: "1"} // паи — количество в дробном поле
}
out = append(out, ClaimSec{
SecurityCode: s.code,
SecurityDetails: SecurityDetails{ISIN: s.isin},
Quantity: qty,
SettlementAccounts: []SettlementAccount{{
SettlementRequisitesINN: robotSettleReqINN,
SettlementLocation: SettlementLocation{
DeponentCode: depCode, AccountID: accID, SectionID: sectID,
},
}},
})
}
return out
}
// testNSD — POST /admin/setup/test-nsd. Отправляет эталонный M2MTransferRequest
// роботу НРД (MC0012500000) с выбранным сценарием. Ответ робота приходит
// асинхронно во входящие ИШ и применяется поллером (статус заявки меняется).
func (h *setupHandlers) testNSD(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
scenario := r.FormValue("scenario")
if _, ok := robotScenarios[scenario]; !ok {
scenario = "2001"
}
// Количество бумаг в пакете (симулятор). По умолчанию — весь набор.
secCount, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("securities_count")))
if secCount < 1 {
secCount = 0 // 0 → весь эталонный набор
}
if secCount > 20 {
secCount = 20
}
// Отправляем синхронно, чтобы получить ID заявки и сразу открыть её карточку.
// Ответ робота придёт асинхронно — его подхватит поллер входящих ИШ.
claimID, err := h.runRobotTest(scenario, secCount)
if err != nil {
setupFlash(w, r, fmt.Sprintf("Сценарий %s «%s»: отправка роботу не удалась: %v",
scenario, robotScenarios[scenario], err))
return
}
msg := fmt.Sprintf("Пакет (сценарий %s «%s») отправлен роботу НРД %s. Ответ придёт через ~30-60 сек — обновите страницу.",
scenario, robotScenarios[scenario], NSDRobotCode)
http.Redirect(w, r, "/admin/claims/"+claimID+"?flash="+url.QueryEscape(msg), http.StatusSeeOther)
}
// runRobotTest формирует и синхронно отправляет эталонную заявку роботу НРД.
// Возвращает ID созданной заявки (для редиректа в карточку) и ошибку отправки.
// Сам ответ робота приходит асинхронно — его подхватывает поллер входящих ИШ.
// buildRobotClaimRequest формирует эталонную заявку роботу НРД (общий код для
// прямой отправки в шлюз и для отправки через БД Fancy).
func (h *setupHandlers) buildRobotClaimRequest(scenario string, secCount int) CreateClaimRequest {
nsd := h.rc.Snapshot().NSD
depCode := nsd.DeponentCode
accID := nsd.AccountID
sectID := nsd.SectionID
docNumber, ok := robotDocumentNumbers[scenario]
if !ok {
docNumber = robotDocumentNumbers["2001"]
}
return CreateClaimRequest{
Investor: Investor{
ID: "22222222-2222-2222-2222-222222222222",
LastName: "Толкин", FirstName: "Никита", MiddleName: "Анатольевич",
Document: Document{DocumentType: "21", Series: scenario, Number: docNumber},
},
TransferringDepositoryINN: "7703807489",
ReceivingDepositoryINN: "7722061076",
CostInfo: CostInfo{Yes: &CostInfoYes{Code: depCode}},
Securities: buildRobotSecurities(depCode, accID, sectID, secCount),
SignedDocument: "dGVzdC1zaWduYXR1cmU=",
SignatureFormat: "XMLDSig-GOST",
ReceiverCodeOverride: NSDRobotCode,
InvestorDocumentOverride: &Document{DocumentType: "21", Series: scenario, Number: docNumber},
}
}
func (h *setupHandlers) runRobotTest(scenario string, secCount int) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second)
defer cancel()
req := h.buildRobotClaimRequest(scenario, secCount)
res := TestRunResult{StartedAt: time.Now().UTC()}
created, err := h.svc.CreateClaim(ctx, req)
if err != nil {
res.FinishedAt = time.Now().UTC()
res.OK = false
res.FinalStatus = "send_failed"
res.Message = fmt.Sprintf("Сценарий %s: отправка роботу не удалась: %v", scenario, err)
_ = h.rc.RecordTestRun(res)
return "", err
}
res.ClaimID = created.ID
res.OK = true
res.FinalStatus = created.Status
res.Message = fmt.Sprintf("Сценарий %s «%s»: пакет отправлен роботу %s (GUID %s). Ждём ответ робота во входящих ИШ.",
scenario, robotScenarios[scenario], NSDRobotCode, created.ID)
res.FinishedAt = time.Now().UTC()
_ = h.rc.RecordTestRun(res)
return created.ID, nil
}
// saveLicense — POST /admin/setup/license. Сохраняет лицензионный ключ и
// сразу проверяет его (подпись + срок).
func (h *setupHandlers) saveLicense(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
cfg := h.rc.Snapshot().License
key := strings.Join(strings.Fields(r.FormValue("key")), "")
if key == "" {
setupFlash(w, r, "Лицензия: вставьте офлайн-ключ (пустое поле действующую лицензию не меняет)")
return
}
if _, err := verifyLicenseToken(key); err != nil {
setupFlash(w, r, "Лицензия: ключ не принят — подпись не сходится или ключ повреждён")
return
}
cfg.Key = key
if err := h.rc.SaveLicense(cfg); err != nil {
setupFlash(w, r, "лицензия: ошибка сохранения: "+err.Error())
return
}
st := licenseStatus(h.rc)
if st.Present && st.Valid {
setupFlash(w, r, fmt.Sprintf("Лицензия принята: %s, план %s, %s", st.Tenant, st.Plan, st.Message))
} else {
setupFlash(w, r, "Лицензия сохранена, но: "+st.Message)
}
}
// licenseActivate — POST /admin/setup/license/activate: активация установки кодом на сервере лицензий.
func (h *setupHandlers) licenseActivate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
code := strings.TrimSpace(r.FormValue("code"))
if code == "" {
setupFlash(w, r, "Лицензия: введите код активации")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second)
defer cancel()
if err := LicenseActivate(ctx, h.rc, code); err != nil {
setupFlash(w, r, "Лицензия: активация не удалась — "+err.Error())
return
}
// дальше — «Подготовка модулей»: модули лицензии ставятся и запускаются, затем возврат туда, откуда пришли
next := safeReturnPath(r.FormValue("return_to"))
if next == "" || strings.HasPrefix(next, "/admin/wizard") {
next = "/admin/wizard?step=12" // после ключа — «Время и сеть»: часовой пояс клиент выбирает сам
}
http.Redirect(w, r, "/admin/setup/prepare?next="+url.QueryEscape(next), http.StatusSeeOther)
}
// licenseRenewNow — POST /admin/setup/license/renew: продлить ключ сейчас.
func (h *setupHandlers) licenseRenewNow(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second)
defer cancel()
if err := LicenseRenew(ctx, h.rc); err != nil {
setupFlash(w, r, "Лицензия: "+err.Error())
return
}
setupFlash(w, r, "Лицензия: "+h.rc.Snapshot().License.LastMsg)
}
// saveUpdate — POST /admin/setup/update. Сохраняет настройки обновлений.
func (h *setupHandlers) saveUpdate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
cfg := h.rc.Snapshot().Update
was := updateChannel(cfg)
cfg.BaseURL = strings.TrimSpace(r.FormValue("base_url"))
cfg.Channel = strings.TrimSpace(r.FormValue("channel"))
cfg.PublicKey = strings.TrimSpace(r.FormValue("public_key"))
cfg.AutoCheck = r.FormValue("auto_check") != ""
if updateChannel(cfg) != was {
// Версии из прошлого канала к новому не относятся — забываем их до проверки.
cfg.Components, cfg.ComponentsChannel, cfg.Available, cfg.Notes = nil, "", "", ""
cfg.LastResult = "канал сменён на «" + updateChannel(cfg) + "» — нажмите «Проверить обновления»"
}
if err := h.rc.SaveUpdateSettings(cfg); err != nil {
setupFlash(w, r, "обновления: ошибка сохранения: "+err.Error())
return
}
setupFlash(w, r, "Настройки обновлений сохранены")
}
// checkUpdate — POST /admin/setup/update/check. Проверяет наличие обновления.
func (h *setupHandlers) checkUpdate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
st, err := NewUpdater(h.rc).CheckForUpdate(r.Context())
if r.Header.Get("X-BJ-Ajax") != "" { // раздел «Обновления» проверяет сам при открытии
msg := st.Message
if err != nil {
msg = err.Error()
}
jsonOut(w, map[string]any{"ok": err == nil, "has_update": st.HasUpdate, "available": st.Available, "msg": msg})
return
}
if err != nil {
setupFlash(w, r, "Проверка обновлений: "+err.Error())
return
}
setupFlash(w, r, "Проверка обновлений: "+st.Message)
}
// applyUpdate — POST /admin/setup/update/apply. Скачивает и применяет.
func (h *setupHandlers) applyUpdate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
// форма может прийти и как multipart (окно обновления) — иначе имена компонентов теряются
// и «Обновить» у одной строки превращается в «Обновить всё»
if err := r.ParseMultipartForm(1 << 20); err != nil {
_ = r.ParseForm()
}
names := r.Form["component"]
rc := h.rc
save := func(msg string, err error) string {
if err != nil {
msg = "Обновление не применено: " + err.Error()
} else {
msg = "Обновление: " + msg
}
log.Printf("lk-gateway: %s", msg)
cfg := rc.Snapshot().Update
cfg.LastResult, cfg.LastCheck = msg, time.Now().UTC()
_ = rc.SaveUpdateSettings(cfg)
return msg
}
// Обновление ядра — только в фоне: bj-server перезапустится раньше, чем придёт
// ответ установщика, и страница всё равно не дождётся. Модули обновляем на месте
// и показываем настоящий результат: иначе отказ («в канале нет релизов», нет
// лицензии, не сходится подпись) виден только строкой «Последняя проверка»,
// и со стороны выглядит так, будто кнопка ничего не делает.
if updateTouchesCore(rc, names) {
go func() { save(NewUpdater(rc).ApplyUpdate(context.Background(), names)) }()
what := "всех компонентов с новой версией"
if len(names) > 0 {
what = strings.Join(names, ", ")
}
setupFlash(w, r, "Обновление "+what+" запущено: файлы скачиваются и проверяются, затем перезапускаются службы. Итог — в строке «Последняя проверка» через минуту; при обновлении ядра страница временно не отвечает.")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
defer cancel()
setupFlash(w, r, save(NewUpdater(rc).ApplyUpdate(ctx, names)))
}
// uploadUpdate — POST /admin/setup/update/upload: пакет обновления файлом (закрытый контур).
// Файл сохраняется в каталог подготовки, дальше — в фоне, как и обновление по сети.
func (h *setupHandlers) uploadUpdate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 2<<30)
f, hdr, err := r.FormFile("bundle")
if err != nil {
setupFlash(w, r, "Обновление из файла: файл не получен ("+err.Error()+")")
return
}
defer f.Close()
if err := os.MkdirAll(updateStagingDir, 0o750); err != nil {
setupFlash(w, r, "Обновление из файла: каталог подготовки: "+err.Error())
return
}
dst := filepath.Join(updateStagingDir, fmt.Sprintf("upload-%d.zip", time.Now().UnixNano()))
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o640)
if err != nil {
setupFlash(w, r, "Обновление из файла: "+err.Error())
return
}
n, err := io.Copy(out, f)
out.Close()
if err != nil {
_ = os.Remove(dst)
setupFlash(w, r, "Обновление из файла: не удалось сохранить: "+err.Error())
return
}
rc := h.rc
go func() {
msg, err := NewUpdater(rc).ApplyBundle(context.Background(), dst)
cfg := rc.Snapshot().Update
if err != nil {
msg = "Обновление из файла не применено: " + err.Error()
} else {
msg = "Обновление: " + msg
}
log.Printf("lk-gateway: %s", msg)
cfg.LastResult, cfg.LastCheck = msg, time.Now().UTC()
_ = rc.SaveUpdateSettings(cfg)
}()
setupFlash(w, r, fmt.Sprintf("Файл %s (%d МБ) принят: проверяется подпись, затем ставятся компоненты новее текущих — ядро, потом модули. Итог — в строке «Последняя проверка» через минуту; при обновлении ядра страница временно не отвечает.", hdr.Filename, n>>20))
}
// runTestClaim делает CreateClaim + ждёт финального состояния через GetClaim.
func (h *setupHandlers) runTestClaim() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
whole := uint64(1500)
req := CreateClaimRequest{
Investor: Investor{
ID: "11111111-1111-1111-1111-111111111111",
LastName: "Иванов", FirstName: "Иван", MiddleName: "Иванович",
Document: Document{DocumentType: "21", Series: "4512", Number: "654321"},
},
TransferringDepositoryINN: "0702345678",
ReceivingDepositoryINN: "0710987654",
CostInfo: CostInfo{No: &struct{}{}},
IIAAgreement: &IIAAgreement{
AgreementType: "T03", AgreementNumber: "ИИС78/2024",
AgreementDate: "2026-01-15", BrokerINN: "0707083893",
},
Securities: []ClaimSec{
{
SecurityCode: "MM0766162534",
SecurityDetails: SecurityDetails{ISIN: "RU0007661625"},
Quantity: Quantity{Whole: whole},
SettlementAccounts: []SettlementAccount{
{
SettlementRequisitesINN: "7702070139",
SettlementLocation: SettlementLocation{
DeponentCode: "DP789456", AccountID: "31MC0021900000F01", SectionID: "P001",
},
},
},
},
},
SignedDocument: "dGVzdC1zaWduYXR1cmU=",
SignatureFormat: "XMLDSig-GOST",
}
res := TestRunResult{StartedAt: time.Now().UTC()}
created, err := h.svc.CreateClaim(ctx, req)
if err != nil {
res.FinishedAt = time.Now().UTC()
res.OK = false
res.Message = "CreateClaim упал: " + err.Error()
_ = h.rc.RecordTestRun(res)
return
}
res.ClaimID = created.ID
// Опрашиваем статус каждые 200ms до перехода в финал.
deadline := time.Now().Add(25 * time.Second)
for time.Now().Before(deadline) {
view, err := h.svc.GetClaim(ctx, created.ID)
if err != nil {
res.FinishedAt = time.Now().UTC()
res.OK = false
res.FinalStatus = "lookup_failed"
res.Message = err.Error()
_ = h.rc.RecordTestRun(res)
return
}
switch view.Status {
case "confirmed", "awaiting_sub16", "done":
res.FinishedAt = time.Now().UTC()
res.OK = true
res.FinalStatus = view.Status
res.Message = "Заявка подтверждена принимающей стороной (mock или реальный НРД)."
_ = h.rc.RecordTestRun(res)
return
case "rejected", "timed_out":
res.FinishedAt = time.Now().UTC()
res.OK = false
res.FinalStatus = view.Status
res.Message = "Заявка не прошла: статус " + view.Status
_ = h.rc.RecordTestRun(res)
return
}
time.Sleep(200 * time.Millisecond)
}
res.FinishedAt = time.Now().UTC()
res.OK = false
res.FinalStatus = "timeout_waiting"
res.Message = "Не дождались финального статуса за 25 сек (mock-задержка обычно 3 сек; проверьте лог lk-gateway)"
_ = h.rc.RecordTestRun(res)
}
// tryPingPostgres делает короткое подключение через pgx и Ping.
func tryPingPostgres(dsn string) error {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
conn, err := pgx.Connect(ctx, dsn)
if err != nil {
return err
}
defer conn.Close(ctx)
return conn.Ping(ctx)
}
// tryHTTPHealth делает GET и ждёт 2xx.
func tryHTTPHealth(u string) error {
c := &http.Client{Timeout: 3 * time.Second}
resp, err := c.Get(u)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("HTTP %d", resp.StatusCode)
}
return nil
}
// setupFlash шлёт 303 с flash-сообщением в query. Если запрос пришёл
// с какой-то «принимающей flash» страницы (/admin/wizard, /admin/news,
// /admin/setup) — возвращаем туда же. Иначе дефолт — /admin/setup.
// Это нужно чтобы пользователь не «выпадал» из текущего контекста после
// POST-действия (нажал кнопку «Проверить обновления» в Новостях — должен
// остаться в Новостях со флешем).
func setupFlash(w http.ResponseWriter, r *http.Request, msg string) {
// Куда вернуться: на ту же страницу, где была форма. Берём из скрытого поля
// return_to (его проставляет JS из location.pathname) — это надёжнее Referer,
// который браузер по plain-HTTP/политике может не слать. Фолбэки: Referer,
// затем /admin/setup.
dest := safeReturnPath(r.FormValue("return_to"))
if dest == "" {
if ref := r.Header.Get("Referer"); ref != "" {
if u, err := url.Parse(ref); err == nil && safeReturnPath(u.Path) != "" {
dest = u.Path
}
}
}
if dest == "" {
dest = "/admin/setup"
}
q := url.Values{}
q.Set("flash", msg)
// Секция (id карточки) — через ?scrollto=, JS прокрутит к ней и уберёт параметр.
if s := r.FormValue("section"); s != "" && isAnchorName(s) {
q.Set("scrollto", s)
}
sep := "?"
if strings.Contains(dest, "?") {
sep = "&"
}
http.Redirect(w, r, dest+sep+q.Encode(), http.StatusSeeOther)
}
// safeReturnPath пропускает только наши же admin-пути ("/admin/..."), без
// схемы/хоста и без "//" — защита от open-redirect. Пусто, если путь небезопасен.
func safeReturnPath(p string) string {
if p == "" || len(p) > 256 {
return ""
}
if !strings.HasPrefix(p, "/admin") {
return ""
}
if strings.HasPrefix(p, "//") || strings.ContainsAny(p, ":\\\r\n") {
return ""
}
if i := strings.IndexByte(p, '#'); i >= 0 {
p = p[:i]
}
// путь и безопасные параметры (например ?step=4&sub=done в мастере); flash/scrollto — свежие
path, query, _ := strings.Cut(p, "?")
var keep []string
for _, kv := range strings.Split(query, "&") {
k, v, ok := strings.Cut(kv, "=")
if !ok || k == "flash" || k == "scrollto" || !safeQueryPart.MatchString(k) || !safeQueryPart.MatchString(v) {
continue
}
keep = append(keep, k+"="+v)
}
if len(keep) > 0 {
return path + "?" + strings.Join(keep, "&")
}
return path
}
var safeQueryPart = regexp.MustCompile(`^[A-Za-z0-9_-]{1,40}$`)
// isAnchorName пропускает только безопасные id-якоря (буквы/цифры/-/_),
// чтобы не дать заголовкам ответа инжектить произвольное.
func isAnchorName(s string) bool {
if s == "" || len(s) > 64 {
return false
}
for _, c := range s {
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_') {
return false
}
}
return true
}
// _q извлекает Request из ResponseWriter trick — здесь не нужно
// (всегда работаем через chain).
func _q(_ http.ResponseWriter) string { return "" }
func errMsgFromQuery(_ string) string { return "" }
// restartCrypto — POST /admin/setup/restart-crypto. Отправляет
// gRPC.Shutdown сайдкару — он завершится с exit(2), systemd
// поднимет его через RestartSec=5.
func (h *setupHandlers) restartCrypto(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
s := h.rc.Snapshot()
cli := cryptocli.New(cryptocli.Config{
Provider: cryptocli.Provider(s.Crypto.Provider),
SocketPath: s.Crypto.SocketPath,
})
defer cli.Close()
if err := cli.Shutdown(r.Context()); err != nil {
// Сайдкар мог уже закрыть соединение до отправки — это нормально.
log.Printf("restartCrypto: Shutdown: %v (ожидаемо, если уже закрылся)", err)
}
setupFlash(w, r, "Проверка ключа подписи перезапускается — через 10 секунд обновите страницу.")
}
// restartServer — POST /admin/setup/restart-server. Делает os.Exit(2)
// через короткую задержку, systemd поднимает bj-server обратно.
func (h *setupHandlers) restartServer(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
setupFlash(w, r, "bj-server остановлен. systemd поднимет его обратно через ~5 секунд. Через 7-10 секунд обновите страницу.")
// Даём 500мс чтобы редирект и flash доставились браузеру.
go func() {
time.Sleep(500 * time.Millisecond)
log.Printf("restartServer: os.Exit(2) по запросу из UI")
os.Exit(2)
}()
}
var _ = errors.New