Files
Bridge-and-Join-s/internal/lkgateway/setup.go
T
zuevav 9737c787f9 feat: живой цикл M2M с НРД + мастер установки ключа на флешку
Инфраструктура M2M (живой обмен с НРД через ИШ):
- обработка M2MTransferResponse: ERROR(M2Mxx) → заявка Отклонена, сохранение
  ответа; INFO → ждём Decision; идемпотентность поллера
- fallback-корреляция ответов с нулевым GUID (M2M14/M2M17) по FIFO
- сырой XML ответа НРД в карточке заявки (для пересылки в ТП)
- тестовый пакет роботу приведён к эталону m2m_robot_samples (CostInfo=Yes,
  4 бумаги, IsolationStatus, DocumentSeries=сценарий); override паспорта
- редирект из теста сразу в карточку заявки

Мастер установки ключа Валидаты на флешку (admin/setup/keywizard):
- пошаговый: загрузка .7z+пароль → выбор флешки → запись → справочник
  сертификатов (CRL) → перезапуск+проверка ИШ → готово
- привилегированный воркер (bj-keymedia) в host-namespace через файл-обмен,
  bj-server остаётся в песочнице
- сохранение структуры профиля архива (spr<N>), перечисление съёмных USB

Прочее:
- пакет-доказательство для ТП НРД + форма регистрации участника M2M
- эталонные образцы робота (DOC/m2m_robot_samples)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 00:03:21 +03:00

1160 lines
46 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"
"errors"
"fmt"
"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)
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/nsd", h.saveNSD)
mux.HandleFunc("/admin/setup/lk", h.saveLK)
mux.HandleFunc("/admin/setup/test-run", h.testRun)
// Авто-загрузка сертификатов УЦ НРД и нашего УЦ.
mux.HandleFunc("/admin/setup/cacerts", h.saveCACerts)
mux.HandleFunc("/admin/setup/cacerts/fetch", h.fetchCACertsNow)
// Носители ключей (USB-флешки, загруженные ISO).
mux.HandleFunc("/admin/setup/media/iso/upload", h.uploadISO)
mux.HandleFunc("/admin/setup/media/iso/unmount", h.unmountISO)
mux.HandleFunc("/admin/setup/media/import-container", h.importContainer)
mux.HandleFunc("/admin/setup/media/import-profile", h.importProfile)
mux.HandleFunc("/admin/setup/media/activate-profile", h.activateProfile)
mux.HandleFunc("/admin/setup/media/delete-profile", h.deleteProfile)
mux.HandleFunc("/admin/setup/media/delete-container", h.deleteContainer)
// Перезапуск сервисов из 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()
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
// Определяем текущий шаг.
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
Media []Medium // USB-флешки и распакованные ISO
ImportedContainers []KeyContainer // уже импортированные контейнеры
ImportedProfiles []string // уже импортированные профили Валидаты
CurrentVersion string // версия bj-server (для раздела «Обновления»)
License LicenseStatus // статус лицензии
Flash string
Error string
}
func (h *setupHandlers) renderSetup(w http.ResponseWriter, _ *http.Request, flash string) {
s := h.rc.Snapshot()
r := h.rc.ReadinessSummary()
ready := 0
for _, x := range r {
if x.Configured {
ready++
}
}
data := SetupData{
page: nowPage("Настройка", "setup"),
Settings: s,
Readiness: r,
ReadyCount: ready,
TotalCount: len(r),
Certificates: h.listCertsForUI(),
Media: ScanMedia(),
ImportedContainers: ListImportedContainers(),
ImportedProfiles: ListImportedProfiles(),
CurrentVersion: BuildVersion,
License: licenseStatus(h.rc),
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")),
DeponentCode: strings.TrimSpace(r.FormValue("deponent_code")),
AccountID: strings.TrimSpace(r.FormValue("account_id")),
SectionID: strings.TrimSpace(r.FormValue("section_id")),
}
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
}
s := LKSettings{CallbackURL: strings.TrimSpace(r.FormValue("callback_url"))}
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 в ЛК сохранён и применён")
}
// 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 — одна бумага из эталонного набора робота НРД:
// ISIN и ИНН расчётного депозитария (SettlementRequisites). Набор одинаков
// во всех сценариях; робот различает их по ReferenceId (генерируется нами).
type robotReferenceSecurity struct {
isin string
inn string
}
// robotReferenceSecurities — эталонный набор из 4 бумаг (m2m_robot_samples):
// две через расчётный депозитарий 7702165310, две — через 7831000034.
var robotReferenceSecurities = []robotReferenceSecurity{
{isin: "RU0007661625", inn: "7702165310"},
{isin: "RU000A0JP5V6", inn: "7702165310"},
{isin: "RU000A0JPKH7", inn: "7831000034"},
{isin: "RU000A0JPGP8", inn: "7831000034"},
}
// buildRobotSecurities собирает эталонный набор бумаг с НАШИМИ депо-реквизитами
// списания (из настроек НРД) в SettlementLocation.
func buildRobotSecurities(depCode, accID, sectID string) []ClaimSec {
out := make([]ClaimSec, 0, len(robotReferenceSecurities))
for _, s := range robotReferenceSecurities {
out = append(out, ClaimSec{
SecurityCode: s.isin, // эталон робота: SecurityCode = ISIN
SecurityDetails: SecurityDetails{ISIN: s.isin},
Quantity: Quantity{Whole: 1},
SettlementAccounts: []SettlementAccount{{
SettlementRequisitesINN: s.inn,
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"
}
// Отправляем синхронно, чтобы получить ID заявки и сразу открыть её карточку.
// Ответ робота придёт асинхронно — его подхватит поллер входящих ИШ.
claimID, err := h.runRobotTest(scenario)
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 созданной заявки (для редиректа в карточку) и ошибку отправки.
// Сам ответ робота приходит асинхронно — его подхватывает поллер входящих ИШ.
func (h *setupHandlers) runRobotTest(scenario string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second)
defer cancel()
// Депо-реквизиты клиента из настроек (откуда списываются бумаги).
// Если не заданы — подставляем плейсхолдеры (робот отвергнет по формату,
// но запрос уйдёт; реальные значения вводятся в админке → раздел НРД).
nsd := h.rc.Snapshot().NSD
depCode := nsd.DeponentCode
accID := nsd.AccountID
sectID := nsd.SectionID
// DocumentNumber по сценарию (эталон m2m_robot_samples). Неизвестный
// сценарий — дефолт 2001.
docNumber, ok := robotDocumentNumbers[scenario]
if !ok {
docNumber = robotDocumentNumbers["2001"]
}
req := CreateClaimRequest{
Investor: Investor{
ID: "22222222-2222-2222-2222-222222222222",
LastName: "Толкин", FirstName: "Никита", MiddleName: "Анатольевич",
// Series = код сценария робота, Number = вариант теста (эталон НРД).
Document: Document{DocumentType: "21", Series: scenario, Number: docNumber},
},
TransferringDepositoryINN: "7702165310",
ReceivingDepositoryINN: "7722061076", // принимающий депозитарий из набора робота
// Эталон робота: CostInfo=Yes с НАШИМ депкодом (код передачи налоговой
// информации сверяется со справочником участников M2M).
CostInfo: CostInfo{Yes: &CostInfoYes{Code: depCode}},
Securities: buildRobotSecurities(depCode, accID, sectID),
SignedDocument: "dGVzdC1zaWduYXR1cmU=",
SignatureFormat: "XMLDSig-GOST",
ReceiverCodeOverride: NSDRobotCode, // отправляем роботу, не дефолтному получателю
// Серия ДУЛ = код сценария, номер = вариант теста. Иначе паспорт
// берётся из анкеты и робот не распознаёт сценарий.
InvestorDocumentOverride: &Document{DocumentType: "21", Series: scenario, Number: docNumber},
}
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
cfg.Key = strings.TrimSpace(r.FormValue("key"))
if pk := strings.TrimSpace(r.FormValue("public_key")); pk != "" {
cfg.PublicKey = pk
}
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)
}
}
// 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
}
if err := NewUpdater(h.rc).ApplyUpdate(r.Context()); err != nil {
setupFlash(w, r, "Обновление не применено: "+err.Error())
return
}
setupFlash(w, r, "Обновление скачано и применено — bj-server перезапускается, обновите страницу через 10 секунд")
}
// 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) {
if ref := r.Header.Get("Referer"); ref != "" {
if u, err := url.Parse(ref); err == nil {
for _, prefix := range []string{"/admin/wizard", "/admin/news", "/admin/setup"} {
if strings.HasPrefix(u.Path, prefix) {
q := u.Query()
q.Set("flash", msg)
http.Redirect(w, r, u.Path+"?"+q.Encode(), http.StatusSeeOther)
return
}
}
}
}
http.Redirect(w, r, "/admin/setup?flash="+url.QueryEscape(msg), http.StatusSeeOther)
}
// _q извлекает Request из ResponseWriter trick — здесь не нужно
// (всегда работаем через chain).
func _q(_ http.ResponseWriter) string { return "" }
func errMsgFromQuery(_ string) string { return "" }
// uploadISO — POST /admin/setup/media/iso/upload (multipart).
// Принимает .iso или .img файл, сохраняет в /var/lib/bj/iso/<timestamp>-<name>,
// распаковывает через 7z в /var/lib/bj/media/iso/<id>/.
func (h *setupHandlers) uploadISO(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
// Лимит 500 МБ.
if err := r.ParseMultipartForm(500 << 20); err != nil {
setupFlash(w, r, "Образ: ошибка чтения формы: "+err.Error())
return
}
file, header, err := r.FormFile("iso")
if err != nil {
setupFlash(w, r, "Образ: выберите файл .iso или .img")
return
}
defer file.Close()
lower := strings.ToLower(header.Filename)
if !(strings.HasSuffix(lower, ".iso") || strings.HasSuffix(lower, ".img") ||
strings.HasSuffix(lower, ".zip") || strings.HasSuffix(lower, ".7z")) {
setupFlash(w, r, "Образ: расширение файла должно быть .iso, .img, .zip или .7z")
return
}
password := r.FormValue("password")
isoDir := "/var/lib/bj/iso"
if err := os.MkdirAll(isoDir, 0o755); err != nil {
setupFlash(w, r, "Образ: не удалось создать "+isoDir+": "+err.Error())
return
}
safeName := filepath.Base(header.Filename)
dst := filepath.Join(isoDir, time.Now().UTC().Format("20060102-150405-")+safeName)
out, err := os.Create(dst)
if err != nil {
setupFlash(w, r, "Образ: запись: "+err.Error())
return
}
if _, err := io.Copy(out, file); err != nil {
out.Close()
_ = os.Remove(dst)
setupFlash(w, r, "Образ: запись файла: "+err.Error())
return
}
out.Close()
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
defer cancel()
m, err := ExtractISO(ctx, dst, password)
if err != nil {
setupFlash(w, r, "Образ: распаковка: "+err.Error())
return
}
setupFlash(w, r, fmt.Sprintf("Образ «%s» загружен и распакован: %d контейнер(ов), %d сертификат(ов)",
safeName, len(m.Containers), len(m.Certificates)))
}
// unmountISO — POST /admin/setup/media/iso/unmount. Удаляет распакованную ISO.
func (h *setupHandlers) unmountISO(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
id := strings.TrimSpace(r.FormValue("id"))
if id == "" {
setupFlash(w, r, "Образ: не указан id")
return
}
if err := UnmountISO(id); err != nil {
setupFlash(w, r, "Образ: удаление не удалось — "+err.Error())
return
}
setupFlash(w, r, "Образ удалён полностью: распаковка из /var/lib/bj/media/iso/"+id+" и исходный файл .img/.iso из /var/lib/bj/iso/.")
}
// importContainer — POST /admin/setup/media/import-container.
// Принимает path — путь до директории с *.key. Копирует в
// /var/lib/bj/containers/<name>/, при успехе зовёт crypto-service для
// импорта контейнера в Валидата-профиль (если сайдкар запущен).
func (h *setupHandlers) importContainer(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
src := strings.TrimSpace(r.FormValue("path"))
if src == "" {
setupFlash(w, r, "Импорт контейнера: не указан path")
return
}
// Защита: путь должен быть внутри известных media-roots.
if !allowedMediaPath(src) {
setupFlash(w, r, "Импорт контейнера: путь вне допустимых зон (USB/ISO)")
return
}
dst, err := ImportKeyContainer(src)
if err != nil {
setupFlash(w, r, "Импорт контейнера: "+err.Error())
return
}
setupFlash(w, r, "Контейнер скопирован в "+dst+". Перезапустите bj-crypto с BJ_VALIDATA_PROFILE — Валидата подхватит контейнер.")
}
// importProfile — POST /admin/setup/media/import-profile.
// Параметры: root (путь до корня профиля), name (произвольное имя).
// Копирует pse/gdbm/vdkeys в /var/lib/bj/profiles/<name>/.
func (h *setupHandlers) importProfile(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
root := strings.TrimSpace(r.FormValue("root"))
name := strings.TrimSpace(r.FormValue("name"))
if root == "" {
setupFlash(w, r, "Импорт профиля: не указан root")
return
}
if !allowedMediaPath(root) {
setupFlash(w, r, "Импорт профиля: путь вне допустимых зон (USB/ISO)")
return
}
res, err := ImportProfile(root, name)
if err != nil {
setupFlash(w, r, "Импорт профиля: "+err.Error())
return
}
profileName := filepath.Base(res.Path)
if res.ConfWritten {
setupFlash(w, r, "Профиль «"+profileName+"» импортирован: "+res.Path+
". Секция дописана в /opt/Validata/VDCSP/etc/pki1.conf. "+
"Теперь нажмите «Активировать» в таблице ниже — Валидата переключится на профиль БЕЗ перезапуска сайдкара.")
} else {
setupFlash(w, r, "Профиль «"+profileName+"» импортирован: "+res.Path+
". НО в pki1.conf дописать не удалось ("+res.ConfWriteError+"). "+
"Готовая секция лежит в "+res.Path+"/pki1.conf-section.txt — "+
"допишите её через sudo, потом нажмите «Активировать».")
}
}
// activateProfile — POST /admin/setup/media/activate-profile.
// Параметр: name — имя профиля из pki1.conf. Делает gRPC-вызов
// crypto-service.Activate(name) — без рестарта сайдкара. После
// успеха сохраняет имя в runtime-конфиге.
func (h *setupHandlers) activateProfile(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
name := strings.TrimSpace(r.FormValue("name"))
s := h.rc.Snapshot()
cli := cryptocli.New(cryptocli.Config{
Provider: cryptocli.Provider(s.Crypto.Provider),
SocketPath: s.Crypto.SocketPath,
})
defer cli.Close()
res, err := cli.Activate(r.Context(), name)
if err != nil {
setupFlash(w, r, "Активация: "+err.Error())
return
}
if !res.OK {
setupFlash(w, r, "Активация не удалась: "+res.Message)
return
}
// Сохраняем активный профиль в runtime-конфиге (для UI и для
// автозагрузки при следующем рестарте сайдкара).
cur := h.rc.Snapshot().Crypto
cur.Profile = name
if err := h.rc.UpdateCrypto(cur); err != nil {
log.Printf("activateProfile: UpdateCrypto: %v", err)
}
if name == "" {
setupFlash(w, r, "Профиль сброшен — Валидата в minimal-режиме. "+res.Message)
} else {
setupFlash(w, r, "Профиль «"+name+"» активирован: "+res.Message)
}
}
// deleteContainer — POST /admin/setup/media/delete-container.
// Параметр: name — имя контейнера в /var/lib/bj/containers/.
func (h *setupHandlers) deleteContainer(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
setupFlash(w, r, "Удаление контейнера: не указано имя")
return
}
if err := DeleteImportedContainer(name); err != nil {
setupFlash(w, r, "Удаление контейнера: "+err.Error())
return
}
setupFlash(w, r, "Контейнер «"+name+"» удалён из /var/lib/bj/containers/.")
}
// deleteProfile — POST /admin/setup/media/delete-profile.
// Параметр: name — имя профиля в /var/lib/bj/profiles/.
// Сносит директорию + чистит секцию из pki1.conf. Если профиль был
// активным — переключает crypto-service в minimal через Activate("").
func (h *setupHandlers) deleteProfile(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
setupFlash(w, r, "Удаление профиля: не указано имя")
return
}
s := h.rc.Snapshot()
wasActive := s.Crypto.Profile == name
if wasActive {
// Сначала переключаем сайдкар в minimal — иначе он держит
// открытый контекст на файлы, которые мы сейчас удалим.
cli := cryptocli.New(cryptocli.Config{
Provider: cryptocli.Provider(s.Crypto.Provider),
SocketPath: s.Crypto.SocketPath,
})
_, _ = cli.Activate(r.Context(), "")
_ = cli.Close()
cur := s.Crypto
cur.Profile = ""
_ = h.rc.UpdateCrypto(cur)
}
if err := DeleteImportedProfile(name); err != nil {
setupFlash(w, r, "Удаление профиля: "+err.Error())
return
}
if wasActive {
setupFlash(w, r, "Профиль «"+name+"» удалён (директория, секция в pki1.conf). Был активным — crypto-service переведён в minimal.")
} else {
setupFlash(w, r, "Профиль «"+name+"» удалён (директория, секция в pki1.conf).")
}
}
// 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)
}()
}
// allowedMediaPath проверяет, что путь к контейнеру внутри одной из
// известных media-точек: USB-mount, наша /var/lib/bj/media/iso/.
func allowedMediaPath(p string) bool {
abs, err := filepath.Abs(p)
if err != nil {
return false
}
allowed := []string{"/run/media/", "/media/", "/mnt/", "/var/lib/bj/media/iso/"}
for _, prefix := range allowed {
if strings.HasPrefix(abs, prefix) {
return true
}
}
return false
}
// guard — заглушка для совместимости с возможным расширением.
var _ = errors.New