Files
Bridge-and-Join-s/internal/lkgateway/setup.go
T
zuevav 2a7442c935 Кабинет клиента: документы и корпоративные действия, дистрибутив, обратный канал, защита
Данные
- корпоративные действия по бумагам клиента (U_CA_ACTS), готовые выписки (U_OP_INF_EXTRACT),
  заявки клиента на документ (lk.doc_requests) с оповещением сотрудникам; страница «Документы»
- дистрибутив кабинета из настроек ядра: архив с программой, службой, настройками,
  установщиком и инструкцией; ключ доступа на странице показан замаскированным

Связь без публичного адреса у ядра
- режим ожидания (BJ_MODE=agent): кабинет копит запросы, ядро само подключается к нему,
  выполняет их обработчиком /api/lk/* и возвращает ответы; 4 параллельных подключения,
  тот же ключ модуля в обе стороны, состояние канала в настройках
- сквозной тест: настоящий кабинет + настоящий цикл ядра

Защита (по итогам аудита)
- вход: лимит попыток по адресу и логину, одинаковое время ответа для несуществующего логина,
  пароль от 10 символов
- сессии ядра: на диске отпечаток токена и ссылка на учётную запись, без персональных данных;
  карточка клиента читается из БД на каждый запрос (блокировка действует сразу); абсолютный
  срок 24 ч; привязка к браузеру, к сети — по настройке
- вход сотрудника в кабинет клиента пишется в lk.audit и поднимает оповещение
- кабинет: CSRF на всех формах, /impersonate только POST, куки HttpOnly+Secure+SameSite=Strict,
  выход гасит все сессии, заголовки CSP/X-Frame-Options/nosniff/HSTS, no-store, таймауты сервера,
  ограничение тела, recover; сообщения через куку вместо адреса; /health наружу без подробностей
- шрифты вшиты в программу: страница не обращается к сторонним сайтам
- экранирование LIKE в выборках по номеру договора; ключ и токен без суррогата при отказе rand
- исправлено: ошибка входа не показывалась клиенту (шаблон ждал Error, приходил Flash)

Тесты: lksecurity_test, lkagent_test, lkdist_test, modules/lk (CSRF, заголовки, режим ожидания),
живые lkcoverage/lkunpriced, scripts/lk-sec-smoke.sh — проверка работающего кабинета снаружи.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 23:47:10 +03:00

1504 lines
62 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/base64"
"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"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/cryptocli"
)
// crypto-сертификаты на текущих токенах (для отображения на странице).
// В gRPC-режиме сертификатами управляет сам сайдкар через профиль
// Валидаты — здесь временно возвращаем пустой список, пока
// crypto-service не получит метод ListCertificates.
func (h *setupHandlers) listCertsForUI() []cryptocli.Certificate {
s := h.rc.Snapshot()
if s.Crypto.Provider == "" || s.Crypto.Provider == "stub" {
return nil
}
cli := cryptocli.New(cryptocli.Config{
Provider: cryptocli.Provider(s.Crypto.Provider),
SocketPath: s.Crypto.SocketPath,
ModulePath: s.Crypto.ModulePath,
})
defer cli.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
certs, _ := cli.FindCertificates(ctx)
return certs
}
// 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}
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)
mux.HandleFunc("/admin/setup/postgres/quick-start", h.quickStartPostgres)
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/license", h.saveLicense)
mux.HandleFunc("/admin/setup/license/activate", h.licenseActivate)
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/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-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/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)
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)
}
// 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
Certs []cryptocli.Certificate
Flash string
Done struct {
Postgres bool
Crypto bool
Certs bool
NSD bool
TestRun 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,
Certs: h.listCertsForUI(),
Flash: r.URL.Query().Get("flash"),
}
d.Done.Postgres = s.Postgres.DSN != ""
d.Done.Crypto = s.Crypto.Provider != "" && s.Crypto.Provider != "stub" && s.Crypto.ModulePath != ""
d.Done.Certs = len(d.Certs) > 0
d.Done.NSD = s.NSD.IGWBaseURL != "" && s.NSD.Profile != ""
d.Done.TestRun = s.LastTest != nil || (!d.DevMode && d.Done.NSD) // вне режима разработки тестовой заявки нет
// Определяем текущий шаг.
step := 1
if v := r.URL.Query().Get("step"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 1 && n <= 5 {
step = n
}
} else {
// Авто: первый незавершённый.
switch {
case !d.Done.Postgres:
step = 1
case !d.Done.Crypto:
step = 2
case !d.Done.Certs:
step = 3
case !d.Done.NSD:
step = 4
default:
step = 5
}
}
d.Step = step
render(w, h.tpl.a.wizard, d)
}
// 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()
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)
}
b64Preview := base64.StdEncoding.EncodeToString(sig)
if len(b64Preview) > 80 {
b64Preview = b64Preview[:80] + "..."
}
setupFlash(w, r, fmt.Sprintf("Пробная подпись OK: CMS detached, %d байт. Сохранена в %s. Префикс base64: %s",
len(sig), outPath, b64Preview))
}
// SetupData — данные для шаблона admin_setup.html.
type SetupData struct {
page
Settings Settings
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 // ключ кабинета в маскированном виде
Versions SystemVersions // версии компонентов bj и внешних систем
Modules map[string]bool // какие лицензируемые модули разрешены (ключ = признак)
Features []LicenseFeatureView // признаки лицензии для карточки
Users UsersView // пользователи, роли, сессии
Flash string
Error string
}
// HealthLabel — человекочитаемое имя компонента для UI.
func HealthLabel(name string) string {
switch name {
case "poll-lk":
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"
}
return name
}
func (h *setupHandlers) renderSetup(w http.ResponseWriter, req *http.Request, flash string) {
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,
Readiness: r,
ReadyCount: ready,
TotalCount: len(r),
Certificates: h.listCertsForUI(),
CurrentVersion: BuildVersion,
License: licenseStatus(h.rc),
Health: HealthSnapshot(),
AdvancedUnlocked: advancedUnlocked(req),
NSDCerts: nsdCertsView(s),
Participants: handbookParticipants(),
OrgINN: orgINN(s),
OrgName: orgName(s),
OrgCode: orgCode(s),
NotifyGroups: notifyGroups,
NotifyKinds: notifyKinds,
DepoJob: depoJobView(s.Fancy),
DepoTables: depoTablesView(s.Fancy),
QuikExport: quikExportView(h.rc, req.URL.Query().Get("reveal_ro") == "1"),
LKAgent: lkAgentStatus(s),
LKKeyMask: maskSecret(s.LK.APIKey),
Versions: systemVersions(h.rc),
Modules: modulesAllowed(h.rc),
Features: licenseFeatureViews(h.rc),
Users: usersView(w),
Flash: flash,
}
if errVal := errMsgFromQuery(_q(w)); errVal != "" {
data.Error = errVal
}
render(w, h.tpl.a.setup, 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",
}
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, "nsd: ИШ не отвечает (engine/state): "+err.Error())
return
}
}
if err := h.rc.UpdateNSD(s); err != nil {
setupFlash(w, r, "nsd: ошибка сохранения: "+err.Error())
return
}
setupFlash(w, r, "nsd-adapter: настройки сохранены")
}
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: r.FormValue("password"),
From: strings.TrimSpace(r.FormValue("from")),
To: strings.TrimSpace(r.FormValue("to")),
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 сохранены")
}
// testAlertsSMTP шлёт пробное письмо по текущим (сохранённым) настройкам.
func (h *setupHandlers) testAlertsSMTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
a := h.rc.Snapshot().Alerts
err := sendSMTP(a.SMTP, "[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 = "проверочное письмо отправлено"
_ = h.rc.UpdateAlerts(a)
setupFlash(w, r, "Проверка SMTP: письмо отправлено на "+a.SMTP.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: r.FormValue("enabled") == "on",
Emails: strings.TrimSpace(r.FormValue("emails")),
EmailsBack: strings.TrimSpace(r.FormValue("emails_back")),
EmailsMiddle: strings.TrimSpace(r.FormValue("emails_middle")),
Routes: map[string]string{},
}
for _, k := range notifyKinds {
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
}
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(cfg.Incoming.Recipients("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 = cfg.Incoming.Recipients("incoming")
if err := sendSMTP(smtp, "[ПРОВЕРКА] MOEX МОСТ (M2M): к вам переведены ценные бумаги — требуется учёт", buildIncomingEmailBody(sample)); err != nil {
setupFlash(w, r, "Проверка уведомления депозитарию: "+err.Error())
return
}
setupFlash(w, r, "Проверка: письмо-уведомление отправлено на "+cfg.Incoming.Recipients("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
}
st := licenseStatus(h.rc)
setupFlash(w, r, fmt.Sprintf("Лицензия активирована: %s, план %s, %s", st.Tenant, st.Plan, st.Message))
}
// 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
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 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 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
}
_ = r.ParseForm()
names := r.Form["component"]
rc := h.rc
// В фоне: при обновлении ядра bj-server перезапустится раньше, чем придёт ответ установщика.
go func() {
msg, err := NewUpdater(rc).ApplyUpdate(context.Background(), names)
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)
}()
what := "всех компонентов с новой версией"
if len(names) > 0 {
what = strings.Join(names, ", ")
}
setupFlash(w, r, "Обновление "+what+" запущено: файлы скачиваются и проверяются, затем перезапускаются службы. Итог — в строке «Последняя проверка» через минуту; при обновлении ядра страница временно не отвечает.")
}
// 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)
}
http.Redirect(w, r, dest+"?"+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 ""
}
// только путь, без query/fragment
if i := strings.IndexAny(p, "?#"); i >= 0 {
p = p[:i]
}
return p
}
// 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, "crypto-service остановлен. systemd поднимет его обратно через ~5 секунд. Через 7-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