4332e3309b
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
1790 lines
78 KiB
Go
1790 lines
78 KiB
Go
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 && d.Done.NSD) // вне режима разработки тестовой заявки нет
|
||
|
||
// Мастер идёт по порядку: каждый шаг показывается, даже если он уже выполнен (установщиком,
|
||
// лицензией) — на нём написано, что сделано, и пользователь сам нажимает «Далее». Без ?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("<bj-test ts=\"" + time.Now().UTC().Format(time.RFC3339) + "\">Bridge-and-Join-s sign smoke test</bj-test>")
|
||
sig, err := cli.SignXMLDSig(r.Context(), payload, "", s.Crypto.Profile)
|
||
if err != nil {
|
||
setupFlash(w, r, "Пробная подпись не удалась: "+err.Error())
|
||
return
|
||
}
|
||
outPath := "/var/lib/bj/.bj/test-sign.p7s"
|
||
if err := os.MkdirAll(filepath.Dir(outPath), 0o700); err == nil {
|
||
_ = os.WriteFile(outPath, sig, 0o600)
|
||
}
|
||
b64Preview := base64.StdEncoding.EncodeToString(sig)
|
||
if len(b64Preview) > 80 {
|
||
b64Preview = b64Preview[:80] + "..."
|
||
}
|
||
setupFlash(w, r, fmt.Sprintf("Пробная подпись OK: CMS detached, %d байт. Сохранена в %s. Префикс base64: %s",
|
||
len(sig), outPath, b64Preview))
|
||
}
|
||
|
||
// SetupData — данные для шаблона admin_setup.html.
|
||
type SetupData struct {
|
||
page
|
||
Settings Settings
|
||
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
|