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("Bridge-and-Join-s sign smoke 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}, }, // Списывающий (передающий) депозитарий — НАША организация. ИНН должен // совпадать с записью нашего участника M2M (MC0413600000 = ООО ИК // «Фонтвьель», ИНН 7703807489), иначе НРД отвечает M2M17 «неверный ИНН // списывающего депозитария». TransferringDepositoryINN: "7703807489", 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/-, // распаковывает через 7z в /var/lib/bj/media/iso//. 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//, при успехе зовёт 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//. 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