diff --git a/internal/lkgateway/admin.go b/internal/lkgateway/admin.go index 4efe903..5b75999 100644 --- a/internal/lkgateway/admin.go +++ b/internal/lkgateway/admin.go @@ -55,6 +55,7 @@ var templateFuncs = template.FuncMap{ "realTest": realTestGet, "realNSDAs": realNSDAsText, "quikRecipientsInfo": quikRecipientsInfo, + "quikNeedsTrust": quikNeedsTrust, "lkPortfolioStates": lkPortfolioStates, "backupStatus": backupStatus, "backupWhen": backupWhen, diff --git a/internal/lkgateway/letters.go b/internal/lkgateway/letters.go index 144b726..c488dde 100644 --- a/internal/lkgateway/letters.go +++ b/internal/lkgateway/letters.go @@ -64,12 +64,11 @@ func letterFor(s Settings, key string) (Letter, bool) { Subject: "Заявка на подключение к промышленному контуру для переводов M2M — " + name, Body: fmt.Sprintf("В НКО АО НРД\n(служба поддержки электронного документооборота)\n\n%s (ИНН %s, код участника %s) завершила тестирование обмена сообщениями о переводах ценных бумаг M2M на контуре TEST3 и просит подключить промышленный контур электронного документооборота НРД.\n\nСведения для подключения:\n%s\n— ключ электронной подписи для промышленного контура: выдан УЦ Московской биржи [номер сертификата, срок действия]\n\nЕсли для подключения установлена форма заявки — просим её направить, а это письмо считать сопроводительным.%s", name, inn, code, req, sign)}, true - case "arqa": - ip := pickStr(outboundIP(s.Quik.Server, s.Quik.Port), firstServerIP()) + case "arqa": // коротко и по делу: о внутреннем устройстве (bj, процедуры, классы) поставщику знать не нужно return Letter{Key: key, Title: "Письмо в ARQA Technologies: модуль неторговых поручений", To: "ARQA Technologies, служба поддержки QUIK", - Subject: "Подключение модуля неторговых поручений QUIK для переводов M2M — " + name, - Body: fmt.Sprintf("В ARQA Technologies\n(служба поддержки QUIK)\n\n%s (ИНН %s) подключает систему Bridge&Join для переводов ценных бумаг M2M через НРД. Система работает с модулем неторговых поручений (МНП) QUIK через его базу данных. Просим:\n\n1. Подключить и настроить модуль неторговых поручений для переводов M2M:\n — классы инструментов INSTROUTM2M (вывод ценных бумаг) и INSTRINM2M (ввод ценных бумаг);\n — типы неторговых поручений для M2M (100/101) в QAdmin;\n — справочник депозитариев-получателей M2MDepoRecipients: его заполняет внешняя система.\n2. Выдать SQL-логин для внешней системы с правами API модуля (роль InstrEu): процедуры sp_NewInstrSec, sp_SignInstrSec, cancel_instruction; таблицы Export, Contracts, M2MDepoRecipients.\n3. Разрешить подключение к базе данных МНП с сервера Bridge&Join (адрес %s) и сообщить адрес сервера, порт и имя базы.\n4. После первого заполнения справочника M2MDepoRecipients перезапустить модуль неторговых поручений: справочник читается при запуске.%s", - name, inn, orBracket(ip, "IP-адрес сервера bj"), sign)}, true + Subject: "Подключение модуля неторговых поручений QUIK — " + name, + Body: fmt.Sprintf("В ARQA Technologies\n(служба поддержки QUIK)\n\n%s (ИНН %s) просит:\n\n1. Подключить модуль неторговых поручений QUIK (переводы ценных бумаг M2M).\n2. Выдать логин и пароль для подключения к базе данных модуля неторговых поручений.\n3. Разрешить подключение к базе данных модуля с нашего адреса %s и сообщить адрес сервера, порт и имя базы.%s", + name, inn, arqaAddr(publicIP()), sign)}, true case "dba": ip := pickStr(outboundIP(s.Fancy.Server, s.Fancy.Port), firstServerIP()) f := s.Fancy @@ -89,6 +88,16 @@ func letterFor(s Settings, key string) (Letter, bool) { return Letter{}, false } +// arqaAddr — наш внешний адрес для письма в ARQA с пометкой для отправителя: подключение разрешат только +// с этого адреса, поэтому он должен быть постоянным (у провайдера адрес может меняться). Пометку перед +// отправкой удаляют — как и всё в квадратных скобках. +func arqaAddr(ip string) string { + if ip == "" { + return "[постоянный внешний IP-адрес вашего сервера — уточните у сетевого администратора]" + } + return ip + " [убедитесь, что это постоянный внешний адрес вашей организации и он не меняется — уточните у сетевого администратора; пометку удалите]" +} + func firstServerIP() string { _, ips := localAddrs() for _, ip := range ips { diff --git a/internal/lkgateway/onboarding_test.go b/internal/lkgateway/onboarding_test.go index 6e6a797..5c62bed 100644 --- a/internal/lkgateway/onboarding_test.go +++ b/internal/lkgateway/onboarding_test.go @@ -462,6 +462,13 @@ func TestDumpOnboardingPages(t *testing.T) { h.renderWizard(w, httptest.NewRequest(http.MethodGet, fmt.Sprintf("/admin/wizard?step=%d", step), nil)) _ = os.WriteFile(filepath.Join(dir, fmt.Sprintf("wizard-%02d.html", step)), w.Body.Bytes(), 0o644) } + // шаг QUIK после проверки подключения — выбор «Как bj работает с QUIK» + q := rc.Snapshot().Quik + q.Server, q.User, q.Verified = "91.209.122.88", "BROKER_88", true + _ = rc.UpdateQuik(q) + w := httptest.NewRecorder() + h.renderWizard(w, httptest.NewRequest(http.MethodGet, "/admin/wizard?step=5", nil)) + _ = os.WriteFile(filepath.Join(dir, "wizard-05-verified.html"), w.Body.Bytes(), 0o644) var b strings.Builder d := PrepareData{page: page{Title: "Подготовка модулей", Active: "wizard"}, Tenant: "АО «Пример Инвест»", Plan: "enterprise", Until: "поддержка до 06.10.2027", Next: "/admin/wizard?step=12", Flash: "Администратор создан."} if err := a.prepare.ExecuteTemplate(&b, "layout", d); err != nil { @@ -644,3 +651,82 @@ func TestSaveOrgWithRequisites(t *testing.T) { t.Fatalf("неверный счёт — ничего не сохраняется: %+v", s) } } + +// Письмо в ARQA — коротко: организация просит подключить модуль, выдать логин и пароль и разрешить подключение +// с нашего внешнего адреса; о bj и внутреннем устройстве ни слова. Внешний адрес неизвестен — место для него. +func TestArqaLetter(t *testing.T) { + publicIPState.Lock() + publicIPState.ip, publicIPState.at = "195.239.71.22", time.Now() + publicIPState.Unlock() + defer func() { + publicIPState.Lock() + publicIPState.ip, publicIPState.at = "", time.Time{} + publicIPState.Unlock() + }() + var s Settings + s.NSD.OrgName, s.NSD.OrgINN, s.NSD.OrgCode = "ООО \"ИК \"Фонтвьель\"", "7703807489", "MC0413600000" + l, ok := letterFor(s, "arqa") + if !ok { + t.Fatal("нет письма arqa") + } + for _, bad := range []string{"Bridge", "bj", "InstrEu", "sp_NewInstrSec", "M2MDepoRecipients", "10.10."} { + if strings.Contains(l.Body, bad) { + t.Fatalf("в письме лишнее %q:\n%s", bad, l.Body) + } + } + for _, want := range []string{"7703807489", "просит", "логин и пароль", "с нашего адреса 195.239.71.22 [убедитесь, что это постоянный внешний адрес"} { + if !strings.Contains(l.Body, want) { + t.Fatalf("в письме нет %q:\n%s", want, l.Body) + } + } + publicIPState.Lock() + publicIPState.ip = "" + publicIPState.Unlock() + if l, _ := letterFor(s, "arqa"); !strings.Contains(l.Body, "[постоянный внешний IP-адрес вашего сервера") { + t.Fatalf("внешний адрес неизвестен — место для него:\n%s", l.Body) + } +} + +// Мастер, QUIK: один выбор вместо набора флажков; тонкая настройка не трогается. Ошибка сертификата — подсказка. +func TestQuikModeAndCertHint(t *testing.T) { + priv := testLicenseKey(t) + rc := testRC(t) + now := time.Now().UTC() + tok := signTestLicense(t, priv, &license.License{Schema: 1, ID: "lic-1", Tenant: "Тест", Product: "bj-server", Plan: license.PlanPro, + IssuedAt: now, ExpiresAt: now.Add(30 * 24 * time.Hour), ContractUntil: now.AddDate(1, 0, 0), Features: []string{"updates", "quik-mnp"}}) + if err := rc.SaveLicense(LicenseSettings{Key: tok}); err != nil { + t.Fatal(err) + } + old := globalRC + globalRC = rc + defer func() { globalRC = old }() + q := rc.Snapshot().Quik + q.PollTerminal, q.SecOut, q.ClientCodeMap = true, "RUROUTM2M", map[string]string{"БР-1": "BR1"} + if err := rc.UpdateQuik(q); err != nil { + t.Fatal(err) + } + h := &setupHandlers{rc: rc} + post := func(form url.Values) string { + r := httptest.NewRequest(http.MethodPost, "/admin/setup/quik/mode", strings.NewReader(form.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + r.Header.Set("X-BJ-Ajax", "1") + w := httptest.NewRecorder() + h.saveQuikMode(w, r) + return w.Body.String() + } + if msg := post(url.Values{"mode": {"auto"}, "account": {"L01-00000F00"}}); !strings.Contains(msg, "автоматически") { + t.Fatalf("ответ: %q", msg) + } + s := rc.Snapshot().Quik + if !s.WritesEnabled || !s.AutoOut || !s.AutoIn || s.Account != "L01-00000F00" || !s.PollTerminal || s.SecOut != "RUROUTM2M" || s.ClientCodeMap["БР-1"] != "BR1" { + t.Fatalf("автоматически: %+v", s) + } + post(url.Values{"mode": {"check"}}) + if s := rc.Snapshot().Quik; s.WritesEnabled || s.AutoOut || s.AutoIn || s.Account != "L01-00000F00" { + t.Fatalf("только сверка (счёт не стирается): %+v", s) + } + if !quikCertError(errors.New("TLS Handshake failed: tls: failed to verify certificate: x509: cannot validate certificate for 91.209.122.88 because it doesn't contain any IP SANs")) || + quikCertError(errors.New("mssql: login error: Login failed for user 'BROKER_88'")) { + t.Fatal("ошибка сертификата распознаётся, ошибка логина — нет") + } +} diff --git a/internal/lkgateway/publicip.go b/internal/lkgateway/publicip.go new file mode 100644 index 0000000..e3413f0 --- /dev/null +++ b/internal/lkgateway/publicip.go @@ -0,0 +1,74 @@ +package lkgateway + +// Внешний адрес сервера bj (06.10.2026) — каким его видят снаружи. Нужен для письма в ARQA: поставщика просят +// разрешить подключение к модулю неторговых поручений с нашего адреса, и внутренний адрес (10.x, 192.168.x) +// ему ничего не даёт. Изнутри сети внешний адрес не узнать, поэтому спрашиваем наш же сервер лицензий +// (GET /v1/ip — он видит, откуда пришёл запрос); сторонние «узнать мой IP» для сервера финансовой +// организации не годятся. Раз в час, в фоне: письмо строится при показе страницы и сеть не ждёт. + +import ( + "context" + "io" + "log" + "net" + "net/http" + "strings" + "sync" + "time" +) + +var publicIPState struct { + sync.Mutex + ip string + at time.Time +} + +// publicIP — последний известный внешний адрес (пусто — сервер лицензий недоступен, например закрытый контур). +func publicIP() string { + publicIPState.Lock() + defer publicIPState.Unlock() + if time.Since(publicIPState.at) > 24*time.Hour { + return "" + } + return publicIPState.ip +} + +// publicIPRefresh — спросить внешний адрес у сервера лицензий. +func publicIPRefresh(ctx context.Context) error { + cctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(cctx, http.MethodGet, licenseServer+"/v1/ip", nil) + if err != nil { + return err + } + resp, err := licenseHTTP.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + b, _ := io.ReadAll(io.LimitReader(resp.Body, 64)) + ip := net.ParseIP(strings.TrimSpace(string(b))) + if resp.StatusCode != http.StatusOK || ip == nil { + return &net.AddrError{Err: "сервер лицензий не сообщил внешний адрес", Addr: resp.Status} + } + publicIPState.Lock() + changed := publicIPState.ip != ip.String() + publicIPState.ip, publicIPState.at = ip.String(), time.Now() + publicIPState.Unlock() + if changed { + log.Printf("lk-gateway: внешний адрес сервера (по серверу лицензий): %s", ip) + } + return nil +} + +// publicIPLoop — внешний адрес при запуске и раз в час (адрес у провайдера может смениться). +func publicIPLoop(ctx context.Context) { + for { + _ = publicIPRefresh(ctx) + select { + case <-ctx.Done(): + return + case <-time.After(time.Hour): + } + } +} diff --git a/internal/lkgateway/quikdb_handlers.go b/internal/lkgateway/quikdb_handlers.go index 43833ef..696be48 100644 --- a/internal/lkgateway/quikdb_handlers.go +++ b/internal/lkgateway/quikdb_handlers.go @@ -17,6 +17,7 @@ func (h *setupHandlers) registerQuik(mux *http.ServeMux) { mux.HandleFunc("/admin/setup/quik/test", h.testQuik) mux.HandleFunc("/admin/quik", h.quikInstructions) mux.HandleFunc("/admin/setup/quik/cycle", h.saveQuikCycle) + mux.HandleFunc("/admin/setup/quik/mode", h.saveQuikMode) } // quikInstructions — GET /admin/quik. Поручения M2M из БД МНП QUIK. @@ -122,6 +123,43 @@ func (h *setupHandlers) saveQuikCycle(w http.ResponseWriter, r *http.Request) { setupFlash(w, r, "QUIK МНП: цикл M2M сохранён — "+mode) } +// saveQuikMode — POST /admin/setup/quik/mode (мастер): как bj работает с QUIK — одним выбором вместо набора +// флажков. check — только сверка (в МНП bj не пишет, поручения вводят сотрудники); auto — bj ведёт поручения +// сам: исходящие (блокировка, списание или снятие) и входящие (зачисление). Тонкая настройка (инструменты, +// коды клиентов, поручения из терминала, имитация) — в «Настройке», здесь не трогается. Экран сохраняет +// выбор сразу (X-BJ-Ajax — ответ строкой), без JavaScript — обычной формой. +func (h *setupHandlers) saveQuikMode(w http.ResponseWriter, r *http.Request) { + if !requireAdvanced(w, r) { + return + } + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + s := h.rc.Snapshot().Quik + auto := r.FormValue("mode") == "auto" + s.WritesEnabled, s.AutoOut, s.AutoIn = auto, auto, auto + if r.PostForm.Has("account") { + s.Account = strings.TrimSpace(r.FormValue("account")) + } + msg := "только сверка — в QUIK bj ничего не пишет" + if auto { + msg = "автоматически — bj ведёт поручения в QUIK сам" + if s.Account == "" { + msg += "; укажите торговый счёт" + } + } + if err := h.rc.UpdateQuik(s); err != nil { + msg = "не сохранилось: " + err.Error() + } + if r.Header.Get("X-BJ-Ajax") != "" { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = w.Write([]byte(msg)) + return + } + setupFlash(w, r, "QUIK: "+msg) +} + func onOff(b bool) string { if b { return "авто" @@ -168,6 +206,21 @@ func (h *setupHandlers) saveQuik(w http.ResponseWriter, r *http.Request) { setupFlash(w, r, fmt.Sprintf("QUIK МНП: настройки сохранены (%s:%d, логин %s)", s.Server, s.Port, s.User)) } +// quikCertHint — что делать, когда сервер МНП не прошёл проверку сертификата: у QUIK сертификат обычно свой +// (самоподписанный или на имя сервера, а подключаемся по IP). +const quikCertHint = "сертификат сервера МНП выписан не на этот адрес или самоподписанный (так у QUIK обычно и бывает). Включите «Доверять сертификату сервера» в «Дополнительно: база и шифрование» и проверьте ещё раз — соединение останется зашифрованным." + +// quikCertError — подключение не прошло из-за проверки сертификата сервера (не из-за сети или логина). +func quikCertError(err error) bool { + m := strings.ToLower(err.Error()) + return strings.Contains(m, "x509:") || strings.Contains(m, "failed to verify certificate") || strings.Contains(m, "certificate signed by unknown authority") +} + +// quikNeedsTrust — последняя проверка упала на сертификате, а доверие выключено: раскрыть «Дополнительно». +func quikNeedsTrust(s QuikMNPSettings) bool { + return !s.Verified && !s.Trust && strings.Contains(s.LastReport, "Что сделать: сертификат") +} + // testQuik — POST /admin/setup/quik/test. Тест-подключение (только чтение). // Формирует ПОЛНЫЙ отчёт (параметры + ответ сервера) для копирования коллегам. func (h *setupHandlers) testQuik(w http.ResponseWriter, r *http.Request) { @@ -195,10 +248,18 @@ func (h *setupHandlers) testQuik(w http.ResponseWriter, r *http.Request) { if err != nil { fmt.Fprintf(&b, "РЕЗУЛЬТАТ: ОШИБКА ПОДКЛЮЧЕНИЯ\n\n") b.WriteString(quikErrorDetail(err)) + cert := quikCertError(err) && !s.Trust + if cert { + b.WriteString("\n\nЧто сделать: " + quikCertHint) + } s.Verified = false s.LastReport = b.String() s.LastAt = now.UTC() _ = h.rc.UpdateQuik(s) + if cert { + setupFlash(w, r, "QUIK МНП: "+quikCertHint) + return + } setupFlash(w, r, "QUIK МНП: подключение не удалось — полный ответ сервера ниже (можно скопировать для коллег)") return } diff --git a/internal/lkgateway/server.go b/internal/lkgateway/server.go index cd1120c..c366cb4 100644 --- a/internal/lkgateway/server.go +++ b/internal/lkgateway/server.go @@ -365,6 +365,7 @@ func (s *Server) Run(ctx context.Context) error { // Лицензия: ежесуточное продление ключа на сервере лицензий lic.zetit.ru. go superviseLoop(ctx, "license", s.rc, s.licenseLoop) + go publicIPLoop(ctx) // внешний адрес сервера — для письма в ARQA (у сервера лицензий, раз в час) // Авто-обновление сертификатов УЦ раз в сутки (если оператор включил). stopCACerts := StartCACertsAutoUpdater(s.rc) diff --git a/internal/lkgateway/web/templates/admin_wizard.html b/internal/lkgateway/web/templates/admin_wizard.html index 55c20a3..fdf48cd 100644 --- a/internal/lkgateway/web/templates/admin_wizard.html +++ b/internal/lkgateway/web/templates/admin_wizard.html @@ -86,6 +86,10 @@ .dq-jobname { margin-top:14px; max-width:520px; } .dq-jobname label { display:block; margin:0; font-size:13.5px; font-weight:500; color:var(--text-2); } .dq-jobname input { display:block; width:100%; box-sizing:border-box; margin-top:5px; } +.wiz-memo { margin:10px 0 0; font-size:13.5px; } +.wiz-memo summary { cursor:pointer; color:var(--accent); } +.wiz-memo ul { margin:6px 0 8px; padding-left:20px; } +.wiz-memo li { margin:3px 0; } .dq-box { border:1px solid var(--border); border-radius:12px; padding:14px 16px 16px; } .dq-box-bj { background:color-mix(in srgb, var(--accent-weak) 45%, transparent); } .dq-box-h { font-weight:600; font-size:14.5px; margin:0 0 10px; } @@ -462,13 +466,49 @@
Адрес и логин модуля неторговых поручений — у вашего администратора QUIK. Если модуля ещё нет, его подключает ARQA Technologies — письмо для них готово ниже.
- {{if .DepoLicensed}}{{template "letterCard" (letter "arqa")}}{{end}} + {{if .DepoLicensed}}{{template "letterCard" (letter "arqa")}} +Всё это описано в документации ARQA на модуль неторговых поручений — администратору QUIK она знакома:
+INSTROUTM2M (вывод ценных бумаг) и INSTRINM2M (ввод), типы неторговых поручений M2M;InstrEu — его логин и пароль вписываются ниже;M2MDepoRecipients заполняет bj; после первого заполнения модуль нужно перезапустить.Модуль неторговых поручений не входит в текущую лицензию — шаг пропускается.
{{else}}{{with .Fancy}}{{template "quikSection" .}}{{end}} {{if .Settings.Quik.Server}}{{template "mnpCheck" .}}{{end}} {{if .Settings.Quik.M2MMissing}}✗ Подключение есть, но под этим логином не видно объектов M2M: {{.Settings.Quik.M2MMissing}}. Проверьте имя базы МНП и права логина (роль InstrEu) и нажмите «Проверить» ещё раз.
{{end}} -Чтобы QUIK принимал поручения M2M, bj ведёт в МНП справочник депозитариев-получателей (M2MDepoRecipients) по справочнику участников НРД. МНП читает его только при запуске — после первого заполнения попросите администратора QUIK перезапустить модуль, иначе QUIK ответит «Неизвестный депозитарий получателя».
{{quikRecipientsInfo}}
{{quikRecipientsInfo}} После первого заполнения справочника депозитариев-получателей попросите администратора QUIK перезапустить модуль неторговых поручений — QUIK читает справочник только при запуске.
+Когда подключение проверено, здесь появится выбор, как bj работает с QUIK.
{{end}}{{end}} {{template "wizNav" .}}Статус: {{if .Settings.Quik.Verified}}✓ подключение проверено{{else}}не проверено{{end}}
Что делает bj в Модуле неторговых поручений по ходу перевода: исходящий — при отправке в НРД регистрирует «Вывод ЦБ (M2M)» (QUIK блокирует бумаги), по подтверждению подписывает (списание), по отказу снимает с причиной; входящий — после приёма нашего поручения НРД регистрирует «Ввод ЦБ (M2M)» (зачисление сразу). Каждый шаг виден в карточке перевода и на странице QUIK МНП.
- {{if .InWizard}}