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" "regexp" "strconv" "strings" "time" "github.com/jackc/pgx/v5" "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/cryptocli" "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter" ) // crypto-сертификаты на текущих токенах (для отображения на странице). // В gRPC-режиме сертификатами управляет сам сайдкар через профиль // Валидаты — здесь временно возвращаем пустой список, пока // crypto-service не получит метод ListCertificates. func (h *setupHandlers) listCertsForUI() []cryptocli.Certificate { return storeCertsForUI(h.rc.Snapshot()) } // setupHandlers — обработчики /admin/setup/*. type setupHandlers struct { rc *RuntimeConfig tpl *adminTemplates svc *Service keyWiz *keyWizardState // состояние мастера установки ключа } // adminTemplates — обёртка, чтобы передать набор шаблонов в setup. type adminTemplates struct { a *admin } // registerSetup вешает /admin/setup и /admin/setup/* (POST) на mux. func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service) { h := &setupHandlers{rc: rc, tpl: &adminTemplates{a: a}, svc: svc} 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/update/upload", h.uploadUpdate) mux.HandleFunc("/admin/setup/update/progress", h.updateProgress) mux.HandleFunc("/admin/setup/wizard-depo/conn", h.wizardDepoConn) mux.HandleFunc("/admin/setup/wizard-depo/tables", h.wizardDepoTables) mux.HandleFunc("/admin/setup/wizard-depo/job", h.wizardDepoJob) mux.HandleFunc("/admin/setup/wizard-depo/recheck", h.wizardDepoRecheck) mux.HandleFunc("/admin/setup/fancy/acc-prefix", h.accPrefix) 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/nsd-requisites", h.saveNSDRequisites) mux.HandleFunc("/admin/setup/lk", h.saveLK) mux.HandleFunc("/admin/setup/lk/dist", h.lkSaveDist) mux.HandleFunc("/admin/setup/lk/dist.tar.gz", h.lkDistDownload) mux.HandleFunc("/admin/setup/lk/users", h.lkUsersSave) mux.HandleFunc("/admin/setup/lk/relay-php.zip", h.lkPHPRelayDownload) mux.HandleFunc("/admin/setup/lk/preview", h.lkPreviewSave) mux.HandleFunc("/admin/setup/lk/data", h.lkDataSave) mux.HandleFunc("/admin/setup/units/restart", h.unitRestart) mux.HandleFunc("/admin/setup/lk-integration", h.saveLKIntegration) mux.HandleFunc("/admin/setup/lk-check", h.checkLKConn) mux.HandleFunc("/admin/setup/lk-simulate", h.lkSimulate) mux.HandleFunc("/admin/setup/lk-pull", h.lkPull) mux.HandleFunc("/admin/setup/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 Suggest *DepoRequisites // предложение реквизитов из DEPO (после «Определить из DEPO») Lic LicenseStatus DepoLicensed bool DepoDB string DepoTables DepoTablesView DepoJob DepoJobView DepoEdition string // редакция SQL Server клиента (пусто — не определена) DepoAgent bool // у SQL Server есть SQL Agent (не Express, не PostgreSQL) DepoJobDef string // способ обработки по умолчанию для мастера Fancy *SetupData // шаг 3: общий блок «Обмен Fancy» (тот же, что в настройках) Sub string // экран шага 3 (учёт DEPO): intro|conn|tables|job|done ValidataVer string // версия установленной «Валидаты Клиент L» (пусто — не установлена) CryptoSvc string // состояние службы bj-crypto (systemctl is-active) PGSummary string // куда подключена база bj, без пароля Update *UpdateStatus // шаг 1: есть ли на сервере обновлений версия новее, чем в образе AutoDone []string // шаги до текущего, уже выполненные без участия пользователя, — с пояснением Certs []cryptocli.Certificate Flash string Done struct { License bool Depo bool Org bool // организация (код участника, ИНН) задана Quik bool // QUIK МНП проверен (или модуль не в лицензии) Postgres bool Crypto bool Certs bool NSD bool TestRun bool Alerts bool // оповещения настроены и резервные копии включены } } // renderWizard рисует одну из 5 страниц мастера. Шаг управляется query // параметром ?step=N (1..5). По умолчанию шаг определяется автоматически // по первому незавершённому пункту — это даёт «продолжить с того места». func (h *setupHandlers) renderWizard(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method", http.StatusMethodNotAllowed) return } s := h.rc.Snapshot() d := WizardData{ page: nowPage("Мастер настройки", "wizard"), Settings: s, Suggest: depoDetectResult(), Certs: h.listCertsForUI(), Flash: r.URL.Query().Get("flash"), } d.Lic = licenseStatus(h.rc) d.Done.License = d.Lic.Valid d.DepoLicensed = moduleAllowed(h.rc, "quik-mnp") d.DepoDB = s.Fancy.DepoDB() d.DepoTables = depoTablesView(s.Fancy) d.DepoJob = depoJobView(s.Fancy) // Шаг 3 выполнен, когда есть подключение, трансферные таблицы и работающая обработка (job). d.Done.Depo = !d.DepoLicensed || (strings.TrimSpace(s.Fancy.Server) != "" && s.Fancy.DepoTablesCheckOK && s.Fancy.DepoJobCheckOK) d.Done.Postgres = s.Postgres.DSN != "" { ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) d.ValidataVer, _, _ = validataVersion(ctx) out, _ := exec.CommandContext(ctx, "systemctl", "is-active", "bj-crypto.service").Output() cancel() d.CryptoSvc = strings.TrimSpace(string(out)) } d.PGSummary = dsnSummary(s.Postgres.DSN) if d.ValidataVer != "" && (s.Crypto.Provider == "" || (s.Crypto.Provider == "stub" && !d.DevMode)) { // Валидата на месте, а провайдер не выбран — выбираем её сами: от этого зависят список // сертификатов (шаг 5) и проверки СКЗИ. c := s.Crypto c.Provider = "validata" if c.SocketPath == "" { c.SocketPath = "/run/bj/crypto.sock" } if err := h.rc.UpdateCrypto(c); err == nil { s = h.rc.Snapshot() d.Settings = s d.Certs = h.listCertsForUI() } } // Валидата ставится с образа/установщиком: если она на месте, шаг выполнен без ввода параметров // (bj работает с ней через свой криптосервис, путь к модулю не нужен). d.Done.Crypto = d.ValidataVer != "" || (s.Crypto.Provider != "" && s.Crypto.Provider != "stub" && s.Crypto.ModulePath != "") d.Done.Certs = len(ownValidCerts(d.Certs)) > 0 d.Done.NSD = doneNSD(s) d.Done.TestRun = s.LastTest != nil || (!d.DevMode && readinessDone()) // вне режима разработки — итоговая проверка готовности пройдена // Мастер идёт по порядку: каждый шаг показывается, даже если он уже выполнен (установщиком, // лицензией) — на нём написано, что сделано, и пользователь сам нажимает «Далее». Без ?step // продолжаем со следующего за последним пройденным шагом. progress := wizardProgress() step := progress + 1 if v := r.URL.Query().Get("step"); v != "" { if n, err := strconv.Atoi(v); err == nil && n >= 1 && n <= wizardSteps { step = n if n-1 > progress { setWizardProgress(n - 1) } } } if step > wizardSteps { step = wizardSteps } d.Step = step if step == 4 && strings.TrimSpace(s.Fancy.Server) != "" && !doneOrg(s) && depoDetectResult() == nil { // организация ещё не задана, а база DEPO подключена — определяем реквизиты сами: карточка // покажет их заполненными, пользователю остаётся проверить и сохранить ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) if res, err := depoDetectRequisites(ctx, s.Fancy); err == nil { lastDepoDetect.mu.Lock() lastDepoDetect.r = res lastDepoDetect.mu.Unlock() d.Suggest = res } else { log.Printf("wizard: реквизиты из DEPO не определены: %v", err) } cancel() } if (step >= 3 && step <= 5) || step == 9 { // DEPO, организация, QUIK МНП, оповещения и копии — общие блоки из «Настройки» fd := h.setupData(w, r, "") d.Fancy = &fd } if step == 3 { d.Sub = depoWizardSub(r.URL.Query().Get("sub")) if r.URL.Query().Get("sub") == "" && d.Done.Depo { d.Sub = "done" } } d.Done.Org = doneOrg(s) d.Done.Alerts = doneAlerts(s) d.Done.Quik = !d.DepoLicensed || doneQuik(s) if step == 3 && d.DepoLicensed && strings.TrimSpace(s.Fancy.Server) != "" { // Есть ли у сервера SQL Agent: у Express его нет, у PostgreSQL — тоже; тогда обработку // запускает сам bj. Иначе по умолчанию джобу SQL Agent создаёт bj. d.DepoJobDef = "bj" if !s.Fancy.IsPostgres() { ctx, cancel := context.WithTimeout(r.Context(), 4*time.Second) d.DepoEdition, d.DepoAgent = depoServerEdition(ctx, s.Fancy) cancel() if d.DepoAgent || d.DepoEdition == "" { d.DepoJobDef = "sql-auto" } } } if step == 1 && d.Done.License { // Образ ставит ядро той версии, с которой собран; всё, что вышло позже (исправления ядра, // установщика и служб), приезжает обновлением — предлагаем его сразу после ввода ключа. ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second) if st, err := NewUpdater(h.rc).CheckForUpdate(ctx); err == nil { d.Update = &st } cancel() } render(w, h.tpl.a.wizard, d) } // depoServerEdition — редакция SQL Server и есть ли у неё SQL Agent (EngineEdition 4 — Express). func depoServerEdition(ctx context.Context, f FancyMSSQLSettings) (string, bool) { db, err := fancyOpen(f, "master") if err != nil { return "", false } defer db.Close() var eng int var ed string if err := db.QueryRowContext(ctx, "SELECT CAST(SERVERPROPERTY('EngineEdition') AS int), CAST(SERVERPROPERTY('Edition') AS nvarchar(128))").Scan(&eng, &ed); err != nil { return "", false } return ed, eng != 4 } // wizardSteps — шагов в мастере: 1 лицензия, 2 база bj, 3 учёт DEPO, 4 наша организация, 5 QUIK МНП, // 6 Валидата, 7 шлюз НРД, 8 ключ и сертификаты (ключ ставится в профиль ИШ — после шлюза), // 9 оповещения и резервные копии, 10 проверка готовности. const wizardSteps = 10 // wizardProgressFile — номер последнего пройденного шага мастера (переход «Далее» на шаг N // означает, что шаги до N пройдены). const wizardProgressFile = "/var/lib/bj/.bj/wizard-progress" func wizardProgress() int { b, err := os.ReadFile(wizardProgressFile) if err != nil { return 0 } n, _ := strconv.Atoi(strings.TrimSpace(string(b))) return n } func setWizardProgress(n int) { if err := os.WriteFile(wizardProgressFile, []byte(strconv.Itoa(n)+"\n"), 0o600); err != nil { log.Printf("wizard: прогресс мастера не сохранён: %v", err) } } // dsnSummary — «host:port, база name» из строки подключения PostgreSQL, без логина и пароля. func dsnSummary(dsn string) string { u, err := url.Parse(strings.TrimSpace(dsn)) if err != nil || u.Host == "" { return "" } host := u.Host if h := u.Hostname(); h == "127.0.0.1" || h == "localhost" || h == "::1" { host = "PostgreSQL на этом сервере" } if db := strings.TrimPrefix(u.Path, "/"); db != "" { return host + ", база " + db } return host } // runCmd выполняет команду и возвращает stdout+stderr строкой. func runCmd(ctx context.Context, name string, args ...string) (string, error) { cmd := exec.CommandContext(ctx, name, args...) out, err := cmd.CombinedOutput() return string(out), err } // checkCrypto — POST /admin/setup/crypto/check. Делает Health-вызов // к crypto-service через UDS-сокет. func (h *setupHandlers) checkCrypto(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } s := h.rc.Snapshot() 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 Suggest *DepoRequisites // предложение реквизитов из DEPO Readiness []Readiness ReadyCount int TotalCount int Certificates []cryptocli.Certificate CurrentVersion string // версия bj-server (для раздела «Обновления») Components []release.ComponentState // компоненты и их версии (раздел «Обновления») UpdatesAvailable bool License LicenseStatus // статус лицензии Health []HealthStatus // здоровье фоновых компонентов AdvancedUnlocked bool // открыты ли перспективные модули (QUIK, Fancy) NSDCerts NSDCertsView // сертификаты НРД в справочнике ИШ Participants []HandbookEntry // справочник участников M2M (выбор своей организации) OrgINN, OrgName string // наша организация (с учётом справочника) OrgCode string NotifyGroups []struct{ Key, Title string } NotifyKinds []struct{ Key, Title string } DepoJob DepoJobView DepoTables DepoTablesView // трансферные таблицы FTV_M2M* в базе DEPO QuikExport QuikExportView // модуль «Экспорт биржевой информации» LKAgent LKAgentStatus // канал кабинета клиента (режим ожидания) LKKeyMask string // ключ кабинета в маскированном виде LKUsers []LKUser // учётные записи кабинета LKUsersError string LKPreview LKPreviewStatus // кабинет на этом сервере для просмотра LKData LKDataView // курсы валют и ежедневный срез стоимости Units []UnitView // службы bj и её модулей UpdateFailed bool // последняя попытка обновления закончилась отказом Versions SystemVersions // версии компонентов bj и внешних систем Modules map[string]bool // какие лицензируемые модули разрешены (ключ = признак) Features []LicenseFeatureView // признаки лицензии для карточки Users UsersView // пользователи, роли, сессии 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) { render(w, h.tpl.a.setup, h.setupData(w, req, flash)) } // setupData — данные страницы «Настройка»; их же получает общий блок «Обмен Fancy» в мастере. func (h *setupHandlers) setupData(w http.ResponseWriter, req *http.Request, flash string) SetupData { s := h.rc.Snapshot() r := h.rc.ReadinessSummary() ready := 0 for _, x := range r { if x.Configured { ready++ } } comps, anyUpdate := updateComponentRows(h.rc) data := SetupData{ page: nowPage("Настройка", "setup"), Components: comps, UpdatesAvailable: anyUpdate, Settings: s, Suggest: depoDetectResult(), Readiness: r, ReadyCount: ready, TotalCount: len(r), Certificates: h.listCertsForUI(), CurrentVersion: BuildVersion, License: licenseStatus(h.rc), Health: HealthSnapshot(), AdvancedUnlocked: advancedUnlocked(req), 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), LKPreview: lkPreviewStatus(h.rc, req), LKData: lkDataView(h.rc), Units: unitViews(h.rc), UpdateFailed: updateResultFailed(s.Update.LastResult), LKKeyMask: lkKeyForPage(s.LK.APIKey), Versions: systemVersions(h.rc), Modules: modulesAllowed(h.rc), Features: licenseFeatureViews(h.rc), Users: usersView(w), Flash: flash, } data.LKUsers, data.LKUsersError = lkUsersForSetup(h.rc) if errVal := errMsgFromQuery(_q(w)); errVal != "" { data.Error = errVal } return data } func (h *setupHandlers) savePostgres(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } dsn := strings.TrimSpace(r.FormValue("dsn")) if dsn != "" { // Лёгкая проверка: попытка sql.Open и Ping (без драйвера дальше // просто ошибка — это нормально, важно показать что DSN сохранён). if err := tryPingPostgres(dsn); err != nil { setupFlash(w, r, "postgres: проверка соединения не прошла: "+err.Error()) return } } if err := h.rc.UpdatePostgres(PostgresSettings{DSN: dsn}); err != nil { setupFlash(w, r, "postgres: ошибка сохранения: "+err.Error()) return } setupFlash(w, r, "PostgreSQL настройки сохранены") } // quickStartPostgres — POST /admin/setup/postgres/quick-start. // «Большая зелёная кнопка» для пользователя без IT-навыков: поднимает // локальный postgres-контейнер через podman-compose, ждёт pg_isready, // накатывает все миграции (fansy-store + m2m-core), сохраняет дефолтный // DSN в runtime-конфиге. После этого пользователю остаётся перезапустить // bj-server (или мы сделаем это автоматически в дальнейших версиях). func (h *setupHandlers) quickStartPostgres(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) defer cancel() // 1. Поднимаем postgres контейнер через podman-compose. composePath := "deploy/docker-compose/docker-compose.yml" if out, err := runCmd(ctx, "podman-compose", "-f", composePath, "up", "-d", "postgres"); err != nil { setupFlash(w, r, "Шаг 1/3: podman-compose не смог поднять контейнер. "+ "Установите podman-compose или проверьте docker-compose.yml. Подсказка: "+ "sudo dnf install -y podman-compose. Вывод: "+strings.TrimSpace(out)) return } // 2. Ждём pg_isready. dsn := "postgres://bj:bj_dev@127.0.0.1:5432/bj?sslmode=disable" deadline := time.Now().Add(30 * time.Second) for time.Now().Before(deadline) { if err := tryPingPostgres(dsn); err == nil { break } time.Sleep(time.Second) } if err := tryPingPostgres(dsn); err != nil { setupFlash(w, r, "Шаг 2/3: контейнер запущен, но БД не отвечает за 30 сек. Ошибка: "+err.Error()) return } // 3. Применяем миграции через podman exec. migrations := []string{ "migrations/fansy-store/000__roles.sql", "migrations/fansy-store/001__schemas.sql", "migrations/fansy-store/002__working.sql", "migrations/fansy-store/003__staging.sql", "migrations/fansy-store/004__seed_participants.sql", "migrations/m2m-core/001__deals.sql", "migrations/m2m-core/002__stages.sql", } for _, mig := range migrations { if err := applyMigration(ctx, mig); err != nil { // Миграция могла быть уже применена ранее (например, ROLE уже // существует) — это не критично, продолжаем. log.Printf("quick-start: миграция %s: %v (продолжаем)", mig, err) } } // 4. Сохраняем DSN в runtime-конфиг. if err := h.rc.UpdatePostgres(PostgresSettings{DSN: dsn}); err != nil { setupFlash(w, r, "Шаг 3/3: не получилось сохранить DSN: "+err.Error()) return } setupFlash(w, r, "Локальный PostgreSQL поднят и настроен. DSN сохранён. "+ "Перезапустите bj-server (или подождите пока systemd сам перезапустит сервис), "+ "чтобы Repository подключился к БД. После этого статус PostgreSQL будет зелёным.") } // applyMigration выполняет одну SQL-миграцию через podman exec в bj-postgres. func applyMigration(ctx context.Context, path string) error { data, err := os.ReadFile(path) if err != nil { return err } cmd := exec.CommandContext(ctx, "podman", "exec", "-i", "bj-postgres", "psql", "-U", "bj", "-d", "bj", "-v", "ON_ERROR_STOP=1") cmd.Stdin = strings.NewReader(string(data)) out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("%w / output: %s", err, strings.TrimSpace(string(out))) } return nil } func (h *setupHandlers) saveCrypto(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } s := CryptoSettings{ Provider: strings.TrimSpace(r.FormValue("provider")), SocketPath: strings.TrimSpace(r.FormValue("socket_path")), ModulePath: strings.TrimSpace(r.FormValue("module_path")), } if s.Provider == "" { s.Provider = "stub" } if s.SocketPath == "" { s.SocketPath = "/run/bj/crypto.sock" } if s.ModulePath != "" { if _, err := os.Stat(s.ModulePath); err != nil { setupFlash(w, r, "crypto: module_path не найден: "+err.Error()) return } } if err := h.rc.UpdateCrypto(s); err != nil { setupFlash(w, r, "crypto: ошибка сохранения: "+err.Error()) return } setupFlash(w, r, "Криптография: настройки сохранены ("+s.Provider+")") } func (h *setupHandlers) saveNSD(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } s := NSDSettings{ Profile: strings.TrimSpace(r.FormValue("profile")), IGWBaseURL: strings.TrimSpace(r.FormValue("igw_base_url")), KeyContainer: strings.TrimSpace(r.FormValue("key_container")), OrgCode: h.rc.Snapshot().NSD.OrgCode, // организация — отдельной формой (saveOrg) OrgINN: h.rc.Snapshot().NSD.OrgINN, OrgName: h.rc.Snapshot().NSD.OrgName, DeponentCode: strings.TrimSpace(r.FormValue("deponent_code")), AccountID: strings.TrimSpace(r.FormValue("account_id")), SectionID: strings.TrimSpace(r.FormValue("section_id")), IGWv904: r.FormValue("igw_v904") == "on", } cur := h.rc.Snapshot().NSD s.DepoDBTest, s.DepoDBProd, s.SwitchedAt = cur.DepoDBTest, cur.DepoDBProd, cur.SwitchedAt if r.FormValue("keep_requisites") == "1" { // мастер: реквизиты НРД — на шаге «Организация» s.DeponentCode, s.AccountID, s.SectionID = cur.DeponentCode, cur.AccountID, cur.SectionID } if _, ok := r.Form["key_container"]; !ok { s.KeyContainer = cur.KeyContainer } if s.Profile == "prod" { // старый пункт мастера: такого профиля нет — bj ушёл бы в имитацию s.Profile = "prod-gost" } if s.Profile != "" { if _, err := nsdadapter.LookupProfile(s.Profile); err != nil { setupFlash(w, r, "НРД: неизвестный контур "+s.Profile) return } } if s.IGWBaseURL != "" { if _, err := url.ParseRequestURI(s.IGWBaseURL); err != nil { setupFlash(w, r, "nsd: невалидный URL: "+err.Error()) return } // У ИШ нет /healthz — проверяем рабочий эндпоинт Web API. if err := tryHTTPHealth(s.IGWBaseURL + "/api/admin/engine/state"); err != nil { setupFlash(w, r, "Шлюз НРД не отвечает по адресу "+s.IGWBaseURL+": "+err.Error()+". Проверьте, что ИШ установлен и запущен (служба igate).") return } } if err := h.rc.UpdateNSD(s); err != nil { setupFlash(w, r, "nsd: ошибка сохранения: "+err.Error()) return } msg := "Шлюз НРД: настройки сохранены" if s.Profile != cur.Profile || s.IGWBaseURL != cur.IGWBaseURL { // отправитель в НРД создаётся при запуске — перезапускаем bj, чтобы контур и адрес применились сразу msg += ". bj перезапускается, чтобы применить контур и адрес, — обновите страницу через 10–15 секунд." go func() { time.Sleep(2 * time.Second) if _, err := restartUnit(context.Background(), "bj-server"); err != nil { log.Printf("setup: перезапуск bj-server после смены контура НРД: %v", err) } }() } setupFlash(w, r, msg) } func (h *setupHandlers) saveLK(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } // Стартуем от текущих настроек, чтобы не затереть APIKey/PublicURL. s := h.rc.Snapshot().LK s.CallbackURL = strings.TrimSpace(r.FormValue("callback_url")) s.PublicURL = strings.TrimSpace(r.FormValue("lk_public_url")) if s.PublicURL != "" { if _, err := url.ParseRequestURI(s.PublicURL); err != nil { setupFlash(w, r, "lk: невалидный адрес ЛК: "+err.Error()) return } } if s.CallbackURL != "" { if _, err := url.ParseRequestURI(s.CallbackURL); err != nil { setupFlash(w, r, "lk: невалидный URL: "+err.Error()) return } if err := tryHTTPHealth(s.CallbackURL + "/healthz"); err != nil { setupFlash(w, r, "lk: callback URL не отвечает на /healthz: "+err.Error()) return } } if err := h.rc.UpdateLK(s); err != nil { setupFlash(w, r, "lk: ошибка сохранения: "+err.Error()) return } if s.CallbackURL != "" { h.svc.callbackURL = s.CallbackURL } setupFlash(w, r, "Callback в ЛК сохранён и применён") } // lkEnvPtr возвращает окружение ЛК по имени (test/prod) внутри LKSettings. func lkEnvPtr(s *LKSettings, env string) *LKEndpoint { if env == "prod" { return &s.Prod } return &s.Test } // saveLKIntegration — сохранение реквизитов подключения к API ЛК (тест/бой). func (h *setupHandlers) saveLKIntegration(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } env := r.FormValue("env") if env != "test" && env != "prod" { setupFlash(w, r, "lk: неизвестное окружение") return } s := h.rc.Snapshot().LK ep := lkEnvPtr(&s, env) ep.BaseURL = strings.TrimRight(strings.TrimSpace(r.FormValue("base_url")), "/") ep.Login = strings.TrimSpace(r.FormValue("login")) if pw := r.FormValue("password"); pw != "" { ep.Password = pw // пустое поле не затирает сохранённый пароль } if ep.BaseURL != "" { if _, err := url.ParseRequestURI(ep.BaseURL); err != nil { setupFlash(w, r, "lk: невалидный адрес API: "+err.Error()) return } } if a := r.FormValue("active_env"); a == "test" || a == "prod" { s.ActiveEnv = a } if r.FormValue("emulate_nsd_form") != "" && devMode(h.rc) { // флажок — в форме активного окружения, в режиме разработки s.EmulateNSD = r.FormValue("emulate_nsd") != "" } if err := h.rc.UpdateLK(s); err != nil { setupFlash(w, r, "lk: ошибка сохранения: "+err.Error()) return } setupFlash(w, r, "Интеграция с ЛК ("+lkEnvName(env)+") сохранена") } // checkLKConn — «Проверить связь»: GET к BaseURL с Basic-авторизацией. // Различает: связи нет / авторизация не прошла / связь есть. func (h *setupHandlers) checkLKConn(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } env := r.FormValue("env") s := h.rc.Snapshot().LK ep := lkEnvPtr(&s, env) if ep.BaseURL == "" { setupFlash(w, r, "lk: сначала укажите адрес API "+lkEnvName(env)) return } ok, msg := probeLK(*ep) ep.LastOK, ep.LastMsg, ep.LastAt = ok, msg, time.Now() _ = h.rc.UpdateLK(s) setupFlash(w, r, "Проверка связи с ЛК ("+lkEnvName(env)+"): "+msg) } // lkAPIBase возвращает базу back_office API ЛК ЕСИА Финанс из адреса стенда. // Пользователь вводит корень (https://host), API живёт на /api/v1/back_office. func lkAPIBase(baseURL string) string { b := strings.TrimRight(baseURL, "/") if strings.Contains(b, "/api/v1/back_office") { return b } return b + "/api/v1/back_office" } // probeLK проверяет связь по реальному эндпоинту API с Basic-авторизацией — // так 401 честно отличается от «связь есть» (а не корневой SPA, отдающий 200). func probeLK(ep LKEndpoint) (bool, string) { ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) defer cancel() u := lkAPIBase(ep.BaseURL) + "/notifications/flat?per_page=1" req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { return false, "неверный адрес: " + err.Error() } req.Header.Set("Accept", "application/json") if ep.Login != "" { req.SetBasicAuth(ep.Login, ep.Password) } resp, err := (&http.Client{Timeout: 8 * time.Second}).Do(req) if err != nil { return false, "связи нет — сервер недоступен (" + err.Error() + ")" } defer resp.Body.Close() switch { case resp.StatusCode == 401 || resp.StatusCode == 403: return false, fmt.Sprintf("сервер отвечает, но авторизация не прошла (HTTP %d) — проверьте логин/пароль", resp.StatusCode) case resp.StatusCode >= 500: return false, fmt.Sprintf("сервер вернул ошибку (HTTP %d)", resp.StatusCode) default: return true, fmt.Sprintf("связь есть (HTTP %d)", resp.StatusCode) } } func lkEnvName(env string) string { if env == "prod" { return "бой" } return "тест" } // lkSimulate — симулятор подачи заявки: bj собирает depo_m2m и POST-ит в API ЛК // ЕСИА Финанс (пока подача через веб-форму у них не готова). Показывает сырой // ответ, чтобы докрутить контракт и затем «поймать» заявку опросом. func (h *setupHandlers) lkSimulate(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } s := h.rc.Snapshot().LK ep := s.Test if s.ActiveEnv == "prod" { ep = s.Prod } if ep.BaseURL == "" || ep.Login == "" { setupFlash(w, r, "Симулятор: сначала заполните и проверьте подключение к API ЛК") return } // Реальная структура стенда (проверено): kind=depo_m2m, поля бумаги ПЛОСКО в data. body := map[string]any{"claim": map[string]any{ "kind": "depo_m2m", "user_id": atoiDefault(r.FormValue("user_id"), 0), "owner_type": "Contract", "owner_id": atoiDefault(r.FormValue("contract_id"), 0), // Структура по swagger (test/prod): paper{} вложен (name/isin/type/reg_number), // paper_count — ПЛОСКО в data (float), review_links — массив {text,url}. "data": map[string]any{ "account": r.FormValue("account"), "section": r.FormValue("section"), "paper": map[string]any{ "name": r.FormValue("paper_name"), "isin": r.FormValue("paper_isin"), "type": nonEmpty(r.FormValue("paper_type"), "share"), "registration_number": r.FormValue("paper_reg"), }, "paper_count": atofDefault(r.FormValue("paper_count"), 1), "contragent_inn": r.FormValue("contragent_inn"), "contragent_name": r.FormValue("contragent_name"), "contragent_place_of_payment": nonEmpty(r.FormValue("place_of_payment"), "nrd"), "expense_information": r.FormValue("expense_information") == "on", "review_links": []any{}, }, }} raw, _ := json.Marshal(body) code, respBody := lkPostJSON(lkAPIBase(ep.BaseURL)+"/claims", ep, raw) snip := respBody if len(snip) > 400 { snip = snip[:400] + "…" } setupFlash(w, r, fmt.Sprintf("Симулятор → POST /claims: HTTP %d · %s", code, snip)) } // lkPull — «Забрать заявки из ЛК»: один проход коннектора (GET in_queue → // сборка M2M → отправка в НРД → PATCH «В работе»). Ручной триггер; штатно // это делает фоновый поллер, но кнопка нужна для первичного теста. func (h *setupHandlers) lkPull(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) defer cancel() res, err := PullLKClaims(ctx, h.rc, h.svc) if err != nil { log.Printf("lk-gateway: ручной забор ЛК: %v", err) setupFlash(w, r, "Забор заявок из ЛК: "+err.Error()) return } msg := fmt.Sprintf("Забор заявок из ЛК: получено %d, отправлено в НРД %d, отклонено %d", res.Fetched, res.Sent, res.Rejected) if res.Emulated { _, why := lkEmulateNSD(h.rc) if devMode(h.rc) { msg = fmt.Sprintf("Забор заявок из ЛК (имитация НРД — %s): получено %d, прошли сверку и переданы имитатору %d, отклонены сверкой %d", why, res.Fetched, res.Sent, res.Rejected) } else { msg = fmt.Sprintf("Забор заявок из ЛК (без отправки в НРД — %s): получено %d, прошли сверку %d, отклонены сверкой %d", why, res.Fetched, res.Sent, res.Rejected) } } if len(res.Errors) > 0 { msg += " · ошибки: " + strings.Join(res.Errors, "; ") } log.Printf("lk-gateway: ручной забор ЛК: %s", msg) setupFlash(w, r, msg) } // saveAlertsSMTP сохраняет настройки почтовых уведомлений. func (h *setupHandlers) saveAlertsSMTP(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } a := h.rc.Snapshot().Alerts a.SMTP = SMTPSettings{ Enabled: r.FormValue("enabled") == "on", Host: strings.TrimSpace(r.FormValue("host")), Port: atoiDefault(r.FormValue("port"), 587), Security: nonEmpty(r.FormValue("security"), "starttls"), Username: strings.TrimSpace(r.FormValue("username")), Password: pickStr(r.FormValue("password"), a.SMTP.Password), // пароль в страницу не выводится: пусто — прежний From: strings.TrimSpace(r.FormValue("from")), To: 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 was := updateChannel(cfg) cfg.BaseURL = strings.TrimSpace(r.FormValue("base_url")) cfg.Channel = strings.TrimSpace(r.FormValue("channel")) cfg.PublicKey = strings.TrimSpace(r.FormValue("public_key")) cfg.AutoCheck = r.FormValue("auto_check") != "" if updateChannel(cfg) != was { // Версии из прошлого канала к новому не относятся — забываем их до проверки. cfg.Components, cfg.ComponentsChannel, cfg.Available, cfg.Notes = nil, "", "", "" cfg.LastResult = "канал сменён на «" + updateChannel(cfg) + "» — нажмите «Проверить обновления»" } if err := h.rc.SaveUpdateSettings(cfg); err != nil { setupFlash(w, r, "обновления: ошибка сохранения: "+err.Error()) return } setupFlash(w, r, "Настройки обновлений сохранены") } // checkUpdate — POST /admin/setup/update/check. Проверяет наличие обновления. func (h *setupHandlers) checkUpdate(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } st, err := NewUpdater(h.rc).CheckForUpdate(r.Context()) if err != nil { setupFlash(w, r, "Проверка обновлений: "+err.Error()) return } setupFlash(w, r, "Проверка обновлений: "+st.Message) } // applyUpdate — POST /admin/setup/update/apply. Скачивает и применяет. func (h *setupHandlers) applyUpdate(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } // форма может прийти и как multipart (окно обновления) — иначе имена компонентов теряются // и «Обновить» у одной строки превращается в «Обновить всё» if err := r.ParseMultipartForm(1 << 20); err != nil { _ = r.ParseForm() } names := r.Form["component"] rc := h.rc save := func(msg string, err error) string { if err != nil { msg = "Обновление не применено: " + err.Error() } else { msg = "Обновление: " + msg } log.Printf("lk-gateway: %s", msg) cfg := rc.Snapshot().Update cfg.LastResult, cfg.LastCheck = msg, time.Now().UTC() _ = rc.SaveUpdateSettings(cfg) return msg } // Обновление ядра — только в фоне: bj-server перезапустится раньше, чем придёт // ответ установщика, и страница всё равно не дождётся. Модули обновляем на месте // и показываем настоящий результат: иначе отказ («в канале нет релизов», нет // лицензии, не сходится подпись) виден только строкой «Последняя проверка», // и со стороны выглядит так, будто кнопка ничего не делает. if updateTouchesCore(rc, names) { go func() { save(NewUpdater(rc).ApplyUpdate(context.Background(), names)) }() what := "всех компонентов с новой версией" if len(names) > 0 { what = strings.Join(names, ", ") } setupFlash(w, r, "Обновление "+what+" запущено: файлы скачиваются и проверяются, затем перезапускаются службы. Итог — в строке «Последняя проверка» через минуту; при обновлении ядра страница временно не отвечает.") return } ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute) defer cancel() setupFlash(w, r, save(NewUpdater(rc).ApplyUpdate(ctx, names))) } // uploadUpdate — POST /admin/setup/update/upload: пакет обновления файлом (закрытый контур). // Файл сохраняется в каталог подготовки, дальше — в фоне, как и обновление по сети. func (h *setupHandlers) uploadUpdate(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } r.Body = http.MaxBytesReader(w, r.Body, 2<<30) f, hdr, err := r.FormFile("bundle") if err != nil { setupFlash(w, r, "Обновление из файла: файл не получен ("+err.Error()+")") return } defer f.Close() if err := os.MkdirAll(updateStagingDir, 0o750); err != nil { setupFlash(w, r, "Обновление из файла: каталог подготовки: "+err.Error()) return } dst := filepath.Join(updateStagingDir, fmt.Sprintf("upload-%d.zip", time.Now().UnixNano())) out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o640) if err != nil { setupFlash(w, r, "Обновление из файла: "+err.Error()) return } n, err := io.Copy(out, f) out.Close() if err != nil { _ = os.Remove(dst) setupFlash(w, r, "Обновление из файла: не удалось сохранить: "+err.Error()) return } rc := h.rc go func() { msg, err := NewUpdater(rc).ApplyBundle(context.Background(), dst) cfg := rc.Snapshot().Update if err != nil { msg = "Обновление из файла не применено: " + err.Error() } else { msg = "Обновление: " + msg } log.Printf("lk-gateway: %s", msg) cfg.LastResult, cfg.LastCheck = msg, time.Now().UTC() _ = rc.SaveUpdateSettings(cfg) }() setupFlash(w, r, fmt.Sprintf("Файл %s (%d МБ) принят: проверяется подпись, затем ставятся компоненты новее текущих — ядро, потом модули. Итог — в строке «Последняя проверка» через минуту; при обновлении ядра страница временно не отвечает.", hdr.Filename, n>>20)) } // runTestClaim делает CreateClaim + ждёт финального состояния через GetClaim. func (h *setupHandlers) runTestClaim() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() whole := uint64(1500) req := CreateClaimRequest{ Investor: Investor{ ID: "11111111-1111-1111-1111-111111111111", LastName: "Иванов", FirstName: "Иван", MiddleName: "Иванович", Document: Document{DocumentType: "21", Series: "4512", Number: "654321"}, }, TransferringDepositoryINN: "0702345678", ReceivingDepositoryINN: "0710987654", CostInfo: CostInfo{No: &struct{}{}}, IIAAgreement: &IIAAgreement{ AgreementType: "T03", AgreementNumber: "ИИС78/2024", AgreementDate: "2026-01-15", BrokerINN: "0707083893", }, Securities: []ClaimSec{ { SecurityCode: "MM0766162534", SecurityDetails: SecurityDetails{ISIN: "RU0007661625"}, Quantity: Quantity{Whole: whole}, SettlementAccounts: []SettlementAccount{ { SettlementRequisitesINN: "7702070139", SettlementLocation: SettlementLocation{ DeponentCode: "DP789456", AccountID: "31MC0021900000F01", SectionID: "P001", }, }, }, }, }, SignedDocument: "dGVzdC1zaWduYXR1cmU=", SignatureFormat: "XMLDSig-GOST", } res := TestRunResult{StartedAt: time.Now().UTC()} created, err := h.svc.CreateClaim(ctx, req) if err != nil { res.FinishedAt = time.Now().UTC() res.OK = false res.Message = "CreateClaim упал: " + err.Error() _ = h.rc.RecordTestRun(res) return } res.ClaimID = created.ID // Опрашиваем статус каждые 200ms до перехода в финал. deadline := time.Now().Add(25 * time.Second) for time.Now().Before(deadline) { view, err := h.svc.GetClaim(ctx, created.ID) if err != nil { res.FinishedAt = time.Now().UTC() res.OK = false res.FinalStatus = "lookup_failed" res.Message = err.Error() _ = h.rc.RecordTestRun(res) return } switch view.Status { case "confirmed", "awaiting_sub16", "done": res.FinishedAt = time.Now().UTC() res.OK = true res.FinalStatus = view.Status res.Message = "Заявка подтверждена принимающей стороной (mock или реальный НРД)." _ = h.rc.RecordTestRun(res) return case "rejected", "timed_out": res.FinishedAt = time.Now().UTC() res.OK = false res.FinalStatus = view.Status res.Message = "Заявка не прошла: статус " + view.Status _ = h.rc.RecordTestRun(res) return } time.Sleep(200 * time.Millisecond) } res.FinishedAt = time.Now().UTC() res.OK = false res.FinalStatus = "timeout_waiting" res.Message = "Не дождались финального статуса за 25 сек (mock-задержка обычно 3 сек; проверьте лог lk-gateway)" _ = h.rc.RecordTestRun(res) } // tryPingPostgres делает короткое подключение через pgx и Ping. func tryPingPostgres(dsn string) error { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() conn, err := pgx.Connect(ctx, dsn) if err != nil { return err } defer conn.Close(ctx) return conn.Ping(ctx) } // tryHTTPHealth делает GET и ждёт 2xx. func tryHTTPHealth(u string) error { c := &http.Client{Timeout: 3 * time.Second} resp, err := c.Get(u) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode >= 400 { return fmt.Errorf("HTTP %d", resp.StatusCode) } return nil } // setupFlash шлёт 303 с flash-сообщением в query. Если запрос пришёл // с какой-то «принимающей flash» страницы (/admin/wizard, /admin/news, // /admin/setup) — возвращаем туда же. Иначе дефолт — /admin/setup. // Это нужно чтобы пользователь не «выпадал» из текущего контекста после // POST-действия (нажал кнопку «Проверить обновления» в Новостях — должен // остаться в Новостях со флешем). func setupFlash(w http.ResponseWriter, r *http.Request, msg string) { // Куда вернуться: на ту же страницу, где была форма. Берём из скрытого поля // return_to (его проставляет JS из location.pathname) — это надёжнее Referer, // который браузер по plain-HTTP/политике может не слать. Фолбэки: Referer, // затем /admin/setup. dest := safeReturnPath(r.FormValue("return_to")) if dest == "" { if ref := r.Header.Get("Referer"); ref != "" { if u, err := url.Parse(ref); err == nil && safeReturnPath(u.Path) != "" { dest = u.Path } } } if dest == "" { dest = "/admin/setup" } q := url.Values{} q.Set("flash", msg) // Секция (id карточки) — через ?scrollto=, JS прокрутит к ней и уберёт параметр. if s := r.FormValue("section"); s != "" && isAnchorName(s) { q.Set("scrollto", s) } sep := "?" if strings.Contains(dest, "?") { sep = "&" } http.Redirect(w, r, dest+sep+q.Encode(), http.StatusSeeOther) } // safeReturnPath пропускает только наши же admin-пути ("/admin/..."), без // схемы/хоста и без "//" — защита от open-redirect. Пусто, если путь небезопасен. func safeReturnPath(p string) string { if p == "" || len(p) > 256 { return "" } if !strings.HasPrefix(p, "/admin") { return "" } if strings.HasPrefix(p, "//") || strings.ContainsAny(p, ":\\\r\n") { return "" } if i := strings.IndexByte(p, '#'); i >= 0 { p = p[:i] } // путь и безопасные параметры (например ?step=4&sub=done в мастере); flash/scrollto — свежие path, query, _ := strings.Cut(p, "?") var keep []string for _, kv := range strings.Split(query, "&") { k, v, ok := strings.Cut(kv, "=") if !ok || k == "flash" || k == "scrollto" || !safeQueryPart.MatchString(k) || !safeQueryPart.MatchString(v) { continue } keep = append(keep, k+"="+v) } if len(keep) > 0 { return path + "?" + strings.Join(keep, "&") } return path } var safeQueryPart = regexp.MustCompile(`^[A-Za-z0-9_-]{1,40}$`) // isAnchorName пропускает только безопасные id-якоря (буквы/цифры/-/_), // чтобы не дать заголовкам ответа инжектить произвольное. func isAnchorName(s string) bool { if s == "" || len(s) > 64 { return false } for _, c := range s { if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_') { return false } } return true } // _q извлекает Request из ResponseWriter trick — здесь не нужно // (всегда работаем через chain). func _q(_ http.ResponseWriter) string { return "" } func errMsgFromQuery(_ string) string { return "" } // restartCrypto — POST /admin/setup/restart-crypto. Отправляет // gRPC.Shutdown сайдкару — он завершится с exit(2), systemd // поднимет его через RestartSec=5. func (h *setupHandlers) restartCrypto(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } s := h.rc.Snapshot() cli := cryptocli.New(cryptocli.Config{ Provider: cryptocli.Provider(s.Crypto.Provider), SocketPath: s.Crypto.SocketPath, }) defer cli.Close() if err := cli.Shutdown(r.Context()); err != nil { // Сайдкар мог уже закрыть соединение до отправки — это нормально. log.Printf("restartCrypto: Shutdown: %v (ожидаемо, если уже закрылся)", err) } setupFlash(w, r, "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