35 Commits

Author SHA1 Message Date
zuevav 4b18579956 Кабинет: операции по счетам депо и доходы с удержанным налогом из учётной системы
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 23:11:34 +03:00
zuevav 26785ff3ba Кабинет: оценка портфеля по ценам QUIK и карточка бумаги из справочника депозитария
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 23:03:31 +03:00
zuevav f057b89d2e Личный кабинет: оформление «Табло», разделы Табло/Реестр/Поручения/Профиль, поручение как вид
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 22:51:54 +03:00
zuevav ea869665df DESIGN.md: мир «Табло» для клиентского кабинета
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 22:41:23 +03:00
zuevav 1f12167946 Экспорт QUIK: очистка таблиц-срезов (ClearTables) для включённых пакетов
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 21:56:10 +03:00
zuevav 10e765b5b1 Экспорт QUIK: отметка «данные поступают» по непустым таблицам базы
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 21:45:06 +03:00
zuevav 893e1222ab Экспорт QUIK: настройка пакетами данных вместо правки export.ini
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 21:43:41 +03:00
zuevav 1fbc5d2375 Справочник участников: выпадающий список с поиском
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 20:04:40 +03:00
zuevav a9dc14969b Справочник участников: поиск по ИНН/названию/коду вместо списка (ручной ввод, организация); ручной ввод — только НРД
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 20:02:42 +03:00
zuevav 634244ee28 DEPO: версионная карточка лица — счета по SELF_ID (поиск в ручном вводе и сверка)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 19:59:08 +03:00
zuevav 1d0f904010 Своя организация в настройках (справочник НРД или вручную) вместо зашитого ИНН; ручной ввод: поиск клиента и бумаг в DEPO, одна бумага на поручение
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 19:51:04 +03:00
zuevav e4de1ab4d8 Раздел «Ручной ввод M2M» по бумажным поручениям; тестовые тексты и имитации убраны из клиентского интерфейса
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 18:26:59 +03:00
zuevav 627dbca6af Режим разработки: служебные разделы, робот НРД и имитации — только по признаку из ключа лицензии
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 18:15:18 +03:00
zuevav 698956092b Живой тест вставки события FTV_M2M_EVENTS в новом формате (с откатом)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 12:55:42 +03:00
zuevav 059206348f DEPO: события по ответу программиста bj2depo — без COMMENT/STATE, параметры в PARAMETERS, время события из bj
COMMENT и STATE в трансферных таблицах заполняет только bj2depo: bj их больше не пишет (шапка,
события, BJ_PING). Параметры событий — в PARAMETERS (CONTR_D_ID поручения НРД, номер поручения МНП,
текст причины отказа), EVENT_DATE — фактическое время события в bj. Событий от bj2depo нет: bj не
читает и не помечает DEPO_*, номер документа берёт из FTV_M2M.DOC. Страница «bj ↔ bj2depo» обновлена:
поля, правила, FTV_M2M_PROCESS_WIRING по BJ_ORDER_ACCEPTED/BJ_EXECUTED, примеры SQL, закрытые и
оставшиеся вопросы.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 12:54:50 +03:00
zuevav 28c0ed7349 Документы НРД по поручению: папки с понятными названиями, опись и PDF
Пакеты НРД из INBOX ИШ (K…, 508…) распознаются по содержимому: квитанция о регистрации (или отказ),
уведомление GS036, отчёт о статусе GS116 (сквитовано / не сквитовано), отчёт об исполнении MS010,
ошибка. В ZIP ответа НРД и выгрузках для регулятора (16 и 16/1) — папки «01 Квитанция о регистрации
поручения — K1699092» и т. д., «Опись.txt» с назначением и реквизитами каждого документа и
«Документы НРД.pdf» (читаемое представление, печать через Chromium; без него — HTML). В карточке
перевода ответы НРД подписаны так же.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 16:26:25 +03:00
zuevav 6a2e9b7122 DEPO: в строках FTV_M2M_LINES заполняется DEPO_NUMBER — торговый счёт клиента ВТНКЦ (самый ранний договор), чтобы джоба привязала счёт к поручению
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 16:14:32 +03:00
zuevav 2601d29018 Тестирование M2M: после изменения количества отправка заблокирована до пересчёта сверки; количество — в подтверждении отправки
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 16:07:02 +03:00
zuevav e4e262de9e Сверка DEPO: квалификация по признаку бумаги — входящие: зелёный/отказ M2M08, исходящие: зелёный/предупреждение (решает получатель); общая жёлтая строка убрана
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 16:01:51 +03:00
zuevav f35c72c50a Тестирование M2M: количество бумаг выбирается в предпросмотре — сверка DEPO и отправка берут его
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 15:56:03 +03:00
zuevav cfd6dfa1e8 Карточка входящего: отклонённый перевод — 16/1 не требуется, операция завершена; черновик отмечается только если реально получен
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 15:47:07 +03:00
zuevav 7288c86fba Сверка DEPO: физлицо в картотеке без торгового счёта — не наш клиент, отказ M2M04 (а не M2M02)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 15:26:33 +03:00
zuevav d47229c9a5 Автоматический режим M2M и имитация QUIK МНП
Автоматический режим (Настройка → НРД, выключен по умолчанию): входящие переводы — решение по свежей
сверке с DEPO без оператора (пакет только целиком; бумага для квал. инвесторов при неполной или
непрочитанной квалификации — отказ M2M08), поручение 16/1 — как только пришёл черновик MF010,
поручение 16 по исходящим — по черновику. Нет сверки или ИШ — повтор каждые 3 минуты и оповещение,
без сверки ничего не принимается. Обрабатывается только появившееся не раньше суток до включения
(старые черновики в INBOX не подаются). Решение не отправляется повторно по уже отвеченному переводу.

Имитация QUIK МНП (Настройка → QUIK МНП → цикл): регистрация, подпись, снятие и зачисление проходят
как выполненные с пометкой «имитация», в МНП ничего не пишется; события в DEPO — как обычно.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 14:59:23 +03:00
zuevav 4b62d00ac2 Входящие M2M: пакет только целиком и отказ M2M08 для бумаг «для квалифицированных инвесторов»
Сверка с DEPO: у бумаги проверяется опция RESTRICTED («Ограничение ценной бумаги по обращению»,
1 — предназначена для квалифицированных инвесторов). Если инвестор не квалифицирован — отказ M2M08;
квалифицирован по всем видам — проходит; по части видов — предупреждение, решает оператор.

Пакет принимается только целиком (решение 16.09): при отказе хотя бы по одной бумаге рекомендация —
отказ по всем с кодом первой отказной, и так же нормализуется отправляемое решение (packageDecisions);
в форме выбор отказа по одной бумаге переключает на отказ все.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 14:45:43 +03:00
zuevav be0081c139 Тест M2M: К1 снова Бубликов 3453 289090 — ГроттБьерн использует тех же клиентов, что ЗЕНИТ
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 13:59:36 +03:00
zuevav 42f5883b4a Лицензия: предупреждения заранее об окончании и о смене версий внешних систем
За 30, 14, 7 и 1 день до окончания договора (офлайн-ключ или застрявшее продление — до конца
ключа) bj шлёт уведомление: что продолжит работать, а что отключится. Каждый порог — один раз,
при продлении договора отсчёт заново; проверка раз в час в licenseLoop.

После каждого опроса версий bj запоминает версии внешних систем (МНП QUIK, ИШ, DEPO, экспортёр
ARQA, «Валидата», Java, PostgreSQL, ОС). Если обновления bj недоступны и версия изменилась —
уведомление, что совместимость с новой версией не гарантируется.

Решение: после окончания лицензии цикл QUIK МНП и личный кабинет продолжают работать.
Состояние — /var/lib/bj/.bj/license-watch.json.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 12:19:47 +03:00
zuevav 7c653a2490 Версии систем: общий список в «Обзоре» и строка в разделе каждого модуля
bj собирает версии своих компонентов и внешних систем (в фоне, кэш 10 минут, прогрев при старте):
ядро и модули bj (components.json или «-version»), экспортёр ARQA (служба bj-quikexport, /version),
криптосервис (Health), «Валидата CSP» (zpki1utl), Java, ИШ НРД (deps.json + сайт НРД),
МНП QUIK (VersionInfo), DEPO (OD_DATA_VERSION), PostgreSQL, ОС. «Настройка → Обзор» — таблица
«Версии систем» с кнопкой «Обновить сведения»; в разделах БД, Fancy, QUIK МНП, Экспорт QUIK,
Криптография, НРД, ЛК, НПР и Обновления — строка с версией. В таблице обновлений у компонентов,
выложенных вручную, вместо «неизвестна» — версия самого компонента. bj-component-install: -version.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 12:00:00 +03:00
zuevav 6fee3c5ff7 Личный кабинет — модуль modules/lk: сборка в релизе, unit в комплекте, ключ вне unit-файла
Код bj-lk перенесён в modules/lk (cmd/bj-lk — запуск, флаг -version); релиз собирает bj-lk
со своей версией (LK_VERSION), unit bj-lk.service встроен в комплект установщика и берёт
адрес bj-server и ключ доступа из /etc/bj/lk.env, а не из unit-файла.

Исправлено: признаки «администратор» и «кабинет клиента» читались из общей переменной
последнего запроса (curReq) — при одновременной работе признак мог достаться чужому запросу.
Теперь из кук своего запроса. Без BJ_URL/BJ_KEY кабинет показывает подсказку настройки
вместо падения службы. Тесты: вход, портфель, health, флаг админа по запросу, без настройки.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 01:21:01 +03:00
zuevav 97e99b72c7 Экспорт QUIK — отдельная служба bj-quikexport; модули, выделенные из ядра, ставятся с обновлением
bj-quikexport (modules/quikexport + cmd/bj-quikexport, 127.0.0.1:8084): управление ARQA
quikexport перенесено из bj-server. Настройки у службы в /var/lib/bj/.bj/quik-export/settings.json
(при первом запуске переносятся из setup.json), раздел отдаётся фрагментом с индикатором меню,
действия отвечают X-BJ-Flash. bj-server встраивает раздел в «Настройку», проксирует формы
/admin/setup/quikexport/… и передаёт лицензию (X-BJ-Licensed). Общая проверка служебного
ключа — modules/modkit (им пользуется и bj-npr), общее для прокси — lkgateway/modules.go.

Выделенные из ядра модули (bj-npr, bj-quikexport: Component.FromCore) при обновлении ставятся и
там, где их ещё нет, если входят в лицензию: release.MarkSplitModules, request.json "new",
root-установщик ставит и включает unit-файл из комплекта. Если в обновлении меняется сам
установщик, модули ставятся после перезапуска bj-server (ensureSplitModules).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 01:15:27 +03:00
zuevav 5527a464ca НПР / УДС — отдельная служба bj-npr
Модуль вынесен в modules/npr + cmd/bj-npr (127.0.0.1:8083), обновляется
независимо от ядра. Служба читает подключение из setup.json и принимает
запросы только со служебным ключом модулей (/var/lib/bj/.bj/module.key,
создаёт bj-server). Полные страницы отдаются фрагментом (X-BJ-Layout: page).

bj-server хранит настройки и проверку подключения, проксирует /admin/npr
после входа, прав и лицензии и встраивает страницы модуля в общий макет
(admin.modulePage); если служба не отвечает — понятная страница.
Unit bj-npr.service встроен в установщик, установщик включает службы
установленных модулей; build-release.sh собирает bj-npr.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 01:00:31 +03:00
zuevav d8dee07213 feat(iso): образ Bridge&Join на основе Debian 13 netinst
- deploy/iso/build-iso.sh: пересборка официального netinst (xorriso, загрузочные записи
  BIOS и UEFI сохраняются), пункт по умолчанию «Установить Bridge&Join», текстовый установщик
- preseed.cfg: русская локаль, Москва, DHCP, SSH и пакеты для bj; спрашиваются только
  пароли, диск и подтверждение стирания
- bj-firstboot: при первой загрузке на экране сервера спрашивает ключ продукта и запускает
  bj-installer; адрес первичной настройки и код остаются на экране входа

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 00:48:56 +03:00
zuevav 4135435b51 feat(install): консольный установщик bj и первичная настройка по одноразовому коду
- cmd/bj-installer (вместо веб-мастера): проверка Debian 13 / Astra, пакеты, пользователь и
  каталоги, активация лицензии ключом продукта, PostgreSQL и миграции m2m_core, компоненты
  по лицензии с проверкой подписи, root-скрипты и службы, одноразовый код и итоговый экран
  с адресом http://<сервер>:8080/first-run; проверка в песочнице (--steps, --root)
- bj: /first-run — пока нет пользователей и установщик оставил код, по коду создаётся
  первый администратор (прежние установки без файла работают как раньше)
- internal/release/install.go — общая установка компонентов (bj-component-install и
  установщик); bj-component-install стал компонентом ядра
- открытые ключи вынесены в internal/license и internal/release; миграции и файлы
  развёртывания встроены (migrations/embed.go, deploy/embed.go); unit bj-server исправлен
- scripts/build-release.sh: сборка с версиями, подпись, публикация на сервер обновлений

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 23:56:44 +03:00
zuevav 8099e50ab2 feat(update): обновления с сервера лицензий по лицензии установки
- по умолчанию https://lic.zetit.ru/updates; клиент артефактории передаёт лицензионный
  токен установки (Authorization: Bearer), сервер отдаёт модули по признакам лицензии
- компоненты получили признак лицензии (ядро и криптосервис — updates, ЛК — web-cabinet,
  НПР — npr, экспорт QUIK — quik-export)
- в bj зашит открытый ключ подписи релизов (закрытый хранится вне сервера лицензий)
- проверено сквозной установкой bj-lk 0.2.0 из канала beta

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 20:43:56 +03:00
zuevav 2908697363 feat(update): независимые обновления компонентов bj
- internal/release/components.go: список компонентов (ядро bj-server, криптосервис, bj-lk,
  bj-npr, bj-quikexport) с путём и службой, установленные версии в components.json,
  расчёт обновлений по версии каждого файла и sha256 (без отката, модули — только если
  уже установлены на сервере)
- cmd/bj-component-install: root-установщик — сам перепроверяет подпись манифеста ключом
  /etc/bj/update.pub и sha256, копия прежнего файла, перезапуск только затронутых служб
- updater: bj-server (без прав root) скачивает в update-staging и передаёт установку
  root-воркеру (действие component-install); установка в фоне
- настройки: таблица компонентов с версиями, «Обновить» и «Обновить всё»
- bj-release: версия файла из <файл>.version; bj-keymedia-worker: component-install

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 20:16:36 +03:00
zuevav 5115ed7223 refactor: сервер лицензий вынесен в репозиторий lic_Bridge-and-Join-s
cmd/bj-license-server, cmd/bj-license и deploy/license перенесены в zuevav/lic_Bridge-and-Join-s
(наша инфраструктура, клиентам не поставляется). Здесь остаются клиентская часть
лицензирования, формат токена internal/license и контракт API в docs.
TestTokenCompat — одинаковый тест формата токена в обоих репозиториях.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 20:07:14 +03:00
172 changed files with 11793 additions and 8396 deletions
+33
View File
@@ -75,3 +75,36 @@ near-black в тёмной теме (настоящее рабочее мест
доступность). Постранично ещё не подняты: `admin_setup`, `admin_help_*`,
`admin_wizard`, `admin_claim` — они наследуют систему, но их частная композиция
(плотные формы, help-простыни) ждёт отдельного прохода.
## Личный кабинет клиента — мир «Табло» (17.09.2026)
Клиентский ЛК (`modules/lk`, служба `bj-lk`) получает собственную поверхность
внутри той же системы. Дизайн-холст: https://claude.ai/artifact/NAW1ak8cGgZDyXjy35p9Av
**THESIS.** Кабинет — табло отправлений: бумаги куда-то едут, клиент читает
состояние и расписание. Отказ от ряда метрик-плиток, одинаковых карточек как
структуры страницы и серифного display-шрифта.
**OWN-WORLD.** Графитовая доска сверху (`#191A1C` шапка, `#1F2124` полоса
состояния, отбивка `#E8A33D` 3px), светлая бумага реестра `#EDEBE4`, лист формы
`#F7F5EF`. Янтарь `#E8A33D` — «в пути» и первичное действие; зелёный `#1F6B3E`
— свободно/исполнено; красный `#A32D22` — отказ; интерактив — индиго `#2C46C0`.
Волосяные линейки `#DED8CA` вместо рамок, радиус 2–3px (не 12).
**Типографика.** Две гарнитуры: Golos Text (интерфейс, кириллица) и JetBrains
Mono (все величины, коды, подписи-заголовки `.lbl` — заглавные с трекингом
0.1em). Величины всегда `font-variant-numeric: tabular-nums`. Крупная величина
на доске 38–62px, шкала интерфейса 13–26px.
**Композиция.** Шапка 58px: знак (два узла + мост) · разделы · янтарная кнопка
«Поручение M2M» (точка входа с каждого экрана) · договор · выход. Ниже — полоса
состояния. Рабочая часть — таблицы с группировкой по счетам депо, состояние в
правом столбце чипом.
**Движение.** Один авторский момент — «перекидывание шторки» (`.live`,
`@keyframes flip`, 420ms) на чипе состояния, который только что изменился;
отключается при `prefers-reduced-motion`.
**Роли.** Вход клиента и вход сотрудника — один экран с переключателем.
Сотрудник в кабинете клиента только готовит поручение, подпись кодом из СМС —
за клиентом; вход сотрудника виден клиенту в «Безопасности».
+50
View File
@@ -0,0 +1,50 @@
// Command bj-component-install — root-установщик компонентов bj (независимые обновления).
//
// bj-server работает без прав root: он скачивает подписанный манифест релиза и выбранные файлы в
// каталог подготовки и просит root-воркер (bj-keymedia-worker, действие component-install) вызвать
// этот инструмент. Проверка и установка — release.InstallStaged: подпись манифеста ключом из
// /etc/bj/update.pub (принадлежит root), sha256 файлов, только известные компоненты, без отката,
// копия прежнего файла, перезапуск служб (ядро последним), записи в /var/lib/bj/.bj/components.json.
// Выделенным из ядра модулям, которые ставятся впервые, ставит и включает unit-файл службы из комплекта.
// Вывод — JSON для bj-server.
//
// bj-component-install <каталог подготовки>
// bj-component-install -root /tmp/песочница -pub test.pub -no-restart <каталог> (проверка на копии)
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"git.zetit.ru/zuevav/Bridge-and-Join-s/deploy"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/release"
)
// version — версия установщика (go build -ldflags "-X main.version=…").
var version = "0.1.0"
func main() {
fs := flag.NewFlagSet("bj-component-install", flag.ExitOnError)
root := fs.String("root", "", "корень установки (для проверки в песочнице; по умолчанию /)")
pubPath := fs.String("pub", "/etc/bj/update.pub", "открытый ключ подписи релизов (base64 Ed25519), файл root")
noRestart := fs.Bool("no-restart", false, "не перезапускать службы")
showVersion := fs.Bool("version", false, "показать версию")
_ = fs.Parse(os.Args[1:])
if *showVersion {
fmt.Println("bj-component-install", version)
return
}
var res release.InstallResult
if pub, err := release.LoadPublicKey(*pubPath); err != nil {
res.Error = fmt.Sprintf("открытый ключ обновлений (%s): %v", *pubPath, err)
} else {
res = release.InstallStaged(fs.Arg(0), release.InstallOptions{Root: *root, PubKey: pub, Restart: !*noRestart, Units: deploy.Files})
}
b, _ := json.Marshal(res)
fmt.Println(string(b))
if !res.OK {
os.Exit(1)
}
}
+839 -82
View File
@@ -1,111 +1,868 @@
// Package main — bj-installer.
// Command bj-installer — установка bj на Debian 13 или Astra Linux.
//
// Web-инсталлятор для bj-server: на машине клиента после установки
// Debian/Astra поднимает локальный HTTP на 127.0.0.1:8181, проводит
// через 5-страничный wizard (welcome → precheck → config → install → done)
// и за кадром выполняет 20+ шагов установки Валидаты + bj-server + ИШ.
// curl -fsSL https://lic.zetit.ru/install.sh | sudo bash -s -- КЛЮЧ-ПРОДУКТА
// sudo bj-installer install --code BJ-XXXX-XXXX-XXXX [--server https://lic.zetit.ru] [--channel stable]
// sudo bj-installer install --dry-run только проверка системы, без изменений
// sudo bj-installer first-run-code новый одноразовый код первичной настройки
// bj-installer version
//
// Прогресс шагов прилетает в UI через Server-Sent Events. Каждый шаг
// идемпотентен — можно повторно запускать инсталлятор на уже настроенной
// машине, он пропустит то, что сделано.
// Установка идёт в консоли, без графической среды: проверка системы, пакеты, пользователь и каталоги,
// активация лицензии, база PostgreSQL, компоненты bj по лицензии (подпись релиза и контрольные суммы
// проверяются), root-скрипты и службы. В конце на экране — адрес первичной настройки и одноразовый код:
// дальше настройка в браузере, в самом bj.
//
// Запуск: sudo ./bj-installer [--addr 127.0.0.1:8181] [--no-browser]
// Артефакты ожидаются рядом с бинарём в каталоге ./artifacts/:
//
// artifacts/ClientL_Other/zpki-*.deb
// artifacts/ClientL_Other/zsdk-*.deb
// artifacts/bj-server (Go-бинарь)
// artifacts/crypto-service.jar (Java-сайдкар)
// artifacts/ish/igate_*.deb (ИШ НРД)
// Проверка без чистого сервера: --steps license,components --root /tmp/песочница --server http://127.0.0.1:18091
package main
import (
"bufio"
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"flag"
"fmt"
"log"
"io"
"io/fs"
"math/big"
"net"
"net/http"
"os"
"os/exec"
"os/signal"
"os/user"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"syscall"
"time"
"golang.org/x/crypto/bcrypt"
"git.zetit.ru/zuevav/Bridge-and-Join-s/deploy"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/license"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/release"
"git.zetit.ru/zuevav/Bridge-and-Join-s/migrations"
)
const banner = `
======================================================================
bj-installer — мастер установки Bridge-and-Join-s
======================================================================
`
// installerVersion — версия установщика (go build -ldflags "-X main.installerVersion=…").
var installerVersion = "0.1.0"
func main() {
addr := flag.String("addr", "127.0.0.1:8181", "адрес web-инсталлятора")
noBrowser := flag.Bool("no-browser", false, "не пытаться открыть браузер автоматически")
artifactsDir := flag.String("artifacts", "./artifacts", "каталог с дистрибутивами (Validata deb, bj-server, ish)")
flag.Parse()
const (
setupFile = "/var/lib/bj/.bj/setup.json"
usersFile = "/var/lib/bj/.bj/users.json"
firstRunFile = "/var/lib/bj/.bj/first-run.json"
bjPort = 8080
)
if os.Geteuid() != 0 {
fmt.Fprintln(os.Stderr, "Установщик должен быть запущен от root (sudo).")
os.Exit(1)
}
type installer struct {
server, channel, code string
root string // префикс файловой системы (песочница)
dryRun bool
steps map[string]bool
out *console
fmt.Print(banner)
fmt.Printf(" адрес: http://%s\n", *addr)
fmt.Printf(" артефакты: %s\n", *artifactsDir)
fmt.Println("======================================================================")
st := newState(*artifactsDir)
srv := newServer(st)
httpSrv := &http.Server{
Addr: *addr,
Handler: srv,
ReadHeaderTimeout: 10 * time.Second,
}
// SIGINT/SIGTERM → корректный shutdown
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
go func() {
if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("HTTP-сервер упал: %v", err)
}
}()
url := "http://" + *addr
log.Printf("Откройте в браузере: %s", url)
if !*noBrowser {
tryOpenBrowser(url)
}
<-ctx.Done()
log.Println("Завершаем работу...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = httpSrv.Shutdown(shutdownCtx)
token string
lic *license.License
setupCode string // одноразовый код первичной настройки (итоговый экран)
shown bool
}
// tryOpenBrowser — без фанатизма. Если xdg-open/sensible-browser есть и
// $DISPLAY поднят (xrdp, Fly DE) — откроем. Иначе пользователь увидит URL
// в выводе и перейдёт сам с другого компа (типичный сценарий headless).
func tryOpenBrowser(url string) {
if os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == "" {
return
func main() {
if len(os.Args) < 2 {
usage()
}
var bin string
switch runtime.GOOS {
case "linux":
for _, cand := range []string{"xdg-open", "sensible-browser", "x-www-browser"} {
if p, err := exec.LookPath(cand); err == nil {
bin = p
break
switch os.Args[1] {
case "install":
cmdInstall(os.Args[2:])
case "first-run-code":
cmdFirstRunCode(os.Args[2:])
case "version":
fmt.Println("bj-installer", installerVersion)
default:
usage()
}
}
func usage() {
fmt.Fprintln(os.Stderr, "bj-installer install --code BJ-XXXX-XXXX-XXXX [--server https://lic.zetit.ru] [--channel stable] [--dry-run]")
fmt.Fprintln(os.Stderr, "bj-installer first-run-code")
fmt.Fprintln(os.Stderr, "bj-installer version")
os.Exit(2)
}
func cmdInstall(args []string) {
fl := flag.NewFlagSet("install", flag.ExitOnError)
in := &installer{out: newConsole()}
fl.StringVar(&in.code, "code", "", "ключ продукта (код активации)")
fl.StringVar(&in.server, "server", "https://lic.zetit.ru", "сервер лицензий и обновлений")
fl.StringVar(&in.channel, "channel", "stable", "канал обновлений")
fl.BoolVar(&in.dryRun, "dry-run", false, "только проверить систему")
stepsFlag := fl.String("steps", "", "только эти шаги через запятую (для проверки): precheck,packages,system,license,database,components,services,firstrun")
fl.StringVar(&in.root, "root", "", "корень файловой системы для проверки в песочнице")
_ = fl.Parse(args)
in.server = strings.TrimRight(in.server, "/")
if *stepsFlag != "" {
in.steps = map[string]bool{}
for _, s := range strings.Split(*stepsFlag, ",") {
in.steps[strings.TrimSpace(s)] = true
}
}
in.out.banner()
if err := in.run(); err != nil {
in.out.failure(err)
os.Exit(1)
}
}
// P — путь с учётом песочницы.
func (in *installer) P(path string) string {
if in.root == "" {
return path
}
return filepath.Join(in.root, path)
}
type step struct {
id, title string
run func() (string, error)
}
func (in *installer) run() error {
if in.root == "" && !in.dryRun && os.Geteuid() != 0 {
return errors.New("установщик запускается от root: sudo bj-installer install …")
}
if !in.dryRun && in.steps == nil && strings.TrimSpace(in.code) == "" {
return errors.New("не указан ключ продукта: bj-installer install --code BJ-XXXX-XXXX-XXXX (он есть в портале клиента https://lic.zetit.ru/portal/)")
}
all := []step{
{"precheck", "Проверка системы", in.stepPrecheck},
{"packages", "Системные пакеты", in.stepPackages},
{"system", "Пользователь bj и каталоги", in.stepSystem},
{"license", "Активация лицензии", in.stepLicense},
{"database", "База данных PostgreSQL", in.stepDatabase},
{"components", "Компоненты bj по лицензии", in.stepComponents},
{"services", "Скрипты и службы", in.stepServices},
{"firstrun", "Запуск и код первичной настройки", in.stepFirstRun},
}
var todo []step
for _, s := range all {
switch {
case in.dryRun && s.id != "precheck":
case in.steps != nil && !in.steps[s.id]:
default:
todo = append(todo, s)
}
}
for i, s := range todo {
in.out.step(i+1, len(todo), s.title)
detail, err := s.run()
if err != nil {
in.out.stepFail()
return fmt.Errorf("%s: %w", s.title, err)
}
in.out.stepOK(detail)
}
if in.dryRun {
in.out.note("Проверка пройдена. Для установки запустите без --dry-run и с ключом продукта.")
}
if in.shown {
in.out.done(in.setupCode, in.lic)
}
return nil
}
// ---- шаги ----
func osRelease() map[string]string {
m := map[string]string{}
b, err := os.ReadFile("/etc/os-release")
if err != nil {
return m
}
for _, line := range strings.Split(string(b), "\n") {
k, v, ok := strings.Cut(line, "=")
if ok {
m[k] = strings.Trim(v, `"`)
}
}
return m
}
func (in *installer) stepPrecheck() (string, error) {
osr := osRelease()
id, ver, like := osr["ID"], osr["VERSION_ID"], osr["ID_LIKE"]
name := pick(osr["PRETTY_NAME"], id+" "+ver)
switch {
case id == "debian" && strings.HasPrefix(ver, "13"):
case id == "astra" || strings.Contains(like, "astra") || strings.Contains(strings.ToLower(name), "astra"):
default:
return "", fmt.Errorf("поддерживаются Debian 13 и Astra Linux, а на сервере %s", name)
}
if runtime.GOARCH != "amd64" {
return "", fmt.Errorf("нужна архитектура x86_64, а на сервере %s", runtime.GOARCH)
}
if _, err := os.Stat("/run/systemd/system"); err != nil {
return "", errors.New("systemd не запущен")
}
var st syscall.Statfs_t
if err := syscall.Statfs("/", &st); err == nil {
if free := st.Bavail * uint64(st.Bsize); free < 10<<30 {
return "", fmt.Errorf("на диске свободно %.1f ГБ, нужно не меньше 10 ГБ", float64(free)/(1<<30))
}
}
mem := memTotal()
if mem > 0 && mem < 1800<<20 {
return "", fmt.Errorf("памяти %.1f ГБ, нужно не меньше 2 ГБ", float64(mem)/(1<<30))
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, in.server+"/healthz", nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("нет связи с %s: %v", in.server, err)
}
resp.Body.Close()
return fmt.Sprintf("%s, %.1f ГБ памяти, связь с сервером лицензий есть", name, float64(mem)/(1<<30)), nil
}
func memTotal() uint64 {
b, err := os.ReadFile("/proc/meminfo")
if err != nil {
return 0
}
for _, line := range strings.Split(string(b), "\n") {
if strings.HasPrefix(line, "MemTotal:") {
f := strings.Fields(line)
if len(f) >= 2 {
kb, _ := strconv.ParseUint(f[1], 10, 64)
return kb << 10
}
}
}
if bin == "" {
return 0
}
func (in *installer) stepPackages() (string, error) {
pkgs := []string{"ca-certificates", "curl", "postgresql", "postgresql-client", "default-jre-headless", "pcscd", "python3"}
env := append(os.Environ(), "DEBIAN_FRONTEND=noninteractive")
if out, err := runEnv(env, "apt-get", "update", "-qq"); err != nil {
return "", fmt.Errorf("apt-get update: %v\n%s", err, tail(out, 8))
}
args := append([]string{"install", "-y", "-qq", "--no-install-recommends"}, pkgs...)
if out, err := runEnv(env, "apt-get", args...); err != nil {
return "", fmt.Errorf("apt-get install: %v\n%s", err, tail(out, 12))
}
return strings.Join(pkgs, ", "), nil
}
func (in *installer) stepSystem() (string, error) {
if in.root == "" {
if _, err := user.Lookup("bj"); err != nil {
if out, err := run("useradd", "--system", "--home-dir", "/var/lib/bj", "--no-create-home", "--shell", "/usr/sbin/nologin", "bj"); err != nil {
return "", fmt.Errorf("useradd: %v %s", err, out)
}
}
}
dirs := []struct {
path string
mode os.FileMode
bjOwn bool
}{
{"/opt/bj", 0o755, false}, {"/etc/bj", 0o755, false},
{"/var/lib/bj", 0o750, true}, {"/var/lib/bj/.bj", 0o700, true}, {"/var/log/bj", 0o750, true},
{"/var/lib/bj/keymedia-requests", 0o770, true}, {"/var/lib/bj/update-staging", 0o750, true},
}
for _, d := range dirs {
if err := os.MkdirAll(in.P(d.path), d.mode); err != nil {
return "", err
}
_ = os.Chmod(in.P(d.path), d.mode)
if d.bjOwn {
in.chownBJ(in.P(d.path))
}
}
return "пользователь bj, /opt/bj, /var/lib/bj, /var/log/bj", nil
}
func (in *installer) chownBJ(path string) {
if in.root != "" {
return
}
_ = exec.Command(bin, url).Start()
u, err := user.Lookup("bj")
if err != nil {
return
}
uid, _ := strconv.Atoi(u.Uid)
gid, _ := strconv.Atoi(u.Gid)
_ = os.Chown(path, uid, gid)
}
// ---- настройки bj (setup.json) ----
func (in *installer) loadSetup() map[string]any {
m := map[string]any{}
if b, err := os.ReadFile(in.P(setupFile)); err == nil {
_ = json.Unmarshal(b, &m)
}
return m
}
func (in *installer) saveSetup(m map[string]any) error {
b, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
p := in.P(setupFile)
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
return err
}
if err := os.WriteFile(p+".tmp", b, 0o600); err != nil {
return err
}
in.chownBJ(p + ".tmp")
return os.Rename(p+".tmp", p)
}
func section(m map[string]any, key string) map[string]any {
if s, ok := m[key].(map[string]any); ok {
return s
}
s := map[string]any{}
m[key] = s
return s
}
func newUUID() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
b[6] = b[6]&0x0f | 0x40
b[8] = b[8]&0x3f | 0x80
h := hex.EncodeToString(b)
return h[0:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:]
}
func machineHash() string {
b, err := os.ReadFile("/etc/machine-id")
if err != nil || strings.TrimSpace(string(b)) == "" {
return ""
}
sum := sha256.Sum256([]byte(strings.TrimSpace(string(b))))
return hex.EncodeToString(sum[:])
}
func (in *installer) stepLicense() (string, error) {
setup := in.loadSetup()
lic := section(setup, "license")
installID, _ := lic["install_id"].(string)
if len(installID) != 36 {
installID = newUUID()
}
host, _ := os.Hostname()
body, _ := json.Marshal(map[string]string{"code": strings.TrimSpace(in.code), "install_id": installID,
"hostname": host, "version": "bj-installer " + installerVersion, "machine": machineHash()})
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, in.server+"/v1/activate", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("сервер лицензий недоступен: %v", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
var reply struct {
Token string `json:"token"`
Tenant string `json:"tenant"`
Message string `json:"message"`
}
_ = json.Unmarshal(raw, &reply)
if resp.StatusCode != http.StatusOK || reply.Token == "" {
return "", fmt.Errorf("ключ продукта не принят: %s", pick(reply.Message, fmt.Sprintf("HTTP %d", resp.StatusCode)))
}
pub, err := license.ParsePublicKey(license.DefaultPublicKey)
if err != nil {
return "", err
}
tok, err := license.DecodeToken(reply.Token)
if err != nil {
return "", err
}
l, err := license.Verify(tok, pub)
if err != nil {
return "", fmt.Errorf("сервер вернул недействительный ключ: %v", err)
}
if !strings.EqualFold(l.InstallID, installID) {
return "", errors.New("сервер вернул ключ для другой установки")
}
in.token, in.lic = reply.Token, l
now := time.Now().UTC()
lic["key"], lic["install_id"] = reply.Token, installID
lic["last_sync"], lic["last_attempt"], lic["last_ok"] = now, now, true
lic["last_msg"] = "активирована установщиком: ключ до " + l.ExpiresAt.Local().Format("02.01.2006")
if err := in.saveSetup(setup); err != nil {
return "", err
}
return fmt.Sprintf("%s, план %s, модули: %s", pick(reply.Tenant, l.Tenant), l.Plan, featuresText(l)), nil
}
func featuresText(l *license.License) string {
if l.Plan == license.PlanEnterprise {
return "все"
}
if len(l.Features) == 0 {
return "только ядро"
}
return strings.Join(l.Features, ", ")
}
// ensureToken — ключ из настроек, если шаг активации в этом запуске не выполнялся.
func (in *installer) ensureToken() error {
if in.token != "" {
return nil
}
key, _ := section(in.loadSetup(), "license")["key"].(string)
if key == "" {
return errors.New("лицензия не активирована: запустите установку с ключом продукта")
}
pub, _ := license.ParsePublicKey(license.DefaultPublicKey)
tok, err := license.DecodeToken(key)
if err != nil {
return err
}
l, err := license.Verify(tok, pub)
if err != nil {
return err
}
in.token, in.lic = key, l
return nil
}
func (in *installer) stepDatabase() (string, error) {
setup := in.loadSetup()
pg := section(setup, "postgres")
if dsn, _ := pg["dsn"].(string); dsn != "" {
if err := applyMigrations(dsn); err == nil {
return "база уже настроена, схема обновлена", nil
}
}
if out, err := run("systemctl", "enable", "--now", "postgresql"); err != nil {
return "", fmt.Errorf("запуск PostgreSQL: %v %s", err, out)
}
if err := waitFor(60*time.Second, func() bool { _, err := psqlAdmin("SELECT 1"); return err == nil }); err != nil {
return "", errors.New("PostgreSQL не отвечает")
}
pw := randomString(28)
exists, _ := psqlAdmin("SELECT 1 FROM pg_roles WHERE rolname='bj'")
verb := "CREATE ROLE"
if strings.TrimSpace(exists) == "1" {
verb = "ALTER ROLE"
}
if _, err := psqlAdmin(verb + " bj WITH LOGIN PASSWORD '" + pw + "'"); err != nil {
return "", fmt.Errorf("роль bj: %v", err)
}
if db, _ := psqlAdmin("SELECT 1 FROM pg_database WHERE datname='bj'"); strings.TrimSpace(db) != "1" {
if _, err := psqlAdmin("CREATE DATABASE bj OWNER bj"); err != nil {
return "", fmt.Errorf("база bj: %v", err)
}
}
dsn := "postgres://bj:" + pw + "@127.0.0.1:5432/bj?sslmode=disable"
if err := applyMigrations(dsn); err != nil {
return "", err
}
pg["dsn"] = dsn
if err := in.saveSetup(setup); err != nil {
return "", err
}
return "база bj, пользователь bj, схема m2m_core", nil
}
func psqlAdmin(sql string) (string, error) {
return run("runuser", "-u", "postgres", "--", "psql", "-X", "-tAq", "-v", "ON_ERROR_STOP=1", "-c", sql)
}
func applyMigrations(dsn string) error {
names, err := fs.Glob(migrations.M2MCore, "m2m-core/*.sql")
if err != nil {
return err
}
sort.Strings(names)
for _, n := range names {
sqlText, _ := migrations.M2MCore.ReadFile(n)
cmd := exec.Command("psql", "-X", "-q", "-v", "ON_ERROR_STOP=1", dsn)
cmd.Stdin = bytes.NewReader(sqlText)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("миграция %s: %v\n%s", filepath.Base(n), err, tail(string(out), 6))
}
}
return nil
}
func (in *installer) licensed(feature string) bool {
return feature == "" || feature == "updates" || in.lic.HasFeature(feature)
}
// unitTemplate — есть ли в установщике unit-файл для службы компонента.
func unitTemplate(unit string) bool {
if unit == "" {
return true
}
_, err := fs.Stat(deploy.Files, "systemd/"+unit+".service")
return err == nil
}
func (in *installer) stepComponents() (string, error) {
if err := in.ensureToken(); err != nil {
return "", err
}
pub, err := release.ParsePublicKey(release.DefaultPublicKey)
if err != nil {
return "", err
}
cl, err := release.NewClient(in.server+"/updates", in.channel, release.DefaultPublicKey)
if err != nil {
return "", err
}
cl.Token = in.token
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
defer cancel()
sm, m, err := cl.FetchSigned(ctx)
if err != nil {
return "", fmt.Errorf("релиз %s: %v", in.channel, err)
}
staging := filepath.Join(in.P(release.StagingBase), fmt.Sprintf("install-%d", time.Now().UnixNano()))
if err := os.MkdirAll(staging, 0o750); err != nil {
return "", err
}
defer os.RemoveAll(staging)
var names, skipped []string
for _, c := range release.Components {
var art *release.Artifact
for i := range m.Artifacts {
if m.Artifacts[i].Name == c.Name {
art = &m.Artifacts[i]
}
}
switch {
case art == nil:
case !c.Core && !in.licensed(c.Feature):
case !unitTemplate(c.Unit):
skipped = append(skipped, c.Title)
default:
if _, err := cl.DownloadArtifact(ctx, *art, staging); err != nil {
return "", fmt.Errorf("скачивание %s: %v", c.Name, err)
}
names = append(names, c.Name)
}
}
if len(names) == 0 {
return "", fmt.Errorf("в релизе %s нет компонентов для этой лицензии", m.Version)
}
b, _ := json.Marshal(sm)
_ = os.WriteFile(filepath.Join(staging, "manifest.json"), b, 0o640)
rb, _ := json.Marshal(map[string][]string{"names": names})
_ = os.WriteFile(filepath.Join(staging, "request.json"), rb, 0o640)
res := release.InstallStaged(staging, release.InstallOptions{Root: in.root, PubKey: pub, AllowNew: true})
if !res.OK {
return "", errors.New(res.Error)
}
if err := os.WriteFile(in.P("/etc/bj/update.pub"), []byte(release.DefaultPublicKey+"\n"), 0o644); err != nil {
return "", err
}
var list []string
for _, x := range res.Installed {
list = append(list, x["name"]+" "+x["version"])
}
detail := "релиз " + m.Version + ": " + strings.Join(list, ", ")
if len(skipped) > 0 {
detail += "; модули " + strings.Join(skipped, ", ") + " установятся позже, из раздела «Обновления»"
}
return detail, nil
}
func (in *installer) stepServices() (string, error) {
var files []string
err := fs.WalkDir(deploy.Files, ".", func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
body, _ := deploy.Files.ReadFile(p)
switch {
case strings.HasPrefix(p, "linux/sbin/"):
dst := in.P("/usr/local/sbin/" + filepath.Base(p))
if err := writeFile(dst, body, 0o755); err != nil {
return err
}
files = append(files, filepath.Base(p))
case strings.HasPrefix(p, "systemd/"):
if err := writeFile(in.P("/etc/systemd/system/"+filepath.Base(p)), body, 0o644); err != nil {
return err
}
files = append(files, filepath.Base(p))
}
return nil
})
if err != nil {
return "", err
}
enable := []string{"enable", "bj-keymedia.service", "bj-server.service"}
// службы установленных модулей (bj-npr и т. п.), для которых есть unit-файл
for name := range release.LoadInstalled(in.P(release.InstalledFile)) {
if c, ok := release.ComponentByName(name); ok && !c.Core && c.Unit != "" && unitTemplate(c.Unit) {
enable = append(enable, c.Unit+".service")
}
}
if in.root == "" {
for _, c := range [][]string{{"daemon-reload"}, enable} {
if out, err := run("systemctl", c...); err != nil {
return "", fmt.Errorf("systemctl %s: %v %s", strings.Join(c, " "), err, out)
}
}
}
return strings.Join(files, ", ") + "; криптосервис включается после настройки ключей в bj", nil
}
func (in *installer) stepFirstRun() (string, error) {
code, err := in.newFirstRunCode()
if err != nil {
return "", err
}
if in.root == "" {
units := []string{"bj-keymedia", "bj-server"}
for name := range release.LoadInstalled(in.P(release.InstalledFile)) {
if c, ok := release.ComponentByName(name); ok && !c.Core && c.Unit != "" && unitTemplate(c.Unit) {
units = append(units, c.Unit)
}
}
for _, unit := range units {
if out, err := run("systemctl", "restart", unit); err != nil {
return "", fmt.Errorf("запуск %s: %v %s", unit, err, out)
}
}
if err := waitFor(90*time.Second, func() bool {
resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/healthz", bjPort))
if err != nil {
return false
}
resp.Body.Close()
return resp.StatusCode == http.StatusOK
}); err != nil {
return "", errors.New("bj-server не ответил за 90 секунд: journalctl -u bj-server")
}
}
in.setupCode, in.shown = code, true
if in.root != "" {
return "проверка в песочнице: службы не запускались", nil
}
return "bj-server работает", nil
}
// newFirstRunCode — одноразовый код первичной настройки (если пользователей ещё нет).
func (in *installer) newFirstRunCode() (string, error) {
if b, err := os.ReadFile(in.P(usersFile)); err == nil && bytes.Contains(b, []byte(`"login"`)) {
return "", nil // bj уже настроен: вход по учётным записям
}
n, _ := rand.Int(rand.Reader, big.NewInt(100000000))
digits := fmt.Sprintf("%08d", n.Int64())
hash, err := bcrypt.GenerateFromPassword([]byte(digits), 12)
if err != nil {
return "", err
}
b, _ := json.Marshal(map[string]any{"code_hash": string(hash), "created_at": time.Now().UTC()})
if err := writeFile(in.P(firstRunFile), b, 0o600); err != nil {
return "", err
}
in.chownBJ(in.P(firstRunFile))
code := digits[:4] + "-" + digits[4:]
url := setupURLs()
issue := fmt.Sprintf("Bridge&Join, первичная настройка: откройте в браузере %s/first-run и введите код %s\n\n", firstOr(url, "http://<адрес сервера>:8080"), code)
_ = writeFile(in.P("/etc/issue.d/bj-setup.issue"), []byte(issue), 0o644)
return code, nil
}
func cmdFirstRunCode(args []string) {
in := &installer{out: newConsole()}
if os.Geteuid() != 0 {
in.out.failure(errors.New("запустите от root: sudo bj-installer first-run-code"))
os.Exit(1)
}
code, err := in.newFirstRunCode()
if err != nil {
in.out.failure(err)
os.Exit(1)
}
if code == "" {
in.out.note("В bj уже есть пользователи: первичная настройка выполнена, входите своей учётной записью.")
return
}
_ = in.ensureToken()
in.out.done(code, in.lic)
}
// ---- вспомогательное ----
func setupURLs() []string {
var out []string
ifaces, _ := net.Interfaces()
for _, ifc := range ifaces {
if ifc.Flags&net.FlagUp == 0 || ifc.Flags&net.FlagLoopback != 0 {
continue
}
addrs, _ := ifc.Addrs()
for _, a := range addrs {
if ipn, ok := a.(*net.IPNet); ok && ipn.IP.To4() != nil {
out = append(out, fmt.Sprintf("http://%s:%d", ipn.IP.String(), bjPort))
}
}
}
return out
}
func firstOr(list []string, def string) string {
if len(list) > 0 {
return list[0]
}
return def
}
func run(name string, args ...string) (string, error) {
return runEnv(nil, name, args...)
}
func runEnv(env []string, name string, args ...string) (string, error) {
cmd := exec.Command(name, args...)
if env != nil {
cmd.Env = env
}
out, err := cmd.CombinedOutput()
return strings.TrimSpace(string(out)), err
}
func tail(s string, n int) string {
lines := strings.Split(strings.TrimSpace(s), "\n")
if len(lines) > n {
lines = lines[len(lines)-n:]
}
return strings.Join(lines, "\n")
}
func waitFor(d time.Duration, ok func() bool) error {
deadline := time.Now().Add(d)
for time.Now().Before(deadline) {
if ok() {
return nil
}
time.Sleep(time.Second)
}
return errors.New("время ожидания истекло")
}
func writeFile(path string, body []byte, mode os.FileMode) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
if err := os.WriteFile(path+".tmp", body, mode); err != nil {
return err
}
return os.Rename(path+".tmp", path)
}
func randomString(n int) string {
const alphabet = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
b := make([]byte, n)
for i := range b {
x, _ := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet))))
b[i] = alphabet[x.Int64()]
}
return string(b)
}
func pick(v, def string) string {
if strings.TrimSpace(v) == "" {
return def
}
return v
}
// ---- вывод в консоль ----
type console struct {
color bool
w *bufio.Writer
}
func newConsole() *console {
fi, err := os.Stdout.Stat()
return &console{color: err == nil && fi.Mode()&os.ModeCharDevice != 0 && os.Getenv("NO_COLOR") == "", w: bufio.NewWriter(os.Stdout)}
}
func (c *console) paint(code, s string) string {
if !c.color {
return s
}
return "\033[" + code + "m" + s + "\033[0m"
}
func (c *console) flush() { _ = c.w.Flush() }
func (c *console) banner() {
fmt.Fprintf(c.w, "\n %s установка на сервер\n %s\n\n", c.paint("1", "Bridge&Join"), c.paint("2", "bj-installer "+installerVersion))
c.flush()
}
func (c *console) step(i, n int, title string) {
fmt.Fprintf(c.w, " %s %s ", c.paint("36", fmt.Sprintf("[%d/%d]", i, n)), title)
c.flush()
}
func (c *console) stepOK(detail string) {
fmt.Fprintf(c.w, "%s\n", c.paint("32", "готово"))
if detail != "" {
fmt.Fprintf(c.w, " %s\n", c.paint("2", detail))
}
c.flush()
}
func (c *console) stepFail() {
fmt.Fprintf(c.w, "%s\n", c.paint("31", "ошибка"))
c.flush()
}
func (c *console) failure(err error) {
fmt.Fprintf(c.w, "\n %s %v\n\n Исправьте причину и запустите установку ещё раз: пройденные шаги повторяются безопасно.\n\n", c.paint("31;1", "Установка не завершена."), err)
c.flush()
}
func (c *console) note(s string) {
fmt.Fprintf(c.w, "\n %s\n\n", s)
c.flush()
}
// done — итоговый экран: адрес первичной настройки и одноразовый код.
func (c *console) done(code string, l *license.License) {
urls := setupURLs()
line := c.paint("2", strings.Repeat("─", 64))
fmt.Fprintf(c.w, "\n %s\n %s\n\n", line, c.paint("32;1", "bj установлен"))
if code != "" {
fmt.Fprintf(c.w, " Откройте в браузере на своём компьютере:\n")
if len(urls) == 0 {
urls = []string{fmt.Sprintf("http://<адрес сервера>:%d", bjPort)}
}
for _, u := range urls {
fmt.Fprintf(c.w, " %s\n", c.paint("1", u+"/first-run"))
}
fmt.Fprintf(c.w, "\n Одноразовый код первичной настройки: %s\n\n", c.paint("1;33", code))
fmt.Fprintf(c.w, " По коду создаётся администратор, дальше в разделе «Настройка»:\n организация и код участника НРД, ключи и интеграционный шлюз, учётные системы, пользователи.\n")
} else {
fmt.Fprintf(c.w, " Адрес bj: %s\n", c.paint("1", firstOr(urls, fmt.Sprintf("http://<адрес сервера>:%d", bjPort))))
}
if l != nil {
fmt.Fprintf(c.w, "\n Лицензия: %s, план %s, ключ до %s, модули: %s\n", l.Tenant, l.Plan, l.ExpiresAt.Local().Format("02.01.2006"), featuresText(l))
}
fmt.Fprintf(c.w, " Код потерян: sudo bj-installer first-run-code\n %s\n\n", line)
c.flush()
}
-132
View File
@@ -1,132 +0,0 @@
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"syscall"
)
// runPrechecks — все системные проверки на стадии "Проверка системы".
// Возвращает срез результатов, по каждому видно ✓/✗ + объяснение.
//
// Ничего не модифицирует — просто читает /etc/os-release, проверяет
// наличие нужных бинарей, права root, свободное место, артефакты в
// artifactsDir и т.п. UI отрисовывает таблицей.
func runPrechecks(artifactsDir string) []PrecheckResult {
var out []PrecheckResult
out = append(out, checkRoot())
out = append(out, checkArch())
out = append(out, checkDistro())
out = append(out, checkAptAvailable())
out = append(out, checkSystemd())
out = append(out, checkDiskSpace())
out = append(out, checkArtifacts(artifactsDir))
return out
}
func checkRoot() PrecheckResult {
if os.Geteuid() == 0 {
return PrecheckResult{ID: "root", Title: "Запуск от root", OK: true}
}
return PrecheckResult{ID: "root", Title: "Запуск от root", OK: false, Message: "Требуется sudo"}
}
func checkArch() PrecheckResult {
if runtime.GOARCH == "amd64" {
return PrecheckResult{ID: "arch", Title: "Архитектура amd64", OK: true, Message: runtime.GOARCH}
}
return PrecheckResult{ID: "arch", Title: "Архитектура amd64", OK: false, Message: "Валидата собрана только под amd64, у вас " + runtime.GOARCH}
}
func checkDistro() PrecheckResult {
id, pretty := readOSRelease()
switch id {
case "debian", "astra":
return PrecheckResult{ID: "distro", Title: "Поддерживаемая ОС", OK: true, Message: pretty}
case "ubuntu":
return PrecheckResult{ID: "distro", Title: "Поддерживаемая ОС", OK: true, Message: pretty + " (поддерживается на свой страх)"}
default:
return PrecheckResult{ID: "distro", Title: "Поддерживаемая ОС", OK: false, Message: "ОС не в списке поддерживаемых: " + pretty}
}
}
func checkAptAvailable() PrecheckResult {
if _, err := exec.LookPath("apt-get"); err != nil {
return PrecheckResult{ID: "apt", Title: "Доступен apt-get", OK: false, Message: "apt-get не найден — это не Debian-семейство"}
}
return PrecheckResult{ID: "apt", Title: "Доступен apt-get", OK: true}
}
func checkSystemd() PrecheckResult {
if _, err := os.Stat("/run/systemd/system"); err != nil {
return PrecheckResult{ID: "systemd", Title: "systemd работает", OK: false, Message: "/run/systemd/system нет"}
}
return PrecheckResult{ID: "systemd", Title: "systemd работает", OK: true}
}
func checkDiskSpace() PrecheckResult {
var fs syscall.Statfs_t
if err := syscall.Statfs("/var", &fs); err != nil {
return PrecheckResult{ID: "disk", Title: "Свободное место в /var", OK: false, Message: err.Error()}
}
freeBytes := fs.Bavail * uint64(fs.Bsize)
freeGiB := freeBytes / (1 << 30)
if freeGiB < 2 {
return PrecheckResult{ID: "disk", Title: "Свободное место в /var", OK: false, Message: fmt.Sprintf("Свободно %d GiB, нужно ≥ 2", freeGiB)}
}
return PrecheckResult{ID: "disk", Title: "Свободное место в /var", OK: true, Message: fmt.Sprintf("%d GiB свободно", freeGiB)}
}
func checkArtifacts(dir string) PrecheckResult {
required := []struct {
Glob string
Name string
}{
{filepath.Join(dir, "ClientL_Other", "zpki-*.deb"), "zpki (Валидата)"},
{filepath.Join(dir, "bj-server"), "bj-server (Go-бинарь)"},
{filepath.Join(dir, "crypto-service.jar"), "crypto-service.jar"},
}
var missing []string
for _, r := range required {
matches, _ := filepath.Glob(r.Glob)
if len(matches) == 0 {
missing = append(missing, r.Name)
}
}
if len(missing) > 0 {
return PrecheckResult{
ID: "artifacts",
Title: "Артефакты дистрибутива",
OK: false,
Message: "Отсутствуют: " + strings.Join(missing, ", ") + " (положите в " + dir + ")",
}
}
return PrecheckResult{ID: "artifacts", Title: "Артефакты дистрибутива", OK: true, Message: "Все на месте в " + dir}
}
func readOSRelease() (id, pretty string) {
b, err := os.ReadFile("/etc/os-release")
if err != nil {
return "", "неизвестно"
}
for _, line := range strings.Split(string(b), "\n") {
k, v, ok := strings.Cut(line, "=")
if !ok {
continue
}
v = strings.Trim(v, `"`)
switch k {
case "ID":
id = v
case "PRETTY_NAME":
pretty = v
}
}
return
}
-129
View File
@@ -1,129 +0,0 @@
package main
import (
"embed"
"encoding/json"
"io/fs"
"log"
"net/http"
"strings"
)
//go:embed web
var webFS embed.FS
type server struct {
state *State
mux *http.ServeMux
}
func newServer(st *State) *server {
s := &server{state: st, mux: http.NewServeMux()}
// Статика (HTML/CSS/JS из embed)
sub, _ := fs.Sub(webFS, "web")
s.mux.Handle("/", http.FileServer(http.FS(sub)))
// API
s.mux.HandleFunc("/api/state", s.handleState)
s.mux.HandleFunc("/api/precheck", s.handlePrecheck)
s.mux.HandleFunc("/api/config", s.handleConfig)
s.mux.HandleFunc("/api/install", s.handleInstall)
s.mux.HandleFunc("/api/events", s.handleSSE)
s.mux.HandleFunc("/api/reset", s.handleReset)
return s
}
func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Защита: только localhost (даже если addr 0.0.0.0 поставят)
host := r.RemoteAddr
if i := strings.LastIndex(host, ":"); i != -1 {
host = host[:i]
}
switch host {
case "127.0.0.1", "::1", "[::1]", "localhost":
// ok
default:
http.Error(w, "installer is local-only", http.StatusForbidden)
return
}
s.mux.ServeHTTP(w, r)
}
// GET /api/state — полный snapshot для холодного открытия страницы.
func (s *server) handleState(w http.ResponseWriter, r *http.Request) {
snap := s.state.Snapshot()
writeJSON(w, snap)
}
// POST /api/precheck — запускает все pre-check проверки и возвращает результат.
// Wizard переходит на стадию precheck.
func (s *server) handlePrecheck(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
s.state.setStage(StagePrecheck)
results := runPrechecks(s.state.artifactsDir)
s.state.setPrecheck(results)
writeJSON(w, results)
}
// POST /api/config — сохраняет org INN, email, license. Переход на стадию config.
func (s *server) handleConfig(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
var c Config
if err := json.NewDecoder(r.Body).Decode(&c); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
s.state.setConfig(c)
s.state.setStage(StageConfig)
writeJSON(w, map[string]bool{"ok": true})
}
// POST /api/install — стартует установку (в горутине), переход на стадию installing.
// UI слушает /api/events для прогресса.
func (s *server) handleInstall(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
s.state.setStage(StageInstalling)
go func() {
if err := runInstallation(s.state); err != nil {
log.Printf("install error: %v", err)
s.state.setError(err.Error())
return
}
s.state.setStage(StageDone)
}()
writeJSON(w, map[string]bool{"ok": true})
}
// POST /api/reset — сброс wizard'а на welcome (после ошибки).
func (s *server) handleReset(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
s.state.mu.Lock()
s.state.Stage = StageWelcome
s.state.ErrorMsg = ""
s.state.Precheck = nil
s.state.Steps = buildStepList()
s.state.mu.Unlock()
s.state.bus.publish(event{Type: "reset", Data: "{}"})
writeJSON(w, map[string]bool{"ok": true})
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
_ = enc.Encode(v)
}
-86
View File
@@ -1,86 +0,0 @@
package main
import (
"fmt"
"net/http"
"sync"
)
// event — одно событие, отдаваемое подписчикам через SSE.
// Type становится `event:` строкой, Data — `data:`.
type event struct {
Type string
Data string
}
// eventBus — простой fan-out для SSE. Подписчик создаётся в момент
// открытия GET /api/events и живёт до закрытия соединения.
type eventBus struct {
mu sync.Mutex
subscribers map[chan event]struct{}
}
func newEventBus() *eventBus {
return &eventBus{subscribers: make(map[chan event]struct{})}
}
func (b *eventBus) subscribe() chan event {
ch := make(chan event, 64)
b.mu.Lock()
b.subscribers[ch] = struct{}{}
b.mu.Unlock()
return ch
}
func (b *eventBus) unsubscribe(ch chan event) {
b.mu.Lock()
delete(b.subscribers, ch)
close(ch)
b.mu.Unlock()
}
func (b *eventBus) publish(e event) {
b.mu.Lock()
defer b.mu.Unlock()
for ch := range b.subscribers {
select {
case ch <- e:
default:
// Подписчик отстаёт — пропускаем (UI догонится снапшотом по GET /api/state)
}
}
}
// handleSSE — GET /api/events. Держит соединение, в каждом событии
// отдаёт event: <Type>\ndata: <Data>\n\n.
func (s *server) handleSSE(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming not supported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.WriteHeader(http.StatusOK)
ch := s.state.bus.subscribe()
defer s.state.bus.unsubscribe(ch)
// сразу шлём snapshot, чтобы UI догнал состояние
snap := s.state.Snapshot()
fmt.Fprintf(w, "event: snapshot\ndata: %s\n\n", mustJSON(snap))
flusher.Flush()
for {
select {
case <-r.Context().Done():
return
case e := <-ch:
if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", e.Type, e.Data); err != nil {
return
}
flusher.Flush()
}
}
}
-140
View File
@@ -1,140 +0,0 @@
package main
import (
"encoding/json"
"sync"
"time"
)
// WizardStage — какой странице wizard'а соответствует текущее состояние.
// Переходы: welcome → precheck → config → installing → done.
// Из любого можно вернуться в welcome (полный reset).
type WizardStage string
const (
StageWelcome WizardStage = "welcome"
StagePrecheck WizardStage = "precheck"
StageConfig WizardStage = "config"
StageInstalling WizardStage = "installing"
StageDone WizardStage = "done"
StageError WizardStage = "error"
)
// Config — данные, которые wizard собирает на стадии config.
type Config struct {
OrgINN string `json:"orgInn"` // ИНН организации
OrgName string `json:"orgName"` // отображаемое имя
AdminEmail string `json:"adminEmail"` // куда писать алерты
LicenseKey string `json:"licenseKey"` // годовой ключ (опционально, можно пропустить)
}
// StepStatus — текущее состояние конкретного шага установки.
type StepStatus string
const (
StepPending StepStatus = "pending"
StepRunning StepStatus = "running"
StepDone StepStatus = "done"
StepSkipped StepStatus = "skipped"
StepFailed StepStatus = "failed"
)
// StepState — снимок одного шага для отдачи в UI.
type StepState struct {
ID string `json:"id"`
Title string `json:"title"`
Status StepStatus `json:"status"`
Message string `json:"message,omitempty"`
Started *time.Time `json:"started,omitempty"`
Finished *time.Time `json:"finished,omitempty"`
}
// PrecheckResult — результат одной системной проверки на стадии precheck.
type PrecheckResult struct {
ID string `json:"id"`
Title string `json:"title"`
OK bool `json:"ok"`
Message string `json:"message,omitempty"`
}
// State — потокобезопасное состояние wizard'а. Хранит всё что нужно
// отрисовать на любой из страниц + текущий прогресс установки.
type State struct {
mu sync.RWMutex
artifactsDir string
Stage WizardStage `json:"stage"`
ErrorMsg string `json:"errorMsg,omitempty"`
Precheck []PrecheckResult `json:"precheck"`
Config Config `json:"config"`
Steps []StepState `json:"steps"`
bus *eventBus
}
func newState(artifactsDir string) *State {
return &State{
artifactsDir: artifactsDir,
Stage: StageWelcome,
Steps: buildStepList(),
bus: newEventBus(),
}
}
// Snapshot — потокобезопасная копия для GET /api/state.
func (s *State) Snapshot() State {
s.mu.RLock()
defer s.mu.RUnlock()
cp := *s
cp.Precheck = append([]PrecheckResult(nil), s.Precheck...)
cp.Steps = append([]StepState(nil), s.Steps...)
return cp
}
func (s *State) setStage(st WizardStage) {
s.mu.Lock()
s.Stage = st
s.mu.Unlock()
s.bus.publish(event{Type: "stage", Data: mustJSON(map[string]string{"stage": string(st)})})
}
func (s *State) setError(msg string) {
s.mu.Lock()
s.Stage = StageError
s.ErrorMsg = msg
s.mu.Unlock()
s.bus.publish(event{Type: "error", Data: mustJSON(map[string]string{"message": msg})})
}
func (s *State) setPrecheck(items []PrecheckResult) {
s.mu.Lock()
s.Precheck = items
s.mu.Unlock()
s.bus.publish(event{Type: "precheck", Data: mustJSON(items)})
}
func (s *State) setConfig(c Config) {
s.mu.Lock()
s.Config = c
s.mu.Unlock()
}
func (s *State) updateStep(id string, fn func(*StepState)) {
s.mu.Lock()
var snap StepState
for i := range s.Steps {
if s.Steps[i].ID == id {
fn(&s.Steps[i])
snap = s.Steps[i]
break
}
}
s.mu.Unlock()
s.bus.publish(event{Type: "step", Data: mustJSON(snap)})
}
func mustJSON(v any) string {
b, _ := json.Marshal(v)
return string(b)
}
-84
View File
@@ -1,84 +0,0 @@
package main
import (
"fmt"
"time"
)
// Step — описание одного шага установки. Run выполняет шаг, может
// проверить idempotency и вернуть Skipped. Логи прокидываются через
// log-функцию, которая публикует event в SSE.
type Step struct {
ID string
Title string
Run func(s *State, log func(string)) (StepStatus, error)
}
// buildStepList — фиксированный порядок шагов установки. Соответствует
// install-validata.sh + установка bj-server/crypto-service/ИШ. Меняется
// атомарно (если что-то добавляется — добавляем сюда).
func buildStepList() []StepState {
steps := allSteps()
out := make([]StepState, len(steps))
for i, s := range steps {
out[i] = StepState{ID: s.ID, Title: s.Title, Status: StepPending}
}
return out
}
func allSteps() []Step {
return []Step{
{ID: "deps", Title: "Установка системных зависимостей", Run: stepInstallDeps},
{ID: "validata-deb", Title: "Установка пакетов Валидаты (zpki + zsdk)", Run: stepInstallValidataDebs},
{ID: "execstack", Title: "execstack -c libvdcsp.so", Run: stepExecstack},
{ID: "bj-user", Title: "Создание пользователя bj и каталогов", Run: stepCreateBJUser},
{ID: "pcscd-dropin", Title: "Настройка pcscd (always-on)", Run: stepPcscdDropin},
{ID: "bj-crypto-dropins", Title: "Drop-ins для bj-crypto sandbox", Run: stepBJCryptoDropins},
{ID: "bj-server-dropin", Title: "Drop-in для bj-server", Run: stepBJServerDropin},
{ID: "spki-ini", Title: "Создание spki.ini", Run: stepSPKIIni},
{ID: "pki1-prep", Title: "Подготовка pki1.conf для bj", Run: stepPKI1Prep},
{ID: "usb-mount", Title: "Авто-mount USB через udev + systemd", Run: stepUSBMount},
{ID: "bj-server-binary", Title: "Установка bj-server бинаря в /opt/bj/", Run: stepInstallBJServer},
{ID: "crypto-jar", Title: "Установка crypto-service.jar", Run: stepInstallCryptoJar},
{ID: "systemd-units", Title: "systemd unit bj-crypto.service + bj-server.service", Run: stepSystemdUnits},
{ID: "ish-install", Title: "Установка ИШ НРД (если есть .deb)", Run: stepInstallISH},
{ID: "save-config", Title: "Сохранение setup.json", Run: stepSaveConfig},
{ID: "systemd-start", Title: "Запуск сервисов (pcscd, bj-crypto, bj-server)", Run: stepStartServices},
{ID: "health", Title: "Финальный health-check", Run: stepHealthCheck},
}
}
// runInstallation — основной цикл установки. Перебирает шаги, обновляет
// статусы через State, прокидывает логи в SSE. Останавливается при первой
// ошибке (UI покажет какой шаг + сообщение).
func runInstallation(s *State) error {
steps := allSteps()
for _, step := range steps {
now := time.Now()
s.updateStep(step.ID, func(ss *StepState) {
ss.Status = StepRunning
ss.Started = &now
ss.Message = ""
})
logFn := func(line string) {
s.updateStep(step.ID, func(ss *StepState) {
ss.Message = line
})
}
status, err := step.Run(s, logFn)
finished := time.Now()
s.updateStep(step.ID, func(ss *StepState) {
ss.Status = status
ss.Finished = &finished
if err != nil {
ss.Message = err.Error()
}
})
if err != nil {
return fmt.Errorf("шаг %q: %w", step.Title, err)
}
}
return nil
}
-448
View File
@@ -1,448 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
// --------------------------------------------------------------------- //
// Хелперы
// --------------------------------------------------------------------- //
// runCmd — запускает команду, прокидывает stdout/stderr построчно в log.
// Возвращает ошибку с последними строками stderr для удобства отображения.
func runCmd(logFn func(string), name string, args ...string) error {
logFn(fmt.Sprintf("$ %s %s", name, strings.Join(args, " ")))
cmd := exec.Command(name, args...)
out, err := cmd.CombinedOutput()
for _, line := range strings.Split(strings.TrimRight(string(out), "\n"), "\n") {
if line != "" {
logFn(line)
}
}
if err != nil {
return fmt.Errorf("%s: %w", name, err)
}
return nil
}
// writeFileIfChanged — пишет файл только если содержимое отличается. Возвращает
// true если файл был создан/изменён (для решения «нужен ли daemon-reload»).
func writeFileIfChanged(path string, content string, mode os.FileMode) (bool, error) {
existing, err := os.ReadFile(path)
if err == nil && string(existing) == content {
return false, nil
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return false, err
}
if err := os.WriteFile(path, []byte(content), mode); err != nil {
return false, err
}
return true, nil
}
// --------------------------------------------------------------------- //
// Шаги
// --------------------------------------------------------------------- //
func stepInstallDeps(s *State, log func(string)) (StepStatus, error) {
log("Обновляю apt-кеш...")
if err := runCmd(log, "apt-get", "update", "-qq"); err != nil {
return StepFailed, err
}
deps := []string{
"libgtk-3-0", "libpcsclite1", "libccid", "pcscd",
"libcurl4", "libkrb5-3", "libgssapi-krb5-2",
"libsasl2-modules", "libsasl2-modules-gssapi-mit",
"execstack", "p7zip-full",
}
if hasAPTPackage("libldap-2.4-2") {
deps = append(deps, "libldap-2.4-2")
} else {
deps = append(deps, "libldap-2.5-0")
log("libldap-2.4-2 не найден → ставлю 2.5-0, для zpki будет --force-depends")
}
args := append([]string{"install", "-y", "--no-install-recommends"}, deps...)
if err := runCmd(log, "apt-get", args...); err != nil {
return StepFailed, err
}
return StepDone, nil
}
func stepInstallValidataDebs(s *State, log func(string)) (StepStatus, error) {
zpki, _ := filepath.Glob(filepath.Join(s.artifactsDir, "ClientL_Other", "zpki-*.amd64.deb"))
zsdk, _ := filepath.Glob(filepath.Join(s.artifactsDir, "ClientL_Other", "zsdk-*.amd64.deb"))
if len(zpki) == 0 {
return StepFailed, fmt.Errorf("zpki-*.amd64.deb не найден в %s/ClientL_Other/", s.artifactsDir)
}
useForce := !hasAPTPackage("libldap-2.4-2")
for _, deb := range append(zpki, zsdk...) {
args := []string{"-i", deb}
if useForce {
args = append([]string{"--force-depends"}, args...)
}
if err := runCmd(log, "dpkg", args...); err != nil {
return StepFailed, err
}
}
if _, err := os.Stat("/opt/Validata/VDCSP/lib/amd64"); err != nil {
return StepFailed, fmt.Errorf("/opt/Validata не появился после установки")
}
return StepDone, nil
}
func stepExecstack(s *State, log func(string)) (StepStatus, error) {
target := "/opt/Validata/VDCSP/lib/amd64/libvdcsp.so"
// Проверка состояния
out, err := exec.Command("execstack", "-q", target).Output()
if err == nil && strings.HasPrefix(strings.TrimSpace(string(out)), "-") {
log("executable-stack уже снят")
return StepSkipped, nil
}
return StepDone, runCmd(log, "execstack", "-c", target)
}
func stepCreateBJUser(s *State, log func(string)) (StepStatus, error) {
if _, err := exec.LookPath("id"); err == nil {
if exec.Command("id", "bj").Run() == nil {
log("Пользователь bj уже существует")
} else {
if err := runCmd(log, "useradd", "--system", "--create-home",
"--home-dir", "/var/lib/bj", "--shell", "/bin/bash", "bj"); err != nil {
return StepFailed, err
}
}
}
dirs := []struct {
Path string
Mode os.FileMode
}{
{"/var/lib/bj/usb", 0o755},
{"/var/lib/bj/.Validata", 0o700},
{"/var/lib/bj/.Validata/vdkeys", 0o700},
{"/var/lib/bj/profiles", 0o755},
{"/var/log/bj", 0o755},
{"/var/lib/bj/.bj", 0o700},
}
for _, d := range dirs {
if err := os.MkdirAll(d.Path, d.Mode); err != nil {
return StepFailed, err
}
}
return StepDone, runCmd(log, "chown", "-R", "bj:bj", "/var/lib/bj", "/var/log/bj")
}
func stepPcscdDropin(s *State, log func(string)) (StepStatus, error) {
const dropin = `[Unit]
Requires=
After=
Sockets=
[Service]
ExecStart=
ExecStart=/usr/sbin/pcscd --foreground
`
changed, err := writeFileIfChanged("/etc/systemd/system/pcscd.service.d/no-autoexit.conf", dropin, 0o644)
if err != nil {
return StepFailed, err
}
if !changed {
log("Drop-in уже актуален")
return StepSkipped, nil
}
log("Создан /etc/systemd/system/pcscd.service.d/no-autoexit.conf")
return StepDone, nil
}
func stepBJCryptoDropins(s *State, log func(string)) (StepStatus, error) {
files := map[string]string{
"/etc/systemd/system/bj-crypto.service.d/validata-paths.conf": `[Service]
WorkingDirectory=/opt/Validata/VDCSP/etc
ReadWritePaths=/opt/Validata/VDCSP/etc
ReadWritePaths=/var/lib/bj
`,
"/etc/systemd/system/bj-crypto.service.d/usb-access.conf": `[Service]
ReadOnlyPaths=/media
ReadOnlyPaths=/var/lib/bj/usb
`,
"/etc/systemd/system/bj-crypto.service.d/share-crysvc.conf": `[Service]
PrivateTmp=true
BindPaths=/tmp/.crysvc.sock:/tmp/.crysvc.sock
`,
}
for path, content := range files {
if _, err := writeFileIfChanged(path, content, 0o644); err != nil {
return StepFailed, err
}
}
return StepDone, nil
}
func stepBJServerDropin(s *State, log func(string)) (StepStatus, error) {
const dropin = `[Service]
ReadWritePaths=/opt/Validata/VDCSP/etc
`
_, err := writeFileIfChanged("/etc/systemd/system/bj-server.service.d/pki1conf.conf", dropin, 0o644)
if err != nil {
return StepFailed, err
}
return StepDone, nil
}
func stepSPKIIni(s *State, log func(string)) (StepStatus, error) {
const path = "/opt/Validata/VDCSP/etc/spki.ini"
if _, err := os.Stat(path); err == nil {
log("Файл уже существует")
return StepSkipped, nil
}
const content = `[store]
count = 0
[Parameters]
PkiLdapTimeout = 10
PkiHttpTimeout = 60
`
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
return StepFailed, err
}
return StepDone, nil
}
func stepPKI1Prep(s *State, log func(string)) (StepStatus, error) {
const path = "/opt/Validata/VDCSP/etc/pki1.conf"
if _, err := os.Stat(path); err != nil {
log("Файл pki1.conf отсутствует — Валидата создаст при первом запуске")
return StepSkipped, nil
}
if err := runCmd(log, "chgrp", "bj", path); err != nil {
return StepFailed, err
}
if err := runCmd(log, "chmod", "g+w", path); err != nil {
return StepFailed, err
}
existing, _ := os.ReadFile(path)
if !strings.Contains(string(existing), "# --- bj-server: BEGIN ---") {
appended := string(existing) + "\n# --- bj-server: BEGIN ---\n# Секции профилей дописываются bj-server при импорте через /admin/setup.\n# --- bj-server: END ---\n"
if err := os.WriteFile(path, []byte(appended), 0o664); err != nil {
return StepFailed, err
}
}
return StepDone, nil
}
func stepUSBMount(s *State, log func(string)) (StepStatus, error) {
files := map[string]string{
"/etc/udev/rules.d/99-bj-usb.rules": `# Авто-mount USB-флешек в /var/lib/bj/usb/<UUID> с владельцем bj.
ACTION=="add", KERNEL=="sd?[0-9]", SUBSYSTEMS=="usb", \
ENV{ID_FS_TYPE}!="", \
ENV{SYSTEMD_WANTS}="bj-usb-mount@$env{ID_FS_UUID}.service"
ACTION=="remove", KERNEL=="sd?[0-9]", SUBSYSTEMS=="usb", \
ENV{ID_FS_TYPE}!="", \
ENV{SYSTEMD_WANTS}="bj-usb-umount@$env{ID_FS_UUID}.service"
`,
"/etc/systemd/system/bj-usb-mount@.service": `[Unit]
Description=Mount USB %i to /var/lib/bj/usb/%i for bj
DefaultDependencies=no
After=local-fs.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/bin/bash -c 'mkdir -p /var/lib/bj/usb/%i && /usr/bin/mount -o uid=$(id -u bj),gid=$(id -g bj),fmask=0133,dmask=0022 UUID=%i /var/lib/bj/usb/%i'
ExecStop=/usr/bin/umount /var/lib/bj/usb/%i || true
`,
"/etc/systemd/system/bj-usb-umount@.service": `[Unit]
Description=Umount USB %i from /var/lib/bj/usb/%i
DefaultDependencies=no
[Service]
Type=oneshot
ExecStart=/usr/bin/bash -c '/usr/bin/umount /var/lib/bj/usb/%i 2>/dev/null; /usr/bin/rmdir /var/lib/bj/usb/%i 2>/dev/null; true'
`,
}
anyChanged := false
for path, content := range files {
ch, err := writeFileIfChanged(path, content, 0o644)
if err != nil {
return StepFailed, err
}
anyChanged = anyChanged || ch
}
if anyChanged {
_ = runCmd(log, "udevadm", "control", "--reload-rules")
_ = runCmd(log, "udevadm", "trigger")
}
return StepDone, nil
}
func stepInstallBJServer(s *State, log func(string)) (StepStatus, error) {
src := filepath.Join(s.artifactsDir, "bj-server")
if _, err := os.Stat(src); err != nil {
return StepSkipped, nil // нет бинаря — может ставится через rpm/deb
}
if err := os.MkdirAll("/opt/bj", 0o755); err != nil {
return StepFailed, err
}
if err := runCmd(log, "install", "-o", "bj", "-g", "bj", "-m", "0755", src, "/opt/bj/bj-server"); err != nil {
return StepFailed, err
}
return StepDone, nil
}
func stepInstallCryptoJar(s *State, log func(string)) (StepStatus, error) {
src := filepath.Join(s.artifactsDir, "crypto-service.jar")
if _, err := os.Stat(src); err != nil {
return StepSkipped, nil
}
if err := os.MkdirAll("/opt/bj", 0o755); err != nil {
return StepFailed, err
}
if err := runCmd(log, "install", "-o", "bj", "-g", "bj", "-m", "0644", src, "/opt/bj/crypto-service.jar"); err != nil {
return StepFailed, err
}
return StepDone, nil
}
func stepSystemdUnits(s *State, log func(string)) (StepStatus, error) {
units := map[string]string{
"/etc/systemd/system/bj-crypto.service": `[Unit]
Description=Bridge-and-Join-s — Crypto sidecar (Java + Валидата Клиент L)
Before=bj-server.service
After=network-online.target pcscd.service
Wants=network-online.target
[Service]
Type=simple
User=bj
Group=bj
RuntimeDirectory=bj
RuntimeDirectoryMode=0750
Environment=BJ_CRYPTO_SOCKET=/run/bj/crypto.sock
Environment=BJ_CRYPTO_PROVIDER=validata
Environment=LD_LIBRARY_PATH=/opt/Validata/VDCSP/lib/amd64
ExecStart=/usr/bin/java -Djava.library.path=/opt/Validata/VDCSP/lib/amd64 -jar /opt/bj/crypto-service.jar
Restart=on-failure
RestartSec=5
StandardOutput=append:/var/log/bj/crypto-service.log
StandardError=append:/var/log/bj/crypto-service.err
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/run/bj /var/log/bj
PrivateTmp=true
[Install]
WantedBy=multi-user.target
`,
"/etc/systemd/system/bj-server.service": `[Unit]
Description=Bridge-and-Join-s — единый сервис M2M-переводов
After=network-online.target bj-crypto.service
Wants=network-online.target
[Service]
Type=simple
User=bj
Group=bj
WorkingDirectory=/var/lib/bj
ExecStart=/opt/bj/bj-server
Restart=on-failure
RestartSec=5
Environment=BJ_HTTP_ADDR=:8080
Environment=BJ_SETUP_PATH=/var/lib/bj/.bj/setup.json
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/bj /var/log/bj
[Install]
WantedBy=multi-user.target
`,
}
for path, content := range units {
if _, err := writeFileIfChanged(path, content, 0o644); err != nil {
return StepFailed, err
}
}
return StepDone, runCmd(log, "systemctl", "daemon-reload")
}
func stepInstallISH(s *State, log func(string)) (StepStatus, error) {
matches, _ := filepath.Glob(filepath.Join(s.artifactsDir, "ish", "igate_*.deb"))
if len(matches) == 0 {
log("Дистрибутив ИШ не найден — пропускаю (можно установить позже)")
return StepSkipped, nil
}
if err := runCmd(log, "dpkg", "-i", matches[0]); err != nil {
// допустим, что зависимости подтянутся
_ = runCmd(log, "apt-get", "-f", "install", "-y")
if err := runCmd(log, "dpkg", "-i", matches[0]); err != nil {
return StepFailed, err
}
}
return StepDone, nil
}
func stepSaveConfig(s *State, log func(string)) (StepStatus, error) {
cfg := s.Snapshot().Config
if cfg.OrgINN == "" && cfg.AdminEmail == "" && cfg.LicenseKey == "" {
return StepSkipped, nil
}
b, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return StepFailed, err
}
if err := os.MkdirAll("/var/lib/bj/.bj", 0o700); err != nil {
return StepFailed, err
}
if err := os.WriteFile("/var/lib/bj/.bj/setup.json", b, 0o600); err != nil {
return StepFailed, err
}
return StepDone, runCmd(log, "chown", "-R", "bj:bj", "/var/lib/bj/.bj")
}
func stepStartServices(s *State, log func(string)) (StepStatus, error) {
// disable+stop socket activation для pcscd
_ = runCmd(log, "systemctl", "stop", "pcscd.socket")
_ = runCmd(log, "systemctl", "disable", "pcscd.socket")
for _, svc := range []string{"pcscd", "bj-crypto", "bj-server"} {
if err := runCmd(log, "systemctl", "enable", svc); err != nil {
return StepFailed, err
}
if err := runCmd(log, "systemctl", "restart", svc); err != nil {
return StepFailed, err
}
}
return StepDone, nil
}
func stepHealthCheck(s *State, log func(string)) (StepStatus, error) {
var bad []string
for _, svc := range []string{"pcscd", "vdcrysvc", "bj-crypto", "bj-server"} {
if err := exec.Command("systemctl", "is-active", "--quiet", svc).Run(); err != nil {
bad = append(bad, svc)
} else {
log(svc + ": active")
}
}
if len(bad) > 0 {
return StepFailed, fmt.Errorf("сервисы не запустились: %s", strings.Join(bad, ", "))
}
return StepDone, nil
}
// hasAPTPackage — проверяет наличие пакета в apt-cache (доступен ли для установки).
func hasAPTPackage(name string) bool {
out, err := exec.Command("apt-cache", "show", name).CombinedOutput()
if err != nil {
return false
}
return strings.Contains(string(out), "Package: "+name)
}
-168
View File
@@ -1,168 +0,0 @@
// Минимальный клиент wizard'а: рендерит страницы, ловит события из SSE,
// отправляет POST'ы на backend для перехода между стадиями.
let state = {
stage: "welcome",
precheck: [],
config: {},
steps: [],
errorMsg: "",
};
const STAGE_ORDER = ["welcome", "precheck", "config", "installing", "done"];
const STEP_ICONS = {
pending: "○",
running: "◐",
done: "✓",
skipped: "—",
failed: "✗",
};
function $(sel) { return document.querySelector(sel); }
function $$(sel) { return [...document.querySelectorAll(sel)]; }
function render() {
// stepper
$$("#stepper span").forEach(el => {
el.classList.remove("active", "done");
const stage = el.dataset.stage;
if (stage === state.stage) el.classList.add("active");
if (STAGE_ORDER.indexOf(stage) < STAGE_ORDER.indexOf(state.stage)) el.classList.add("done");
});
// pages
$$(".page").forEach(p => p.classList.toggle("active", p.dataset.stage === state.stage));
if (state.stage === "precheck") renderPrecheck();
if (state.stage === "installing" || state.stage === "done") renderSteps();
if (state.stage === "error") $("#error-message").textContent = state.errorMsg || "(нет деталей)";
if (state.stage === "done") {
// подставляем хост машины в админскую ссылку
const adminURL = window.location.protocol + "//" + window.location.hostname + ":8080/admin/setup";
$("#adminLink").href = adminURL;
$("#adminLink").textContent = "Перейти в " + adminURL + " →";
}
}
function renderPrecheck() {
const root = $("#precheck-results");
root.innerHTML = "";
let allOK = true;
for (const r of state.precheck || []) {
const div = document.createElement("div");
div.className = "check " + (r.ok ? "ok" : "bad");
div.innerHTML = `
<span class="check-icon">${r.ok ? "✓" : "✗"}</span>
<div>
<div class="check-title">${escapeHTML(r.title)}</div>
${r.message ? `<div class="check-msg">${escapeHTML(r.message)}</div>` : ""}
</div>`;
root.appendChild(div);
if (!r.ok) allOK = false;
}
$("#goConfigBtn").disabled = !allOK;
}
function renderSteps() {
const root = $("#step-list");
root.innerHTML = "";
let done = 0;
for (const s of state.steps || []) {
const li = document.createElement("li");
li.className = "step-" + s.status;
li.innerHTML = `
<span class="step-icon">${STEP_ICONS[s.status] || "○"}</span>
<div>
<div class="step-title">${escapeHTML(s.title)}</div>
${s.message ? `<div class="step-msg">${escapeHTML(s.message)}</div>` : ""}
</div>`;
root.appendChild(li);
if (s.status === "done" || s.status === "skipped") done++;
}
const total = state.steps.length;
const pct = total ? Math.round(100 * done / total) : 0;
$("#progress-bar").style.width = pct + "%";
}
function escapeHTML(s) {
return String(s).replace(/[&<>"']/g, c => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;"
}[c]));
}
// ------------- transitions -------------
async function startPrecheck() {
await fetch("/api/precheck", { method: "POST" });
}
function goWelcome() {
state.stage = "welcome";
render();
}
function goPrecheck() {
state.stage = "precheck";
render();
}
async function goConfig() {
state.stage = "config";
render();
}
async function startInstall() {
const form = $("#config-form");
const data = Object.fromEntries(new FormData(form).entries());
await fetch("/api/config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
await fetch("/api/install", { method: "POST" });
}
async function resetWizard() {
await fetch("/api/reset", { method: "POST" });
}
// ------------- SSE -------------
function connectSSE() {
const es = new EventSource("/api/events");
es.addEventListener("snapshot", e => {
const snap = JSON.parse(e.data);
state.stage = snap.stage;
state.precheck = snap.precheck || [];
state.config = snap.config || {};
state.steps = snap.steps || [];
state.errorMsg = snap.errorMsg || "";
render();
});
es.addEventListener("stage", e => {
state.stage = JSON.parse(e.data).stage;
render();
});
es.addEventListener("precheck", e => {
state.precheck = JSON.parse(e.data);
render();
});
es.addEventListener("step", e => {
const s = JSON.parse(e.data);
const idx = state.steps.findIndex(x => x.id === s.id);
if (idx >= 0) state.steps[idx] = s;
render();
});
es.addEventListener("error", e => {
state.errorMsg = JSON.parse(e.data).message;
state.stage = "error";
render();
});
es.addEventListener("reset", () => {
location.reload();
});
es.onerror = () => {
// авто-реконнект делает EventSource сам, ничего не делаем
};
}
connectSSE();
-110
View File
@@ -1,110 +0,0 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>bj-installer — мастер установки Bridge-and-Join-s</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<header class="topbar">
<div class="logo">Bridge-and-Join-s</div>
<div class="subtitle">мастер установки</div>
</header>
<main id="app">
<!-- ===== Stepper ===== -->
<nav class="stepper" id="stepper">
<span data-stage="welcome">1. Старт</span>
<span data-stage="precheck">2. Проверка</span>
<span data-stage="config">3. Настройка</span>
<span data-stage="installing">4. Установка</span>
<span data-stage="done">5. Готово</span>
</nav>
<!-- ===== Welcome ===== -->
<section class="page" data-stage="welcome">
<h1>Добро пожаловать</h1>
<p>Этот мастер установит на сервер <b>СКЗИ «Валидата Клиент L»</b>,
<b>bj-server</b>, <b>bj-crypto</b> и <b>ИШ НРД</b>, настроит
systemd-сервисы и подготовит окружение для подписи документов
по ГОСТ 34.10-2012.</p>
<p class="muted">После установки откроется <code>/admin/setup</code> в bj-server, где можно
загрузить тестовый профиль от MOEX (.7z) и активировать подпись.</p>
<div class="buttons">
<button class="primary" onclick="startPrecheck()">Начать →</button>
</div>
</section>
<!-- ===== Precheck ===== -->
<section class="page" data-stage="precheck">
<h1>Проверка системы</h1>
<div id="precheck-results" class="checks"></div>
<div class="buttons">
<button onclick="goWelcome()">← Назад</button>
<button class="primary" id="goConfigBtn" onclick="goConfig()">Дальше →</button>
</div>
</section>
<!-- ===== Config ===== -->
<section class="page" data-stage="config">
<h1>Настройка</h1>
<form id="config-form" onsubmit="event.preventDefault(); startInstall();">
<label>ИНН организации
<input type="text" name="orgInn" placeholder="7702077840" pattern="\d{10}|\d{12}">
</label>
<label>Название организации (для отображения)
<input type="text" name="orgName" placeholder="ПАО Московская Биржа">
</label>
<label>Email администратора
<input type="email" name="adminEmail" placeholder="admin@example.com">
</label>
<label>Лицензионный ключ (опционально)
<input type="text" name="licenseKey" placeholder="BJ-XXXX-XXXX-XXXX">
<span class="muted">Без ключа сервис работает, но обновления заблокированы. Получить можно в личном кабинете.</span>
</label>
<div class="buttons">
<button type="button" onclick="goPrecheck()">← Назад</button>
<button class="primary" type="submit">Установить →</button>
</div>
</form>
</section>
<!-- ===== Installing ===== -->
<section class="page" data-stage="installing">
<h1>Установка</h1>
<ol id="step-list" class="steps"></ol>
<div class="progress"><div id="progress-bar" class="progress-bar"></div></div>
</section>
<!-- ===== Done ===== -->
<section class="page" data-stage="done">
<h1>✓ Готово</h1>
<p>bj-server и все сервисы запущены. Откройте панель администратора и
импортируйте профиль:</p>
<div class="next-link">
<a href="" id="adminLink" class="primary-link">Перейти в /admin/setup →</a>
</div>
<p class="muted">Что дальше:</p>
<ol>
<li>Подключите USB с .vdk → он автоматически смонтируется в <code>/var/lib/bj/usb/</code></li>
<li>На <code>/admin/setup</code> загрузите .7z с профилем от MOEX и введите пароль</li>
<li>Нажмите «Активировать» — bj-crypto подтянет ключ и подтвердит готовность</li>
</ol>
</section>
<!-- ===== Error ===== -->
<section class="page" data-stage="error">
<h1>✗ Установка прервана</h1>
<p>Произошла ошибка:</p>
<pre id="error-message" class="error"></pre>
<p class="muted">Логи: <code>journalctl -u bj-installer</code> и <code>journalctl -u bj-crypto</code></p>
<div class="buttons">
<button onclick="resetWizard()">Начать заново</button>
</div>
</section>
</main>
<script src="/app.js"></script>
</body>
</html>
-179
View File
@@ -1,179 +0,0 @@
:root {
--bg: #f6f7fb;
--card: #ffffff;
--text: #1d2330;
--muted: #6b7280;
--accent: #2563eb;
--accent-dark: #1d4ed8;
--ok: #16a34a;
--err: #dc2626;
--border: #e5e7eb;
}
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
}
.topbar {
display: flex; align-items: baseline; gap: 16px;
padding: 18px 32px;
background: #0f172a;
color: #fff;
border-bottom: 1px solid #1e293b;
}
.logo { font-weight: 700; font-size: 18px; letter-spacing: 0.3px; }
.subtitle { color: #94a3b8; font-size: 14px; }
main {
max-width: 760px;
margin: 24px auto;
padding: 0 16px;
}
.stepper {
display: flex; gap: 8px;
margin-bottom: 24px;
padding: 12px 14px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 10px;
font-size: 13px;
overflow-x: auto;
}
.stepper span {
padding: 6px 12px;
border-radius: 6px;
color: var(--muted);
white-space: nowrap;
}
.stepper span.active {
background: var(--accent);
color: #fff;
font-weight: 600;
}
.stepper span.done {
color: var(--ok);
}
.page {
display: none;
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 28px 32px;
}
.page.active { display: block; }
h1 { margin: 0 0 16px; font-size: 22px; }
p, label { font-size: 15px; }
.muted { color: var(--muted); font-size: 13px; }
code {
background: #f1f5f9;
padding: 2px 6px;
border-radius: 4px;
font-size: 13px;
}
.buttons {
display: flex; gap: 12px;
margin-top: 24px;
justify-content: flex-end;
}
button, .primary-link {
padding: 10px 18px;
font-size: 14px;
font-weight: 500;
border: 1px solid var(--border);
background: #fff;
color: var(--text);
border-radius: 8px;
cursor: pointer;
text-decoration: none;
display: inline-block;
}
button.primary, .primary-link {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
button.primary:hover, .primary-link:hover { background: var(--accent-dark); }
button:disabled { opacity: 0.5; cursor: not-allowed; }
/* Precheck */
.checks { display: flex; flex-direction: column; gap: 10px; margin-top: 12px; }
.check {
display: flex; gap: 12px; align-items: center;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 8px;
}
.check.ok { border-color: var(--ok); }
.check.bad { border-color: var(--err); }
.check-icon { font-size: 18px; }
.check.ok .check-icon { color: var(--ok); }
.check.bad .check-icon { color: var(--err); }
.check-title { font-weight: 500; }
.check-msg { font-size: 13px; color: var(--muted); }
/* Config form */
#config-form { display: flex; flex-direction: column; gap: 16px; }
#config-form label {
display: flex; flex-direction: column; gap: 4px;
font-weight: 500;
}
#config-form input {
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 8px;
font: inherit;
background: #fff;
}
#config-form input:focus { outline: 2px solid var(--accent); outline-offset: -1px; border-color: var(--accent); }
/* Installing steps */
.steps { list-style: none; padding: 0; margin: 16px 0; }
.steps li {
display: flex; gap: 12px; align-items: flex-start;
padding: 10px 0;
border-bottom: 1px solid var(--border);
}
.steps li:last-child { border-bottom: none; }
.step-icon { font-size: 16px; min-width: 24px; line-height: 1.5; }
.step-title { font-weight: 500; }
.step-msg { font-size: 12px; color: var(--muted); margin-top: 2px; word-break: break-word; }
.step-pending .step-icon { color: var(--muted); }
.step-running .step-icon { color: var(--accent); animation: spin 1.2s linear infinite; }
.step-done .step-icon { color: var(--ok); }
.step-skipped .step-icon { color: var(--muted); }
.step-failed .step-icon { color: var(--err); }
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.progress {
margin-top: 16px;
background: var(--border);
border-radius: 4px;
height: 6px;
overflow: hidden;
}
.progress-bar {
height: 100%;
width: 0%;
background: var(--accent);
transition: width 0.3s ease;
}
/* Done */
.next-link { margin: 20px 0; }
/* Error */
.error {
background: #fef2f2;
border: 1px solid var(--err);
color: var(--err);
padding: 12px;
border-radius: 8px;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 13px;
overflow-x: auto;
white-space: pre-wrap;
}
-512
View File
@@ -1,512 +0,0 @@
package main
// Доступ к кабинету: свой адрес вместо /admin, вход только из белого списка администраторов и
// второй фактор (TOTP, RFC 6238).
//
// Маршруты кабинета внутри остаются /admin/…: adminGate переписывает внешний адрес (State.AdminPath)
// во внутренний, на прямые обращения к /admin при своём адресе отвечает 404 и подменяет Location в
// ответах. Ссылки в шаблонах строятся функцией {{adm}}. При AdminAllowOnly кабинет и страница входа
// видны только адресам белого списка администраторов, остальным — 404. API /v1 не затрагивается.
//
// По SSH: bj-license-server admin-info — адрес и настройки доступа; fw-apply -off — аварийно открыть
// кабинет всем и выключить оборону; admin-config — изменить доступ при остановленном сервере.
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha1"
"encoding/base32"
"encoding/binary"
"flag"
"fmt"
"html/template"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"rsc.io/qr"
)
const (
adminDefault = "/admin"
adminOpenFile = "admin-open" // создаёт fw-apply -off: кабинет открыт всем до повторного включения ограничения
totpIssuer = "BJ License (lic.zetit.ru)"
publicHost = "lic.zetit.ru"
)
var adminSlugRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{7,63}$`)
// adminBase — внешний адрес кабинета: "/admin" или "/<AdminPath>".
func (srv *server) adminBase() string {
base := adminDefault
srv.st.read(func(s *State) {
if s.AdminPath != "" {
base = "/" + s.AdminPath
}
})
return base
}
func (srv *server) adminOpen() bool {
_, err := os.Stat(filepath.Join(srv.dataDir(), adminOpenFile))
return err == nil
}
// adminIPBlocked — кабинет ограничен белым списком, а адрес в него не входит.
func (srv *server) adminIPBlocked(ip string) bool {
blocked := false
srv.st.read(func(s *State) { blocked = s.AdminAllowOnly && !s.allowCovers(ip, false) })
return blocked && !srv.adminOpen()
}
// locRewriter — Location: /admin/… в ответах кабинета заменяется текущим адресом кабинета.
type locRewriter struct {
http.ResponseWriter
srv *server
done bool
}
func (lw *locRewriter) fix() {
if lw.done {
return
}
lw.done = true
h := lw.Header()
if l := h.Get("Location"); l == adminDefault || strings.HasPrefix(l, adminDefault+"/") || strings.HasPrefix(l, adminDefault+"?") {
h.Set("Location", lw.srv.adminBase()+strings.TrimPrefix(l, adminDefault))
}
}
func (lw *locRewriter) WriteHeader(code int) {
lw.fix()
lw.ResponseWriter.WriteHeader(code)
}
func (lw *locRewriter) Write(b []byte) (int, error) {
lw.fix()
return lw.ResponseWriter.Write(b)
}
// adminGate — внешний адрес кабинета, 404 на /admin при своём адресе, ограничение белым списком.
func (srv *server) adminGate(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
base, p := srv.adminBase(), r.URL.Path
under := func(prefix string) bool { return p == prefix || strings.HasPrefix(p, prefix+"/") }
switch {
case base != adminDefault && under(adminDefault):
http.NotFound(w, r)
return
case base != adminDefault && under(base):
r2 := r.Clone(r.Context())
r2.URL.Path = adminDefault + strings.TrimPrefix(p, base)
r2.URL.RawPath = ""
r = r2
case !under(adminDefault):
h.ServeHTTP(w, r) // API, healthz, главная
return
}
if srv.adminIPBlocked(clientIP(r)) {
http.NotFound(w, r)
return
}
h.ServeHTTP(&locRewriter{ResponseWriter: w, srv: srv}, r)
})
}
func (srv *server) setSessionCookie(w http.ResponseWriter, r *http.Request, token string) {
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: token, Path: srv.adminBase(), HttpOnly: true, Secure: isHTTPS(r),
SameSite: http.SameSiteStrictMode, MaxAge: int(sessionMax.Seconds())})
}
// ---- TOTP ----
var b32 = base32.StdEncoding.WithPadding(base32.NoPadding)
func newTOTPSecret() string {
b := make([]byte, 20)
_, _ = rand.Read(b)
return b32.EncodeToString(b)
}
func totpAt(secret string, counter int64) (string, bool) {
key, err := b32.DecodeString(strings.ToUpper(secret))
if err != nil {
return "", false
}
var msg [8]byte
binary.BigEndian.PutUint64(msg[:], uint64(counter))
m := hmac.New(sha1.New, key)
m.Write(msg[:])
sum := m.Sum(nil)
off := sum[len(sum)-1] & 0x0f
v := (binary.BigEndian.Uint32(sum[off:off+4]) & 0x7fffffff) % 1000000
return fmt.Sprintf("%06d", v), true
}
// totpCheck — код текущего, предыдущего или следующего 30-секундного шага, ещё не использованный
// (шаг больше last). Возвращает шаг, чтобы запомнить его и не принять тот же код повторно.
func totpCheck(secret, code string, last int64, now time.Time) (int64, bool) {
code = strings.ReplaceAll(strings.TrimSpace(code), " ", "")
if len(code) != 6 {
return 0, false
}
step := now.Unix() / 30
for d := int64(-1); d <= 1; d++ {
c := step + d
if c <= last {
continue
}
if want, ok := totpAt(secret, c); ok && hmac.Equal([]byte(want), []byte(code)) {
return c, true
}
}
return 0, false
}
func totpURI(login, secret string) string {
return "otpauth://totp/" + url.PathEscape(totpIssuer+":"+login) + "?secret=" + secret +
"&issuer=" + url.QueryEscape(totpIssuer) + "&algorithm=SHA1&digits=6&period=30"
}
// qrSVG — QR-код строкой SVG (с полем в 4 модуля).
func qrSVG(text string) template.HTML {
c, err := qr.Encode(text, qr.M)
if err != nil {
return ""
}
n := c.Size + 8
var b strings.Builder
fmt.Fprintf(&b, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" width="220" height="220" shape-rendering="crispEdges" role="img" aria-label="QR-код для приложения"><rect width="%d" height="%d" fill="#fff"/><path fill="#000" d="`, n, n, n, n)
for y := 0; y < c.Size; y++ {
for x := 0; x < c.Size; x++ {
if c.Black(x, y) {
fmt.Fprintf(&b, "M%d %dh1v1h-1z", x+4, y+4)
}
}
}
b.WriteString(`"/></svg>`)
return template.HTML(b.String())
}
func groupBy4(s string) string {
var parts []string
for i := 0; i < len(s); i += 4 {
parts = append(parts, s[i:min(i+4, len(s))])
}
return strings.Join(parts, " ")
}
// ---- страницы ----
type totpPageData struct {
Enabled bool
SecretGrouped string
URI template.URL
QR template.HTML
}
// meTOTPPage — GET /admin/me/2fa: подключение или отключение второго фактора.
func (srv *server) meTOTPPage(w http.ResponseWriter, r *http.Request, sess *session) {
var d totpPageData
srv.st.read(func(s *State) {
if u := s.user(sess.Login); u != nil {
d.Enabled = u.TOTPSecret != ""
}
})
if !d.Enabled {
srv.sessions.mu.Lock()
if sess.PendingTOTP == "" {
sess.PendingTOTP = newTOTPSecret()
}
secret := sess.PendingTOTP
srv.sessions.mu.Unlock()
uri := totpURI(sess.Login, secret)
d.SecretGrouped, d.URI, d.QR = groupBy4(secret), template.URL(uri), qrSVG(uri)
}
w.Header().Set("Cache-Control", "no-store")
srv.render(w, r, sess, "totp", "Второй фактор", "users", d)
}
func (srv *server) meTOTPEnable(w http.ResponseWriter, r *http.Request, sess *session) {
srv.sessions.mu.Lock()
secret := sess.PendingTOTP
srv.sessions.mu.Unlock()
if secret == "" {
back(w, r, "/admin/me/2fa", "", "обновите страницу и добавьте ключ в приложение заново")
return
}
step, ok := totpCheck(secret, r.PostFormValue("code"), 0, time.Now())
if !ok {
back(w, r, "/admin/me/2fa", "", "код не подошёл: проверьте, что время на телефоне точное, и введите текущий код")
return
}
err := srv.st.write(func(s *State) error {
u := s.user(sess.Login)
if u == nil {
return fmt.Errorf("администратор не найден")
}
u.TOTPSecret, u.TOTPLast = secret, step
s.addEvent("totp_on", "", "", sess.Login, clientIP(r), "подключён второй фактор входа")
return nil
})
if err != nil {
back(w, r, "/admin/me/2fa", "", err.Error())
return
}
srv.sessions.mu.Lock()
sess.PendingTOTP = ""
srv.sessions.mu.Unlock()
back(w, r, "/admin/me/2fa", "Второй фактор подключён: при входе теперь нужен код из приложения.", "")
}
func (srv *server) meTOTPDisable(w http.ResponseWriter, r *http.Request, sess *session) {
var bad string
err := srv.st.write(func(s *State) error {
u := s.user(sess.Login)
if u == nil || u.TOTPSecret == "" {
bad = "второй фактор не подключён"
return nil
}
if _, ok := totpCheck(u.TOTPSecret, r.PostFormValue("code"), 0, time.Now()); !ok {
bad = "код не подошёл"
return nil
}
u.TOTPSecret, u.TOTPLast = "", 0
s.addEvent("totp_off", "", "", sess.Login, clientIP(r), "второй фактор входа отключён")
return nil
})
if err != nil || bad != "" {
back(w, r, "/admin/me/2fa", "", pickStr(bad, fmt.Sprint(err)))
return
}
back(w, r, "/admin/me/2fa", "Второй фактор отключён.", "")
}
// userTOTPReset — POST /admin/users/{login}/2fa-reset: сброс второго фактора другого администратора.
func (srv *server) userTOTPReset(w http.ResponseWriter, r *http.Request, sess *session) {
login := r.PathValue("login")
if strings.EqualFold(login, sess.Login) {
back(w, r, "/admin/users", "", "свой второй фактор отключается на странице «Мой второй фактор» с кодом из приложения")
return
}
err := srv.st.write(func(s *State) error {
u := s.user(login)
if u == nil {
return fmt.Errorf("администратор не найден")
}
u.TOTPSecret, u.TOTPLast = "", 0
s.addEvent("totp_reset", "", "", sess.Login, clientIP(r), "сброшен второй фактор администратора "+u.Login)
return nil
})
if err != nil {
back(w, r, "/admin/users", "", err.Error())
return
}
srv.sessions.dropUser(login, "")
back(w, r, "/admin/users", "Второй фактор "+login+" сброшен, его сессии завершены. При следующем входе хватит пароля.", "")
}
// securityAccess — POST /admin/security/access: адрес кабинета и ограничение белым списком.
func (srv *server) securityAccess(w http.ResponseWriter, r *http.Request, sess *session) {
op, myIP := r.PostFormValue("op"), clientIP(r)
oldBase := srv.adminBase()
var bad, okMsg string
err := srv.st.write(func(s *State) error {
switch op {
case "path", "path-random", "path-default":
slug := strings.ToLower(strings.Trim(strings.TrimSpace(r.PostFormValue("path")), "/"))
switch {
case op == "path-random":
slug = "k" + randHex(12)
case op == "path-default" || slug == "admin":
slug = ""
case !adminSlugRe.MatchString(slug):
bad = "адрес: от 8 до 64 символов — латинские строчные буквы, цифры и дефис"
return nil
}
if slug == s.AdminPath {
bad = "адрес не изменился"
return nil
}
s.AdminPath = slug
shown := adminDefault
if slug != "" {
shown = "/" + slug
}
s.addEvent("admin_path", "", "", sess.Login, myIP, "адрес кабинета изменён на "+shown)
okMsg = "Новый адрес кабинета: https://" + publicHost + shown + "/ — сохраните его в закладки. Прежний адрес больше не открывается."
case "allow-only-on":
if n, ok := normalizeNet(myIP); ok && !s.allowCovers(myIP, false) {
s.AllowIPs = append(s.AllowIPs, &AllowIP{Net: n, Comment: "добавлен автоматически при ограничении кабинета", AddedBy: sess.Login, AddedAt: time.Now()})
s.addEvent("fw_allow_add", "", "", sess.Login, myIP, "IP "+n+" добавлен в белый список (ограничение кабинета)")
}
s.AdminAllowOnly = true
s.addEvent("admin_allow_only_on", "", "", sess.Login, myIP, "кабинет доступен только из белого списка администраторов")
_ = os.Remove(filepath.Join(srv.dataDir(), adminOpenFile))
okMsg = "Кабинет открывается только с адресов белого списка, остальным — «не найдено»."
case "allow-only-off":
s.AdminAllowOnly = false
s.addEvent("admin_allow_only_off", "", "", sess.Login, myIP, "ограничение кабинета белым списком снято")
okMsg = "Ограничение снято: страница входа доступна с любого адреса."
default:
bad = "неизвестная команда"
}
return nil
})
if err != nil || bad != "" {
back(w, r, "/admin/security", "", pickStr(bad, fmt.Sprint(err)))
return
}
if newBase := srv.adminBase(); newBase != oldBase {
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: oldBase, HttpOnly: true, Secure: isHTTPS(r), MaxAge: -1})
srv.setSessionCookie(w, r, sess.Token)
}
back(w, r, "/admin/security", okMsg, "")
}
// ---- консоль ----
type multiFlag []string
func (m *multiFlag) String() string { return strings.Join(*m, ",") }
func (m *multiFlag) Set(v string) error { *m = append(*m, v); return nil }
// adminConfig — `bj-license-server admin-config`: доступ к кабинету из консоли (сервер остановлен).
func adminConfig(args []string) {
fs := flag.NewFlagSet("admin-config", flag.ExitOnError)
data := fs.String("data", "/var/lib/bj-license/state.json", "файл состояния")
var allow multiFlag
fs.Var(&allow, "allow", "добавить в белый список администраторов: IP[=комментарий] (флаг можно повторять)")
path := fs.String("path", "", "адрес кабинета: свой (8–64 символа a-z, 0-9, дефис), random — сгенерировать, admin — стандартный")
only := fs.String("allow-only", "", "кабинет только из белого списка: on | off")
reset := fs.String("reset-2fa", "", "сбросить второй фактор администратора (логин)")
zkFlag := fs.String("zetit-key", "", "вход через ЗЕТИТ Ключ: off — выключить (аварийно)")
_ = fs.Parse(args)
st, err := openStore(*data)
if err != nil {
log.Fatal(err)
}
err = st.write(func(s *State) error {
now := time.Now()
for _, a := range allow {
ipPart, comment, _ := strings.Cut(a, "=")
n, ok := normalizeNet(ipPart)
if !ok {
return fmt.Errorf("неверный адрес %q", ipPart)
}
exists := false
for _, x := range s.AllowIPs {
exists = exists || x.Net == n
}
if !exists {
s.AllowIPs = append(s.AllowIPs, &AllowIP{Net: n, Comment: strings.TrimSpace(comment), AddedBy: "консоль", AddedAt: now})
s.addEvent("fw_allow_add", "", "", "консоль", "", "IP "+n+" добавлен в белый список")
}
}
switch p := strings.ToLower(strings.Trim(*path, "/")); {
case p == "":
case p == "admin":
s.AdminPath = ""
s.addEvent("admin_path", "", "", "консоль", "", "адрес кабинета: стандартный /admin")
case p == "random":
s.AdminPath = "k" + randHex(12)
s.addEvent("admin_path", "", "", "консоль", "", "адрес кабинета изменён")
case adminSlugRe.MatchString(p):
s.AdminPath = p
s.addEvent("admin_path", "", "", "консоль", "", "адрес кабинета изменён")
default:
return fmt.Errorf("адрес: 8–64 символа, латинские строчные буквы, цифры и дефис")
}
switch *only {
case "":
case "on":
if len(s.AllowIPs) == 0 {
return fmt.Errorf("белый список пуст — ограничение закроет кабинет всем")
}
s.AdminAllowOnly = true
s.addEvent("admin_allow_only_on", "", "", "консоль", "", "кабинет доступен только из белого списка администраторов")
case "off":
s.AdminAllowOnly = false
s.addEvent("admin_allow_only_off", "", "", "консоль", "", "ограничение кабинета белым списком снято")
default:
return fmt.Errorf("-allow-only: on или off")
}
switch *zkFlag {
case "":
case "off":
s.ZetitKey.Enabled = false
s.addEvent("zetit_key", "", "", "консоль", "", "вход через ЗЕТИТ Ключ выключен")
default:
return fmt.Errorf("-zetit-key: только off")
}
if *reset != "" {
u := s.user(*reset)
if u == nil {
return fmt.Errorf("администратор %s не найден", *reset)
}
u.TOTPSecret, u.TOTPLast = "", 0
s.addEvent("totp_reset", "", "", "консоль", "", "сброшен второй фактор администратора "+u.Login)
}
return nil
})
if err != nil {
log.Fatal(err)
}
if *only == "on" {
_ = os.Remove(filepath.Join(filepath.Dir(*data), adminOpenFile))
}
printAccess(st)
}
// adminInfo — `bj-license-server admin-info`: адрес и настройки доступа (только чтение).
func adminInfo(args []string) {
fs := flag.NewFlagSet("admin-info", flag.ExitOnError)
data := fs.String("data", "/var/lib/bj-license/state.json", "файл состояния")
_ = fs.Parse(args)
st, err := openStore(*data)
if err != nil {
log.Fatal(err)
}
printAccess(st)
}
func printAccess(st *store) {
_, openErr := os.Stat(filepath.Join(filepath.Dir(st.path), adminOpenFile))
st.read(func(s *State) {
path := adminDefault
if s.AdminPath != "" {
path = "/" + s.AdminPath
}
fmt.Printf("Адрес кабинета: https://%s%s/\n", publicHost, path)
switch {
case s.AdminAllowOnly && openErr == nil:
fmt.Println("Только из белого списка: включено, но кабинет открыт аварийно (fw-apply -off)")
case s.AdminAllowOnly:
fmt.Println("Только из белого списка: включено")
default:
fmt.Println("Только из белого списка: выключено")
}
fmt.Println("Белый список администраторов:")
for _, a := range s.AllowIPs {
fmt.Printf(" %-20s %s\n", a.Net, a.Comment)
}
if s.ZetitKey.Enabled {
fmt.Println("Вход через ЗЕТИТ Ключ: включён (" + pickStr(s.ZetitKey.URL, zetitKeyDefaultURL) + ")")
} else {
fmt.Println("Вход через ЗЕТИТ Ключ: выключен")
}
fmt.Println("Администраторы (второй фактор):")
for _, u := range s.Users {
state := "не подключён"
if u.TOTPSecret != "" {
state = "подключён"
}
fmt.Printf(" %-24s TOTP %s, e-mail %s\n", u.Login, state, pickStr(u.Email, "не указан"))
}
})
}
File diff suppressed because it is too large Load Diff
-291
View File
@@ -1,291 +0,0 @@
package main
// API для установок bj: активация по коду и продление токена (docs/license-server-api.md).
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/license"
)
const (
tokenTTL = 60 * 24 * time.Hour
renewAfterSec = 86400
)
type apiRequest struct {
Code string `json:"code"`
Token string `json:"token"`
InstallID string `json:"install_id"`
Machine string `json:"machine"`
Hostname string `json:"hostname"`
Version string `json:"version"`
}
type apiResponse struct {
Token string `json:"token"`
LicenseID string `json:"license_id"`
Tenant string `json:"tenant"`
ExpiresAt time.Time `json:"expires_at"`
ContractUntil time.Time `json:"contract_until"`
RenewAfterSec int `json:"renew_after_sec"`
}
type apiError struct {
Status int `json:"-"`
Code string `json:"error"`
Message string `json:"message"`
}
func apiErr(status int, code, msg string) *apiError {
return &apiError{Status: status, Code: code, Message: msg}
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func clip(s string, n int) string {
s = strings.TrimSpace(s)
if r := []rune(s); len(r) > n {
return string(r[:n])
}
return s
}
func (srv *server) decodeAPI(w http.ResponseWriter, r *http.Request) (*apiRequest, bool) {
if !srv.apiRate.allow(clientIP(r)) {
writeJSON(w, http.StatusTooManyRequests, apiErr(429, "rate_limited", "слишком много запросов, повторите позже"))
return nil, false
}
var in apiRequest
if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, apiErr(400, "bad_request", "тело запроса — JSON по docs/license-server-api.md"))
return nil, false
}
in.InstallID = strings.ToLower(strings.TrimSpace(in.InstallID))
if !uuidRe.MatchString(in.InstallID) {
writeJSON(w, http.StatusBadRequest, apiErr(400, "bad_request", "install_id: ожидается UUID"))
return nil, false
}
in.Machine, in.Hostname, in.Version = clip(in.Machine, 128), clip(in.Hostname, 128), clip(in.Version, 40)
return &in, true
}
// issueToken — подписанная лицензия под установку: срок токена 60 дней, не дольше договора.
func (srv *server) issueToken(s *State, lic *License, installID string, now time.Time) (string, time.Time, error) {
exp := now.Add(tokenTTL)
if !lic.ContractUntil.IsZero() && lic.ContractUntil.Before(exp) {
exp = lic.ContractUntil
}
tenant := ""
if t := s.tenant(lic.TenantID); t != nil {
tenant = t.Name
}
l := &license.License{
Schema: license.CurrentSchema, ID: lic.ID, Tenant: tenant, Product: "bj-server",
Plan: license.Plan(lic.Plan), IssuedAt: now.UTC(), ExpiresAt: exp.UTC(),
Features: lic.Features, MaxNodes: lic.MaxNodes, InstallID: installID, ContractUntil: lic.ContractUntil.UTC(),
}
tok, err := license.Sign(l, srv.priv, srv.keyID)
if err != nil {
return "", time.Time{}, err
}
return tok.Encode(), exp, nil
}
func (srv *server) answer(s *State, lic *License, token string, exp time.Time) *apiResponse {
tenant := ""
if t := s.tenant(lic.TenantID); t != nil {
tenant = t.Name
}
return &apiResponse{Token: token, LicenseID: lic.ID, Tenant: tenant, ExpiresAt: exp.UTC(), ContractUntil: lic.ContractUntil.UTC(), RenewAfterSec: renewAfterSec}
}
// touchInstall — сведения об установке из запроса; смена machine-id — предупреждение в журнал.
func touchInstall(s *State, inst *Install, in *apiRequest, ip string, now time.Time) {
if in.Machine != "" && inst.Machine != "" && in.Machine != inst.Machine {
s.addEvent("machine_changed", inst.LicenseID, inst.InstallID, "api", ip,
fmt.Sprintf("сменился machine-id установки (%s → %s): возможно, копия сервера", short(inst.Machine), short(in.Machine)))
}
if in.Machine != "" {
inst.Machine = in.Machine
}
if in.Hostname != "" {
inst.Hostname = in.Hostname
}
if in.Version != "" {
inst.Version = in.Version
}
inst.IP, inst.LastSeen = ip, now
}
// trustInstallIP — после успешной активации или продления адрес установки попадает в доверенные fail2ban.
func trustInstallIP(s *State, inst *Install, ip string, now time.Time) {
if s.trustIP(inst, ip, now) {
s.addEvent("trust_ip", inst.LicenseID, inst.InstallID, "api", ip, "IP "+ip+" добавлен в доверенные fail2ban")
}
}
func hostNote(in *apiRequest) string {
if in.Hostname == "" {
return ""
}
return " (" + in.Hostname + ")"
}
// POST /v1/activate
func (srv *server) apiActivate(w http.ResponseWriter, r *http.Request) {
in, ok := srv.decodeAPI(w, r)
if !ok {
return
}
ip := clientIP(r)
code := normalizeCode(in.Code)
var resp *apiResponse
var fail *apiError
err := srv.st.write(func(s *State) error {
now := time.Now()
var lic *License
if code != "" {
lic = s.licenseByCode(code)
}
if lic == nil {
fail = apiErr(http.StatusNotFound, "unknown_code", "код активации не найден")
log.Printf("api denied: unknown_code ip=%s", ip) // строку ловит fail2ban
s.addEvent("activate_denied", "", in.InstallID, "api", ip, "неизвестный код активации"+hostNote(in))
return nil
}
inst := s.install(in.InstallID)
switch {
case lic.Revoked():
fail = apiErr(http.StatusForbidden, "revoked", "лицензия отозвана")
case lic.ContractExpired(now):
fail = apiErr(http.StatusForbidden, "contract_expired", "срок договора истёк "+lic.ContractUntil.In(msk).Format("02.01.2006"))
case inst != nil && inst.Blocked:
fail = apiErr(http.StatusForbidden, "install_blocked", "установка заблокирована: "+inst.BlockReason)
case (inst == nil || inst.LicenseID != lic.ID) && lic.MaxNodes > 0 && s.activeInstalls(lic.ID) >= lic.MaxNodes:
fail = apiErr(http.StatusConflict, "max_nodes", fmt.Sprintf("исчерпан лимит установок по лицензии (%d)", lic.MaxNodes))
}
if fail != nil {
s.addEvent("activate_denied", lic.ID, in.InstallID, "api", ip, fail.Message+hostNote(in))
return nil
}
text := "активирована новая установка" + hostNote(in)
switch {
case inst == nil:
inst = &Install{InstallID: in.InstallID, FirstSeen: now}
s.Installs = append(s.Installs, inst)
case inst.LicenseID != lic.ID:
text = "установка переведена на эту лицензию с " + short(inst.LicenseID) + hostNote(in)
default:
text = "повторная активация установки" + hostNote(in)
}
inst.LicenseID = lic.ID
touchInstall(s, inst, in, ip, now)
trustInstallIP(s, inst, ip, now)
token, exp, err := srv.issueToken(s, lic, inst.InstallID, now)
if err != nil {
return err
}
inst.TokenExpires = exp
s.addEvent("activate", lic.ID, inst.InstallID, "api", ip, text+", токен до "+exp.In(msk).Format("02.01.2006"))
resp = srv.answer(s, lic, token, exp)
return nil
})
srv.finishAPI(w, resp, fail, err)
}
// POST /v1/renew
func (srv *server) apiRenew(w http.ResponseWriter, r *http.Request) {
in, ok := srv.decodeAPI(w, r)
if !ok {
return
}
ip := clientIP(r)
tok, err := license.DecodeToken(in.Token)
if err != nil {
writeJSON(w, http.StatusUnauthorized, apiErr(401, "bad_token", "токен не разобран"))
return
}
l, err := license.Verify(tok, srv.pub)
if err != nil {
log.Printf("api denied: bad_token ip=%s", ip) // строку ловит fail2ban
writeJSON(w, http.StatusUnauthorized, apiErr(401, "bad_token", "подпись токена недействительна"))
return
}
if l.InstallID == "" || !strings.EqualFold(l.InstallID, in.InstallID) {
writeJSON(w, http.StatusForbidden, apiErr(403, "install_mismatch", "токен выдан другой установке"))
return
}
var resp *apiResponse
var fail *apiError
err = srv.st.write(func(s *State) error {
now := time.Now()
lic := s.license(l.ID)
if lic == nil {
fail = apiErr(http.StatusUnauthorized, "bad_token", "лицензия из токена не найдена")
return nil
}
inst := s.install(in.InstallID)
switch {
case inst == nil || inst.LicenseID != lic.ID:
fail = apiErr(http.StatusNotFound, "unknown_install", "установка не зарегистрирована по этой лицензии — активируйте кодом")
case lic.Revoked():
fail = apiErr(http.StatusForbidden, "revoked", "лицензия отозвана")
case lic.ContractExpired(now):
fail = apiErr(http.StatusForbidden, "contract_expired", "срок договора истёк "+lic.ContractUntil.In(msk).Format("02.01.2006"))
case inst.Blocked:
fail = apiErr(http.StatusForbidden, "install_blocked", "установка заблокирована: "+inst.BlockReason)
}
if fail != nil {
s.addEvent("renew_denied", lic.ID, in.InstallID, "api", ip, fail.Message+hostNote(in))
return nil
}
touchInstall(s, inst, in, ip, now)
trustInstallIP(s, inst, ip, now)
token, exp, err := srv.issueToken(s, lic, inst.InstallID, now)
if err != nil {
return err
}
inst.TokenExpires = exp
s.addEvent("renew", lic.ID, inst.InstallID, "api", ip, fmt.Sprintf("продление%s, версия %s, токен до %s", hostNote(in), pickStr(in.Version, "—"), exp.In(msk).Format("02.01.2006")))
resp = srv.answer(s, lic, token, exp)
return nil
})
srv.finishAPI(w, resp, fail, err)
}
func (srv *server) finishAPI(w http.ResponseWriter, resp *apiResponse, fail *apiError, err error) {
switch {
case err != nil:
writeJSON(w, http.StatusInternalServerError, apiErr(500, "internal", "внутренняя ошибка сервера лицензий"))
case fail != nil:
writeJSON(w, fail.Status, fail)
default:
writeJSON(w, http.StatusOK, resp)
}
}
func pickStr(v, def string) string {
if strings.TrimSpace(v) == "" {
return def
}
return v
}
func short(id string) string {
if len(id) > 8 {
return id[:8]
}
return id
}
-917
View File
@@ -1,917 +0,0 @@
package main
// Защита сервера лицензий из кабинета: fail2ban и «режим обороны».
//
// Кабинет работает непривилегированно (пользователь bj-license) и только пишет желаемое
// состояние рядом с state.json: белый список администраторов (allow-ips.txt) и режим с
// разовыми командами fail2ban (fw-control.json). Применяет их root-помощник
// `bj-license-server fw-apply` — systemd запускает его при изменении fw-control.json
// (bj-license-fw.path) и раз в 30 секунд (bj-license-fw.timer); фактическое состояние
// он пишет в fw-status.json, его и показывает кабинет.
//
// Режим обороны: на порты 80/443 пускаются только адреса из белого списка (ipset
// bj-allow / bj-allow6) — доверенные IP установок (trusted-ips.txt) и адреса
// администраторов (allow-ips.txt). SSH не затрагивается. Аварийное выключение по SSH:
//
// /opt/bj-license/bj-license-server fw-apply -off
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"log"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"syscall"
"time"
)
const (
fwControlFile = "fw-control.json"
fwStatusFile = "fw-status.json"
fwAllowFile = "allow-ips.txt"
fwOffFile = "fw-off" // создаёт fw-apply -off; удаляет кабинет при повторном включении обороны
fwSetV4 = "bj-allow"
fwSetV6 = "bj-allow6"
fwRuleComment = "bj-license-lockdown"
fwStatusStale = 2 * time.Minute
)
// fwJails — jail fail2ban, которыми управляет кабинет.
var fwJails = []string{"bj-license-api", "bj-license-admin", "sshd"}
// AllowIP — адрес или сеть администратора в белом списке.
type AllowIP struct {
Net string `json:"net"`
Comment string `json:"comment,omitempty"`
AddedBy string `json:"added_by,omitempty"`
AddedAt time.Time `json:"added_at"`
}
// FirewallState — желаемое состояние защиты: пишет кабинет, применяет fw-apply.
type FirewallState struct {
Mode string `json:"mode,omitempty"` // "" — обычный, lockdown — оборона
Until time.Time `json:"until,omitempty"`
UpdatedBy string `json:"updated_by,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
Actions []fwAction `json:"actions,omitempty"`
// Параметры блокировок jail (секунды; bantime -1 — навсегда). Пусто — значения из jail.d/bj-license.conf.
Jails map[string]fwJailConf `json:"jails,omitempty"`
// Блокировка входа в кабинет самим приложением (до fail2ban): попыток и срок.
LoginAttempts int `json:"login_attempts,omitempty"`
LoginLockSec int64 `json:"login_lock_sec,omitempty"`
}
// fwJailConf — параметры блокировки jail.
type fwJailConf struct {
BanTime int64 `json:"bantime"`
FindTime int64 `json:"findtime"`
MaxRetry int `json:"maxretry"`
}
// fwDefaultJail — значения из deploy/license/fail2ban/jail.d/bj-license.conf.
func fwDefaultJail(name string) fwJailConf {
if name == "sshd" {
return fwJailConf{BanTime: 3600, FindTime: 600, MaxRetry: 5}
}
return fwJailConf{BanTime: 3600, FindTime: 600, MaxRetry: 10}
}
type fwPreset struct {
Sec int64
Label string
}
var (
fwBanPresets = []fwPreset{{900, "15 минут"}, {3600, "1 час"}, {7200, "2 часа"}, {21600, "6 часов"}, {43200, "12 часов"},
{86400, "1 день"}, {259200, "3 дня"}, {604800, "1 неделя"}, {2592000, "1 месяц"}, {7776000, "3 месяца"},
{31536000, "1 год"}, {-1, "навсегда"}}
fwFindPresets = []fwPreset{{600, "10 минут"}, {3600, "1 час"}, {21600, "6 часов"}, {86400, "1 день"}, {604800, "1 неделя"}}
fwRetryPresets = []int{3, 5, 10, 20}
)
// durRu — срок по-русски: из пресетов или в секундах.
func durRu(sec int64) string {
for _, p := range fwBanPresets {
if p.Sec == sec {
return p.Label
}
}
for _, p := range fwFindPresets {
if p.Sec == sec {
return p.Label
}
}
return strconv.FormatInt(sec, 10) + " с"
}
func fwPresetOK(list []fwPreset, sec int64) bool {
for _, p := range list {
if p.Sec == sec {
return true
}
}
return false
}
// loginLimitOf — ограничение входа в кабинет: попыток и срок (по умолчанию 8 за 15 минут).
func loginLimitOf(s *State) (int, time.Duration) {
n, sec := s.Firewall.LoginAttempts, s.Firewall.LoginLockSec
if n <= 0 {
n = 8
}
if sec <= 0 {
sec = 900
}
return n, time.Duration(sec) * time.Second
}
// fwAction — разовая команда fail2ban из кабинета.
type fwAction struct {
ID string `json:"id"`
Op string `json:"op"` // ban | unban
Jail string `json:"jail"`
IP string `json:"ip"`
At time.Time `json:"at"`
By string `json:"by,omitempty"`
}
type fwJailStatus struct {
Name string `json:"name"`
BanTime int64 `json:"bantime"`
FindTime int64 `json:"findtime"`
MaxRetry int `json:"maxretry"`
CurrentlyFailed int `json:"currently_failed"`
TotalFailed int `json:"total_failed"`
CurrentlyBanned int `json:"currently_banned"`
TotalBanned int `json:"total_banned"`
Banned []string `json:"banned"`
}
// fwStatus — фактическое состояние, пишет fw-apply.
type fwStatus struct {
At time.Time `json:"at"`
Lockdown bool `json:"lockdown"` // правило обороны действует
Emergency bool `json:"emergency"` // оборона выключена аварийно (fw-apply -off)
Until time.Time `json:"until,omitempty"`
AllowCount int `json:"allow_count"`
Jails []fwJailStatus `json:"jails"`
Done []string `json:"done,omitempty"`
Errors []string `json:"errors,omitempty"`
}
// normalizeNet — IP или сеть в каноническом виде; слишком широкие сети не принимаются.
func normalizeNet(s string) (string, bool) {
s = strings.TrimSpace(s)
if ip := net.ParseIP(s); ip != nil {
return ip.String(), true
}
_, n, err := net.ParseCIDR(s)
if err != nil {
return "", false
}
ones, bits := n.Mask.Size()
if (bits == 32 && ones < 16) || (bits == 128 && ones < 32) {
return "", false
}
return n.String(), true
}
// netContains — адрес ip попадает в IP или сеть entry.
func netContains(entry, ip string) bool {
p := net.ParseIP(strings.TrimSpace(ip))
if p == nil {
return false
}
if e := net.ParseIP(entry); e != nil {
return e.Equal(p)
}
_, n, err := net.ParseCIDR(entry)
return err == nil && n.Contains(p)
}
// allowCovers — адрес в белом списке администраторов (includeTrusted — или среди доверенных установок).
func (s *State) allowCovers(ip string, includeTrusted bool) bool {
for _, a := range s.AllowIPs {
if netContains(a.Net, ip) {
return true
}
}
if includeTrusted {
for _, t := range s.trustedIPs() {
if netContains(t, ip) {
return true
}
}
}
return false
}
// writeIfChanged — атомарная запись файла, только если содержимое изменилось.
func writeIfChanged(path string, data []byte, perm os.FileMode) error {
if old, err := os.ReadFile(path); err == nil && bytes.Equal(old, data) {
return nil
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, perm); err != nil {
return err
}
return os.Rename(tmp, path)
}
// writeFirewallLocked — allow-ips.txt и fw-control.json для fw-apply; вызывать под блокировкой.
func (st *store) writeFirewallLocked() error {
dir := filepath.Dir(st.path)
body := "# Белый список администраторов (IP или сеть). Файл пишет bj-license-server.\n"
for _, a := range st.s.AllowIPs {
body += a.Net + "\n"
}
if err := writeIfChanged(filepath.Join(dir, fwAllowFile), []byte(body), 0o600); err != nil {
return err
}
b, err := json.MarshalIndent(st.s.Firewall, "", " ")
if err != nil {
return err
}
return writeIfChanged(filepath.Join(dir, fwControlFile), b, 0o600)
}
func fwReadStatus(dir string) fwStatus {
var st fwStatus
if b, err := os.ReadFile(filepath.Join(dir, fwStatusFile)); err == nil {
_ = json.Unmarshal(b, &st)
}
return st
}
// ---- root-помощник ----
// fwApply — `bj-license-server fw-apply [-dir …] [-off]`: применить желаемое состояние (от root).
func fwApply(args []string) {
fs := flag.NewFlagSet("fw-apply", flag.ExitOnError)
dir := fs.String("dir", "/var/lib/bj-license", "каталог данных сервера лицензий")
off := fs.Bool("off", false, "аварийно выключить режим обороны (до повторного включения в кабинете)")
_ = fs.Parse(args)
if os.Geteuid() != 0 {
log.Fatal("fw-apply запускается от root")
}
if *off {
if err := os.WriteFile(filepath.Join(*dir, fwOffFile), []byte(time.Now().Format(time.RFC3339)+"\n"), 0o644); err != nil {
log.Fatalf("аварийное выключение: %v", err)
}
chownLikeDir(*dir, filepath.Join(*dir, fwOffFile))
// Заодно открыть кабинет всем адресам, если он ограничен белым списком (access.go).
if err := os.WriteFile(filepath.Join(*dir, adminOpenFile), []byte(time.Now().Format(time.RFC3339)+"\n"), 0o644); err == nil {
chownLikeDir(*dir, filepath.Join(*dir, adminOpenFile))
}
}
st := fwRun(*dir)
if err := fwWriteStatus(*dir, st); err != nil {
log.Printf("fw-status.json: %v", err)
}
for _, e := range st.Errors {
log.Print(e)
}
if *off {
fmt.Println("Режим обороны выключен аварийно, кабинет открыт для всех адресов. Включить снова — в кабинете, раздел «Защита».")
fmt.Println("Адрес кабинета: bj-license-server admin-info")
}
}
func fwRun(dir string) fwStatus {
now := time.Now()
st := fwStatus{At: now}
prev := fwReadStatus(dir)
var ctl FirewallState
if b, err := os.ReadFile(filepath.Join(dir, fwControlFile)); err == nil {
if err := json.Unmarshal(b, &ctl); err != nil {
st.Errors = append(st.Errors, "fw-control.json: "+err.Error())
}
}
if _, err := os.Stat(filepath.Join(dir, fwOffFile)); err == nil {
st.Emergency = true
}
want := ctl.Mode == "lockdown" && !st.Emergency && (ctl.Until.IsZero() || now.Before(ctl.Until))
v4, v6 := fwReadAllow(dir)
st.AllowCount = len(v4) + len(v6)
if err := fwSyncSet(fwSetV4, "inet", v4); err != nil {
st.Errors = append(st.Errors, err.Error())
want = false
}
if err := fwSyncSet(fwSetV6, "inet6", v6); err != nil {
st.Errors = append(st.Errors, err.Error())
want = false
}
if want && st.AllowCount == 0 {
st.Errors = append(st.Errors, "белый список пуст — режим обороны не включён, иначе доступ закроется всем")
want = false
}
ok4 := fwRule("iptables", fwSetV4, want, &st.Errors)
ok6 := fwRule("ip6tables", fwSetV6, want, &st.Errors)
st.Lockdown = want && ok4 && ok6
if st.Lockdown {
st.Until = ctl.Until
}
if err := fwApplyJailConf(ctl.Jails); err != nil {
st.Errors = append(st.Errors, err.Error())
}
done := map[string]bool{}
for _, id := range prev.Done {
done[id] = true
}
for _, a := range ctl.Actions {
if !done[a.ID] {
if err := fwRunAction(a); err != nil {
st.Errors = append(st.Errors, err.Error())
}
done[a.ID] = true
}
st.Done = append(st.Done, a.ID) // только команды, ещё лежащие в fw-control.json
}
for _, j := range fwJails {
js, err := fwJail(j)
if err != nil {
st.Errors = append(st.Errors, err.Error())
continue
}
st.Jails = append(st.Jails, js)
}
return st
}
// fwReadAllow — белый список для ipset: доверенные установки и администраторы.
func fwReadAllow(dir string) (v4, v6 []string) {
seen := map[string]bool{}
for _, name := range []string{trustedFile, fwAllowFile} {
b, err := os.ReadFile(filepath.Join(dir, name))
if err != nil {
continue
}
for _, line := range strings.Split(string(b), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
n, ok := normalizeNet(line)
if !ok || seen[n] {
continue
}
seen[n] = true
if strings.Contains(n, ":") {
v6 = append(v6, n)
} else {
v4 = append(v4, n)
}
}
}
return v4, v6
}
// fwSyncSet — содержимое ipset заменяется атомарно (временный набор + swap).
func fwSyncSet(name, family string, nets []string) error {
tmp := name + "-tmp"
var b strings.Builder
fmt.Fprintf(&b, "create %s hash:net family %s -exist\n", name, family)
fmt.Fprintf(&b, "create %s hash:net family %s -exist\nflush %s\n", tmp, family, tmp)
for _, n := range nets {
fmt.Fprintf(&b, "add %s %s -exist\n", tmp, n)
}
fmt.Fprintf(&b, "swap %s %s\ndestroy %s\n", tmp, name, tmp)
cmd := exec.Command("ipset", "restore")
cmd.Stdin = strings.NewReader(b.String())
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("ipset %s: %v: %s", name, err, strings.TrimSpace(string(out)))
}
return nil
}
// fwRuleSpec — правило обороны: 80/443 только из белого списка.
func fwRuleSpec(set string) []string {
return []string{"-p", "tcp", "-m", "multiport", "--dports", "80,443", "-m", "set", "!", "--match-set", set, "src",
"-m", "comment", "--comment", fwRuleComment, "-j", "DROP"}
}
// fwRule — привести наличие правила к want; true — правило в нужном состоянии.
func fwRule(bin, set string, want bool, errs *[]string) bool {
has := func() bool {
return exec.Command(bin, append([]string{"-w", "-C", "INPUT"}, fwRuleSpec(set)...)...).Run() == nil
}
if want && !has() {
if out, err := exec.Command(bin, append([]string{"-w", "-I", "INPUT", "1"}, fwRuleSpec(set)...)...).CombinedOutput(); err != nil {
*errs = append(*errs, fmt.Sprintf("%s: включение обороны: %v: %s", bin, err, strings.TrimSpace(string(out))))
return false
}
}
for i := 0; !want && has() && i < 10; i++ {
if out, err := exec.Command(bin, append([]string{"-w", "-D", "INPUT"}, fwRuleSpec(set)...)...).CombinedOutput(); err != nil {
*errs = append(*errs, fmt.Sprintf("%s: выключение обороны: %v: %s", bin, err, strings.TrimSpace(string(out))))
return false
}
}
return want == has()
}
func fwRunAction(a fwAction) error {
ip := net.ParseIP(a.IP)
known := false
for _, j := range fwJails {
known = known || j == a.Jail
}
if ip == nil || !known || (a.Op != "ban" && a.Op != "unban") {
return fmt.Errorf("команда %s %s %s отклонена: неверные параметры", a.Op, a.Jail, a.IP)
}
out, err := exec.Command("fail2ban-client", "set", a.Jail, a.Op+"ip", ip.String()).CombinedOutput()
if err != nil {
return fmt.Errorf("fail2ban %s %s %s: %v: %s", a.Jail, a.Op, ip, err, strings.TrimSpace(string(out)))
}
return nil
}
const (
fwJailOverride = "/etc/fail2ban/jail.d/zz-bj-license-ui.local"
fwServerDefault = "/etc/fail2ban/fail2ban.d/zz-bj-license-ui.local"
)
// fwApplyJailConf — параметры блокировок из кабинета в override fail2ban; при изменении — reload.
func fwApplyJailConf(conf map[string]fwJailConf) error {
var b strings.Builder
b.WriteString("# Параметры блокировок из кабинета сервера лицензий (раздел «Защита»). Файл пишет bj-license-server fw-apply.\n")
var maxBan int64 = 86400
forever := false
for _, j := range fwJails {
c, ok := conf[j]
if !ok {
continue
}
if c.BanTime == -1 {
forever = true
} else if c.BanTime > maxBan {
maxBan = c.BanTime
}
fmt.Fprintf(&b, "\n[%s]\nbantime = %d\nfindtime = %d\nmaxretry = %d\n", j, c.BanTime, c.FindTime, c.MaxRetry)
}
old, _ := os.ReadFile(fwJailOverride)
if string(old) == b.String() {
return nil
}
if err := os.WriteFile(fwJailOverride, []byte(b.String()), 0o644); err != nil {
return fmt.Errorf("параметры блокировок: %w", err)
}
// Долгие баны должны пережить перезапуск fail2ban: база банов хранится дольше самого долгого срока.
purge := maxBan + 86400
if forever && purge < 5*31536000 {
purge = 5 * 31536000
}
_ = os.WriteFile(fwServerDefault, []byte(fmt.Sprintf("# Файл пишет bj-license-server fw-apply.\n[Definition]\ndbpurgeage = %d\n", purge)), 0o644)
if out, err := exec.Command("fail2ban-client", "reload").CombinedOutput(); err != nil {
return fmt.Errorf("fail2ban reload: %v: %s", err, strings.TrimSpace(string(out)))
}
_ = exec.Command("fail2ban-client", "set", "dbpurgeage", strconv.FormatInt(purge, 10)).Run()
return nil
}
var fwNumRe = regexp.MustCompile(`(Currently failed|Total failed|Currently banned|Total banned):\s*(\d+)`)
func fwJail(name string) (fwJailStatus, error) {
js := fwJailStatus{Name: name}
out, err := exec.Command("fail2ban-client", "status", name).CombinedOutput()
if err != nil {
return js, fmt.Errorf("fail2ban status %s: %v: %s", name, err, strings.TrimSpace(string(out)))
}
text := string(out)
for _, m := range fwNumRe.FindAllStringSubmatch(text, -1) {
n, _ := strconv.Atoi(m[2])
switch m[1] {
case "Currently failed":
js.CurrentlyFailed = n
case "Total failed":
js.TotalFailed = n
case "Currently banned":
js.CurrentlyBanned = n
case "Total banned":
js.TotalBanned = n
}
}
if i := strings.Index(text, "Banned IP list:"); i >= 0 {
line, _, _ := strings.Cut(text[i+len("Banned IP list:"):], "\n")
js.Banned = strings.Fields(line)
}
get := func(opt string) int64 {
out, err := exec.Command("fail2ban-client", "get", name, opt).Output()
if err != nil {
return 0
}
n, _ := strconv.ParseInt(strings.TrimSpace(string(out)), 10, 64)
return n
}
js.BanTime, js.FindTime, js.MaxRetry = get("bantime"), get("findtime"), int(get("maxretry"))
return js, nil
}
func chownLikeDir(dir, path string) {
if fi, err := os.Stat(dir); err == nil {
if s, ok := fi.Sys().(*syscall.Stat_t); ok {
_ = os.Chown(path, int(s.Uid), int(s.Gid))
}
}
}
func fwWriteStatus(dir string, st fwStatus) error {
b, err := json.MarshalIndent(st, "", " ")
if err != nil {
return err
}
tmp := filepath.Join(dir, fwStatusFile+".tmp")
if err := os.WriteFile(tmp, b, 0o600); err != nil {
return err
}
chownLikeDir(dir, tmp)
return os.Rename(tmp, filepath.Join(dir, fwStatusFile))
}
// fwIgnore — `bj-license-server fw-ignore [-dir …] <ip> api|admin` для ignorecommand fail2ban:
// код 0 — адрес не блокировать. api — доверенные установки и белый список, admin — только белый список.
func fwIgnore(args []string) {
fs := flag.NewFlagSet("fw-ignore", flag.ExitOnError)
dir := fs.String("dir", "/var/lib/bj-license", "каталог данных сервера лицензий")
_ = fs.Parse(args)
ip, kind := fs.Arg(0), fs.Arg(1)
if net.ParseIP(ip) == nil {
os.Exit(1)
}
files := []string{fwAllowFile}
if kind != "admin" {
files = append(files, trustedFile)
}
for _, name := range files {
b, err := os.ReadFile(filepath.Join(*dir, name))
if err != nil {
continue
}
for _, line := range strings.Split(string(b), "\n") {
line = strings.TrimSpace(line)
if line != "" && !strings.HasPrefix(line, "#") && netContains(line, ip) {
os.Exit(0)
}
}
}
os.Exit(1)
}
// ---- кабинет: раздел «Защита» ----
type securityData struct {
Status fwStatus
StatusFresh bool
Wanted FirewallState
WantedActive bool
Allow []AllowIP
TrustedCount int
Pending []fwAction
MyIP string
MyIPCovered bool
Jails []string
// Доступ к кабинету.
Host string
AdminBase string
AdminDefault bool
AdminAllowOnly bool
AdminOpen bool
TwoFA, Admins int
ZKEnabled bool
ZKHasKey bool
ZKURL string
ZKWithEmail int
Limits []fwLimitRow
BanPresets []fwPreset
FindPresets []fwPreset
RetryPresets []int
LoginTries int
LoginLockSec int64
}
// fwLimitRow — строка карточки «Параметры блокировок».
type fwLimitRow struct {
Jail, Title string
Conf fwJailConf
Effective string // действующие значения в fail2ban
HasEffective bool
BanOpts []fwPreset
FindOpts []fwPreset
RetryOpts []int
}
// withPreset — варианты выбора с текущим значением, даже если его нет среди стандартных.
func withPreset(list []fwPreset, sec int64) []fwPreset {
if fwPresetOK(list, sec) {
return list
}
return append(append([]fwPreset{}, list...), fwPreset{sec, durRu(sec)})
}
func withRetry(list []int, n int) []int {
for _, v := range list {
if v == n {
return list
}
}
return append(append([]int{}, list...), n)
}
func (srv *server) dataDir() string { return filepath.Dir(srv.st.path) }
// fwPrune — убрать из желаемого состояния команды, которые помощник уже выполнил.
func fwPrune(s *State, st fwStatus) {
done := map[string]bool{}
for _, id := range st.Done {
done[id] = true
}
var keep []fwAction
for _, a := range s.Firewall.Actions {
if !done[a.ID] {
keep = append(keep, a)
}
}
s.Firewall.Actions = keep
}
// fwWait — подождать, пока помощник применит изменения (до 5 секунд).
func (srv *server) fwWait(since time.Time) {
for i := 0; i < 20; i++ {
if fwReadStatus(srv.dataDir()).At.After(since) {
return
}
time.Sleep(250 * time.Millisecond)
}
}
func (srv *server) securityPage(w http.ResponseWriter, r *http.Request, sess *session) {
st := fwReadStatus(srv.dataDir())
d := securityData{Status: st, StatusFresh: !st.At.IsZero() && time.Since(st.At) < fwStatusStale, MyIP: clientIP(r), Jails: fwJails}
srv.st.read(func(s *State) {
d.Wanted = s.Firewall
d.WantedActive = s.Firewall.Mode == "lockdown" && (s.Firewall.Until.IsZero() || time.Now().Before(s.Firewall.Until))
for _, a := range s.AllowIPs {
d.Allow = append(d.Allow, *a)
}
d.TrustedCount = len(s.trustedIPs())
done := map[string]bool{}
for _, id := range st.Done {
done[id] = true
}
for _, a := range s.Firewall.Actions {
if !done[a.ID] {
d.Pending = append(d.Pending, a)
}
}
d.MyIPCovered = s.allowCovers(d.MyIP, false)
d.AdminAllowOnly, d.Admins = s.AdminAllowOnly, len(s.Users)
d.ZKEnabled, d.ZKHasKey, d.ZKURL = s.ZetitKey.Enabled, s.ZetitKey.AppKey != "", pickStr(s.ZetitKey.URL, zetitKeyDefaultURL)
for _, u := range s.Users {
if u.Email != "" {
d.ZKWithEmail++
}
}
for _, u := range s.Users {
if u.TOTPSecret != "" {
d.TwoFA++
}
}
n, lock := loginLimitOf(s)
d.LoginTries, d.LoginLockSec = n, int64(lock/time.Second)
for _, j := range fwJails {
c, configured := s.Firewall.Jails[j]
if !configured {
c = fwDefaultJail(j)
}
row := fwLimitRow{Jail: j, Title: fwJailTitle(j)}
for _, js := range st.Jails {
if js.Name == j && js.MaxRetry > 0 {
row.HasEffective = true
row.Effective = fmt.Sprintf("сейчас: бан %s, %d попыток за %s", durRu(js.BanTime), js.MaxRetry, durRu(js.FindTime))
if !configured { // ещё не настраивали в кабинете — показываем то, что действует
c = fwJailConf{BanTime: js.BanTime, FindTime: js.FindTime, MaxRetry: js.MaxRetry}
}
}
}
row.Conf = c
row.BanOpts, row.FindOpts, row.RetryOpts = withPreset(fwBanPresets, c.BanTime), withPreset(fwFindPresets, c.FindTime), withRetry(fwRetryPresets, c.MaxRetry)
d.Limits = append(d.Limits, row)
}
})
d.BanPresets, d.FindPresets, d.RetryPresets = fwBanPresets, fwFindPresets, fwRetryPresets
d.Host, d.AdminBase, d.AdminOpen = publicHost, srv.adminBase(), srv.adminOpen()
d.AdminDefault = d.AdminBase == adminDefault
srv.render(w, r, sess, "security", "Защита", "security", d)
}
func fwJailTitle(j string) string {
switch j {
case "bj-license-api":
return "Активация и продление (API)"
case "bj-license-admin":
return "Вход в кабинет (fail2ban)"
case "sshd":
return "SSH"
}
return j
}
// securityLimits — POST /admin/security/limits: сроки и пороги блокировок.
func (srv *server) securityLimits(w http.ResponseWriter, r *http.Request, sess *session) {
conf := map[string]fwJailConf{}
var changes []string
for _, j := range fwJails {
ban, e1 := strconv.ParseInt(r.PostFormValue("ban_"+j), 10, 64)
find, e2 := strconv.ParseInt(r.PostFormValue("find_"+j), 10, 64)
retry, e3 := strconv.Atoi(r.PostFormValue("retry_" + j))
if e1 != nil || e2 != nil || e3 != nil || (ban != -1 && (ban < 60 || ban > 5*31536000)) || find < 60 || find > 31536000 || retry < 1 || retry > 100 {
back(w, r, "/admin/security", "", "неверные параметры для "+fwJailTitle(j))
return
}
conf[j] = fwJailConf{BanTime: ban, FindTime: find, MaxRetry: retry}
changes = append(changes, fmt.Sprintf("%s: бан %s, %d попыток за %s", fwJailTitle(j), durRu(ban), retry, durRu(find)))
}
tries, e1 := strconv.Atoi(r.PostFormValue("login_tries"))
lock, e2 := strconv.ParseInt(r.PostFormValue("login_lock"), 10, 64)
if e1 != nil || e2 != nil || tries < 3 || tries > 100 || lock <= 0 || !fwPresetOK(fwBanPresets, lock) {
back(w, r, "/admin/security", "", "неверные параметры блокировки входа в кабинет")
return
}
changes = append(changes, fmt.Sprintf("вход в кабинет (приложение): %d попыток, блокировка %s", tries, durRu(lock)))
start := time.Now()
err := srv.st.write(func(s *State) error {
s.Firewall.Jails, s.Firewall.LoginAttempts, s.Firewall.LoginLockSec = conf, tries, lock
s.Firewall.UpdatedBy, s.Firewall.UpdatedAt = sess.Login, start
s.addEvent("fw_limits", "", "", sess.Login, clientIP(r), "параметры блокировок: "+strings.Join(changes, "; "))
return nil
})
if err != nil {
back(w, r, "/admin/security", "", err.Error())
return
}
srv.fails.set(tries, time.Duration(lock)*time.Second)
srv.fwWait(start)
back(w, r, "/admin/security", "Параметры блокировок сохранены. fail2ban перечитывает настройки, действующие значения обновятся в течение 30 секунд.", "")
}
// securityMode — POST /admin/security/mode: включить (op=on, hours) или выключить (op=off) оборону.
func (srv *server) securityMode(w http.ResponseWriter, r *http.Request, sess *session) {
op, myIP := r.PostFormValue("op"), clientIP(r)
start := time.Now()
status := fwReadStatus(srv.dataDir())
var bad string
err := srv.st.write(func(s *State) error {
fwPrune(s, status)
switch op {
case "on":
hours, _ := strconv.Atoi(r.PostFormValue("hours"))
if hours < 0 || hours > 24*30 {
bad = "неверный срок"
return nil
}
if n, ok := normalizeNet(myIP); ok && !s.allowCovers(myIP, false) {
s.AllowIPs = append(s.AllowIPs, &AllowIP{Net: n, Comment: "добавлен автоматически при включении обороны", AddedBy: sess.Login, AddedAt: start})
s.addEvent("fw_allow_add", "", "", sess.Login, myIP, "IP "+n+" добавлен в белый список (включение обороны)")
}
s.Firewall.Mode, s.Firewall.Until = "lockdown", time.Time{}
text := "включён режим обороны: на 80/443 только белый список"
if hours > 0 {
s.Firewall.Until = start.Add(time.Duration(hours) * time.Hour)
text += ", до " + s.Firewall.Until.In(msk).Format("02.01.2006 15:04")
}
s.addEvent("fw_lockdown_on", "", "", sess.Login, myIP, text)
_ = os.Remove(filepath.Join(srv.dataDir(), fwOffFile))
case "off":
s.Firewall.Mode, s.Firewall.Until = "", time.Time{}
s.addEvent("fw_lockdown_off", "", "", sess.Login, myIP, "режим обороны выключен")
default:
bad = "неизвестная команда"
return nil
}
s.Firewall.UpdatedBy, s.Firewall.UpdatedAt = sess.Login, start
return nil
})
if err != nil || bad != "" {
back(w, r, "/admin/security", "", pickStr(bad, fmt.Sprint(err)))
return
}
srv.fwWait(start)
if op == "on" {
back(w, r, "/admin/security", "Режим обороны включается: доступ к сайту и API — только из белого списка.", "")
return
}
back(w, r, "/admin/security", "Режим обороны выключен: доступ открыт, fail2ban работает как обычно.", "")
}
// securityAllow — POST /admin/security/allow: добавить (op=add) или убрать (op=remove) адрес белого списка.
func (srv *server) securityAllow(w http.ResponseWriter, r *http.Request, sess *session) {
op, myIP := r.PostFormValue("op"), clientIP(r)
start := time.Now()
var bad, okMsg string
err := srv.st.write(func(s *State) error {
switch op {
case "add":
n, ok := normalizeNet(r.PostFormValue("net"))
if !ok {
bad = "укажите IP или сеть (IPv4 не шире /16, IPv6 не шире /32)"
return nil
}
for _, a := range s.AllowIPs {
if a.Net == n {
bad = n + " уже в белом списке"
return nil
}
}
s.AllowIPs = append(s.AllowIPs, &AllowIP{Net: n, Comment: clip(r.PostFormValue("comment"), 120), AddedBy: sess.Login, AddedAt: start})
s.addEvent("fw_allow_add", "", "", sess.Login, myIP, "IP "+n+" добавлен в белый список")
okMsg = n + " добавлен в белый список"
case "remove":
n := strings.TrimSpace(r.PostFormValue("net"))
var keep []*AllowIP
for _, a := range s.AllowIPs {
if a.Net != n {
keep = append(keep, a)
}
}
if len(keep) == len(s.AllowIPs) {
bad = "адрес не найден в белом списке"
return nil
}
lockdown := s.Firewall.Mode == "lockdown" && (s.Firewall.Until.IsZero() || start.Before(s.Firewall.Until))
if (lockdown || s.AdminAllowOnly) && netContains(n, myIP) {
rest := &State{AllowIPs: keep}
if !rest.allowCovers(myIP, false) {
bad = n + " покрывает ваш адрес " + myIP + ", а кабинет сейчас ограничен белым списком: после удаления доступ закроется. Сначала снимите ограничение или выключите оборону."
return nil
}
}
s.AllowIPs = keep
s.addEvent("fw_allow_remove", "", "", sess.Login, myIP, "IP "+n+" убран из белого списка")
okMsg = n + " убран из белого списка"
default:
bad = "неизвестная команда"
}
return nil
})
if err != nil || bad != "" {
back(w, r, "/admin/security", "", pickStr(bad, fmt.Sprint(err)))
return
}
srv.fwWait(start)
back(w, r, "/admin/security", okMsg, "")
}
// securityBan — POST /admin/security/ban: заблокировать (op=ban) или разблокировать (op=unban) IP в jail.
func (srv *server) securityBan(w http.ResponseWriter, r *http.Request, sess *session) {
op, jail, myIP := r.PostFormValue("op"), r.PostFormValue("jail"), clientIP(r)
ip := net.ParseIP(strings.TrimSpace(r.PostFormValue("ip")))
known := false
for _, j := range fwJails {
known = known || j == jail
}
switch {
case ip == nil:
back(w, r, "/admin/security", "", "укажите IP-адрес")
return
case !known || (op != "ban" && op != "unban"):
back(w, r, "/admin/security", "", "неверная команда")
return
case op == "ban" && ip.String() == net.ParseIP(myIP).String():
back(w, r, "/admin/security", "", "это ваш текущий адрес — блокировать его нельзя")
return
}
start := time.Now()
status := fwReadStatus(srv.dataDir())
err := srv.st.write(func(s *State) error {
fwPrune(s, status)
s.Firewall.Actions = append(s.Firewall.Actions, fwAction{ID: randHex(8), Op: op, Jail: jail, IP: ip.String(), At: start, By: sess.Login})
verb := "заблокирован"
if op == "unban" {
verb = "разблокирован"
}
s.addEvent("fw_"+op, "", "", sess.Login, myIP, "IP "+ip.String()+" "+verb+" вручную в "+jail)
return nil
})
if err != nil {
back(w, r, "/admin/security", "", err.Error())
return
}
srv.fwWait(start)
if op == "ban" {
back(w, r, "/admin/security", "IP "+ip.String()+" заблокирован в "+jail+" (на срок bantime jail).", "")
return
}
back(w, r, "/admin/security", "IP "+ip.String()+" разблокирован в "+jail+".", "")
}
-144
View File
@@ -1,144 +0,0 @@
// Command bj-license-server — сервер лицензирования Bridge&Join (https://lic.zetit.ru).
//
// Выдаёт и продлевает лицензии установкам bj по коду активации (контракт —
// docs/license-server-api.md) и даёт веб-кабинет администратора: клиенты, лицензии,
// установки, журнал, администраторы. Вход в кабинет — логин и пароль.
//
// Хранилище — один JSON-файл с атомарной записью и ежедневными копиями рядом: клиентов
// десятки, записей немного, отдельная СУБД не нужна.
//
// bj-license-server serve -addr 127.0.0.1:8091 -data /var/lib/bj-license/state.json -key /etc/bj-license/license.priv
// bj-license-server user-set -data /var/lib/bj-license/state.json -login admin
// (пароль из stdin; пусто — сгенерировать; сервер должен быть остановлен)
// bj-license-server fw-apply -dir /var/lib/bj-license [-off] (root, из systemd: защита, см. firewall.go)
package main
import (
"bufio"
"crypto/ed25519"
"flag"
"fmt"
"log"
"net/http"
"os"
"strings"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/license"
)
func main() {
if len(os.Args) < 2 {
usage()
}
switch os.Args[1] {
case "serve":
serve(os.Args[2:])
case "user-set":
userSet(os.Args[2:])
case "fw-apply":
fwApply(os.Args[2:])
case "fw-ignore":
fwIgnore(os.Args[2:])
case "admin-info":
adminInfo(os.Args[2:])
case "admin-config":
adminConfig(os.Args[2:])
default:
usage()
}
}
func usage() {
fmt.Fprintln(os.Stderr, "bj-license-server serve [-addr 127.0.0.1:8091] [-data state.json] [-key license.priv] [-keyid main]")
fmt.Fprintln(os.Stderr, "bj-license-server user-set -login admin [-name Имя] [-data state.json] (пароль из stdin)")
fmt.Fprintln(os.Stderr, "bj-license-server fw-apply [-dir /var/lib/bj-license] [-off] (root: применить защиту; -off — аварийно выключить оборону)")
fmt.Fprintln(os.Stderr, "bj-license-server fw-ignore [-dir /var/lib/bj-license] <ip> api|admin (ignorecommand fail2ban)")
fmt.Fprintln(os.Stderr, "bj-license-server admin-info [-data state.json] (адрес кабинета, белый список, второй фактор)")
fmt.Fprintln(os.Stderr, "bj-license-server admin-config [-data state.json] [-allow IP=комментарий ...] [-path свой|random|admin] [-allow-only on|off] [-reset-2fa логин] (сервер должен быть остановлен)")
os.Exit(2)
}
func serve(args []string) {
fs := flag.NewFlagSet("serve", flag.ExitOnError)
addr := fs.String("addr", "127.0.0.1:8091", "адрес прослушивания (снаружи — через nginx)")
data := fs.String("data", "/var/lib/bj-license/state.json", "файл состояния")
keyPath := fs.String("key", "/etc/bj-license/license.priv", "закрытый ключ подписи лицензий (base64 seed Ed25519)")
keyID := fs.String("keyid", "main", "идентификатор ключа в токене")
_ = fs.Parse(args)
priv, err := license.LoadPrivateKey(*keyPath)
if err != nil {
log.Fatalf("ключ подписи: %v", err)
}
st, err := openStore(*data)
if err != nil {
log.Fatalf("хранилище: %v", err)
}
srv := &server{
st: st,
priv: priv,
pub: priv.Public().(ed25519.PublicKey),
keyID: *keyID,
sessions: newSessions(),
fails: newLimiter(8, 15*time.Minute),
apiRate: newLimiter(60, time.Minute),
}
st.read(func(s *State) { srv.fails.set(loginLimitOf(s)) })
if err := srv.loadTemplates(); err != nil {
log.Fatalf("шаблоны: %v", err)
}
mux := http.NewServeMux()
srv.routes(mux)
go st.backupLoop()
st.read(func(s *State) {
log.Printf("bj-license-server: слушаю %s; клиентов %d, лицензий %d, установок %d, администраторов %d",
*addr, len(s.Tenants), len(s.Licenses), len(s.Installs), len(s.Users))
if len(s.Users) == 0 {
log.Printf("bj-license-server: администраторов нет — создайте: bj-license-server user-set -login admin")
}
})
hs := &http.Server{
Addr: *addr,
Handler: securityHeaders(srv.adminGate(mux)),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
}
log.Fatal(hs.ListenAndServe())
}
func userSet(args []string) {
fs := flag.NewFlagSet("user-set", flag.ExitOnError)
data := fs.String("data", "/var/lib/bj-license/state.json", "файл состояния")
login := fs.String("login", "", "логин администратора")
name := fs.String("name", "", "имя (для журнала)")
_ = fs.Parse(args)
if !loginRe.MatchString(*login) {
log.Fatal("логин: латиница, цифры, точка, дефис, подчёркивание")
}
st, err := openStore(*data)
if err != nil {
log.Fatal(err)
}
pw, _ := bufio.NewReader(os.Stdin).ReadString('\n')
pw = strings.TrimRight(pw, "\r\n")
generated := false
if pw == "" {
pw, generated = randomPassword(16), true
}
if len(pw) < 10 {
log.Fatal("пароль короче 10 символов")
}
if err := st.write(func(s *State) error {
return s.setUserPassword(*login, *name, pw, "cli")
}); err != nil {
log.Fatal(err)
}
if generated {
fmt.Printf("Администратор %s: пароль %s\n", *login, pw)
} else {
fmt.Printf("Администратор %s: пароль задан\n", *login)
}
}
-460
View File
@@ -1,460 +0,0 @@
package main
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log"
"math/big"
"net"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
)
// Признаки лицензии, которые понимает bj (internal/lkgateway/licensecheck.go).
var knownFeatures = []struct{ Key, Title string }{
{"updates", "Обновления"},
{"web-cabinet", "Личный кабинет клиента (bj-lk)"},
{"quik-mnp", "QUIK: неторговые поручения (M2M)"},
{"quik-export", "QUIK: экспорт биржевой информации"},
{"npr", "Риск-монитор НПР / УДС"},
}
var knownPlans = []struct{ Key, Title string }{
{"enterprise", "Enterprise"},
{"pro", "Pro"},
{"free", "Free"},
}
const maxEvents = 20000
var (
loginRe = regexp.MustCompile(`^[A-Za-z0-9_.\-]{2,40}$`)
uuidRe = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
innRe = regexp.MustCompile(`^(\d{10}|\d{12})$`)
)
type User struct {
Login string `json:"login"`
Name string `json:"name,omitempty"`
PassHash string `json:"pass_hash"`
Disabled bool `json:"disabled,omitempty"`
CreatedAt time.Time `json:"created_at"`
LastLogin time.Time `json:"last_login,omitempty"`
// Второй фактор (TOTP): секрет base32 и последний принятый шаг — один код не принимается дважды.
TOTPSecret string `json:"totp_secret,omitempty"`
TOTPLast int64 `json:"totp_last,omitempty"`
HasTOTP bool `json:"-"` // для шаблонов: секрет в представления не отдаётся
Email string `json:"email,omitempty"` // учётная запись в «ЗЕТИТ Ключ» (zetitkey.go)
}
type Tenant struct {
ID string `json:"id"`
Name string `json:"name"`
INN string `json:"inn,omitempty"`
Contact string `json:"contact,omitempty"`
Note string `json:"note,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type License struct {
ID string `json:"id"`
TenantID string `json:"tenant_id"`
Plan string `json:"plan"`
Features []string `json:"features,omitempty"`
MaxNodes int `json:"max_nodes"`
ContractUntil time.Time `json:"contract_until"`
Code string `json:"code"`
Note string `json:"note,omitempty"`
RevokedAt time.Time `json:"revoked_at,omitempty"`
RevokeReason string `json:"revoke_reason,omitempty"`
CreatedAt time.Time `json:"created_at"`
CreatedBy string `json:"created_by,omitempty"`
}
func (l License) Revoked() bool { return !l.RevokedAt.IsZero() }
func (l License) ContractExpired(now time.Time) bool {
return !l.ContractUntil.IsZero() && now.After(l.ContractUntil)
}
func (l License) HasFeature(f string) bool {
for _, x := range l.Features {
if x == f {
return true
}
}
return false
}
type Install struct {
InstallID string `json:"install_id"`
LicenseID string `json:"license_id"`
Machine string `json:"machine,omitempty"`
Hostname string `json:"hostname,omitempty"`
Version string `json:"version,omitempty"`
IP string `json:"ip,omitempty"`
FirstSeen time.Time `json:"first_seen"`
LastSeen time.Time `json:"last_seen"`
TokenExpires time.Time `json:"token_expires"`
Blocked bool `json:"blocked,omitempty"`
BlockReason string `json:"block_reason,omitempty"`
}
type Event struct {
At time.Time `json:"at"`
Kind string `json:"kind"`
LicenseID string `json:"license_id,omitempty"`
InstallID string `json:"install_id,omitempty"`
Actor string `json:"actor,omitempty"`
IP string `json:"ip,omitempty"`
Text string `json:"text"`
}
// TrustedIP — адрес установки, которая успешно активировала или продлила лицензию. fail2ban
// не блокирует такие адреса за ошибки API (jail bj-license-api, ignorecommand по trusted-ips.txt);
// на вход в кабинет доверие не распространяется.
type TrustedIP struct {
IP string `json:"ip"`
LicenseID string `json:"license_id"`
InstallID string `json:"install_id"`
Hostname string `json:"hostname,omitempty"`
AddedAt time.Time `json:"added_at"`
LastSeen time.Time `json:"last_seen"`
}
type State struct {
Users []*User `json:"users"`
Tenants []*Tenant `json:"tenants"`
Licenses []*License `json:"licenses"`
Installs []*Install `json:"installs"`
Events []*Event `json:"events"`
Trusted []*TrustedIP `json:"trusted,omitempty"`
// Защита (раздел «Защита»): белый список администраторов и желаемый режим.
AllowIPs []*AllowIP `json:"allow_ips,omitempty"`
Firewall FirewallState `json:"firewall"`
// Доступ к кабинету (access.go): свой адрес вместо /admin и вход только из белого списка.
AdminPath string `json:"admin_path,omitempty"`
AdminAllowOnly bool `json:"admin_allow_only,omitempty"`
// Второй фактор через «ЗЕТИТ Ключ» (zetitkey.go).
ZetitKey ZetitKeyConf `json:"zetit_key,omitempty"`
}
// trustedFile — список доверенных IP установок для fail2ban и режима обороны.
const trustedFile = "trusted-ips.txt"
type store struct {
mu sync.RWMutex
path string
s State
}
func openStore(path string) (*store, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, err
}
st := &store{path: path}
b, err := os.ReadFile(path)
switch {
case errors.Is(err, os.ErrNotExist):
return st, nil
case err != nil:
return nil, err
}
if err := json.Unmarshal(b, &st.s); err != nil {
return nil, fmt.Errorf("разбор %s: %w", path, err)
}
if err := st.writeTrustedLocked(); err != nil {
log.Printf("доверенные IP: %v", err)
}
return st, nil
}
// trustedPath — список доверенных IP для fail2ban, рядом с файлом состояния.
func (st *store) trustedPath() string {
return filepath.Join(filepath.Dir(st.path), trustedFile)
}
// writeTrustedLocked — переписывает trusted-ips.txt (по адресу в строке) и файлы защиты; вызывать под блокировкой.
func (st *store) writeTrustedLocked() error {
body := "# Доверенные IP установок bj (успешная активация или продление лицензии). Файл пишет bj-license-server.\n"
for _, ip := range st.s.trustedIPs() {
body += ip + "\n"
}
if err := writeIfChanged(st.trustedPath(), []byte(body), 0o600); err != nil {
return err
}
return st.writeFirewallLocked()
}
func (st *store) saveLocked() error {
b, err := json.MarshalIndent(&st.s, "", " ")
if err != nil {
return err
}
tmp := st.path + ".tmp"
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return err
}
if _, err := f.Write(b); err != nil {
f.Close()
return err
}
if err := f.Sync(); err != nil {
f.Close()
return err
}
if err := f.Close(); err != nil {
return err
}
if err := os.Rename(tmp, st.path); err != nil {
return err
}
if err := st.writeTrustedLocked(); err != nil {
log.Printf("доверенные IP: %v", err)
}
return nil
}
// write — изменение состояния под блокировкой; сохраняется, если fn не вернула ошибку.
func (st *store) write(fn func(s *State) error) error {
st.mu.Lock()
defer st.mu.Unlock()
if err := fn(&st.s); err != nil {
return err
}
return st.saveLocked()
}
func (st *store) read(fn func(s *State)) {
st.mu.RLock()
defer st.mu.RUnlock()
fn(&st.s)
}
// backupLoop — раз в сутки копия state.json рядом (state.json.YYYYMMDD), хранится 30 копий.
func (st *store) backupLoop() {
for {
st.mu.RLock()
b, err := json.Marshal(&st.s)
st.mu.RUnlock()
if err == nil {
name := st.path + "." + time.Now().Format("20060102")
if _, e := os.Stat(name); errors.Is(e, os.ErrNotExist) {
if e := os.WriteFile(name, b, 0o600); e != nil {
log.Printf("резервная копия: %v", e)
}
}
old, _ := filepath.Glob(st.path + ".2*")
sort.Strings(old)
for len(old) > 30 {
_ = os.Remove(old[0])
old = old[1:]
}
}
time.Sleep(6 * time.Hour)
}
}
// ---- выборки и изменения (вызывать внутри read/write) ----
// trustIP — адрес установки в доверенные после успешной активации или продления.
// Возвращает true, если для этой установки адрес новый.
func (s *State) trustIP(inst *Install, ip string, now time.Time) bool {
p := net.ParseIP(strings.TrimSpace(ip))
if p == nil || p.IsLoopback() || p.IsUnspecified() {
return false
}
ip = p.String()
for _, t := range s.Trusted {
if t.IP == ip && t.InstallID == inst.InstallID {
t.LicenseID, t.Hostname, t.LastSeen = inst.LicenseID, inst.Hostname, now
return false
}
}
s.Trusted = append(s.Trusted, &TrustedIP{IP: ip, LicenseID: inst.LicenseID, InstallID: inst.InstallID,
Hostname: inst.Hostname, AddedAt: now, LastSeen: now})
return true
}
// untrust — убирает доверенные адреса, подходящие под условие; возвращает убранные.
func (s *State) untrust(match func(t *TrustedIP) bool) []*TrustedIP {
var keep, gone []*TrustedIP
for _, t := range s.Trusted {
if match(t) {
gone = append(gone, t)
} else {
keep = append(keep, t)
}
}
s.Trusted = keep
return gone
}
// trustedIPs — адреса для fail2ban: без отозванных лицензий и заблокированных установок.
func (s *State) trustedIPs() []string {
seen := map[string]bool{}
var out []string
for _, t := range s.Trusted {
if l := s.license(t.LicenseID); l == nil || l.Revoked() {
continue
}
if i := s.install(t.InstallID); i == nil || i.Blocked {
continue
}
if !seen[t.IP] {
seen[t.IP] = true
out = append(out, t.IP)
}
}
sort.Strings(out)
return out
}
func (s *State) user(login string) *User {
for _, u := range s.Users {
if strings.EqualFold(u.Login, login) {
return u
}
}
return nil
}
func (s *State) tenant(id string) *Tenant {
for _, t := range s.Tenants {
if t.ID == id {
return t
}
}
return nil
}
func (s *State) license(id string) *License {
for _, l := range s.Licenses {
if l.ID == id {
return l
}
}
return nil
}
func (s *State) licenseByCode(code string) *License {
for _, l := range s.Licenses {
if l.Code == code {
return l
}
}
return nil
}
func (s *State) install(id string) *Install {
for _, i := range s.Installs {
if strings.EqualFold(i.InstallID, id) {
return i
}
}
return nil
}
func (s *State) installsOf(licenseID string) []*Install {
var out []*Install
for _, i := range s.Installs {
if i.LicenseID == licenseID {
out = append(out, i)
}
}
sort.Slice(out, func(a, b int) bool { return out[a].LastSeen.After(out[b].LastSeen) })
return out
}
func (s *State) activeInstalls(licenseID string) int {
n := 0
for _, i := range s.Installs {
if i.LicenseID == licenseID && !i.Blocked {
n++
}
}
return n
}
func (s *State) addEvent(kind, licenseID, installID, actor, ip, text string) {
s.Events = append(s.Events, &Event{At: time.Now(), Kind: kind, LicenseID: licenseID, InstallID: installID, Actor: actor, IP: ip, Text: text})
if len(s.Events) > maxEvents {
s.Events = append([]*Event(nil), s.Events[len(s.Events)-maxEvents:]...)
}
}
func (s *State) setUserPassword(login, name, password, actor string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
if err != nil {
return err
}
u := s.user(login)
if u == nil {
u = &User{Login: login, Name: name, CreatedAt: time.Now()}
s.Users = append(s.Users, u)
s.addEvent("user_add", "", "", actor, "", "добавлен администратор "+login)
} else {
s.addEvent("user_password", "", "", actor, "", "сменён пароль администратора "+login)
}
if name != "" {
u.Name = name
}
u.PassHash = string(hash)
return nil
}
// ---- идентификаторы ----
func newID() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
b[6] = b[6]&0x0f | 0x40
b[8] = b[8]&0x3f | 0x80
h := hex.EncodeToString(b)
return h[0:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:]
}
const codeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" // без 0/O и 1/I
func randomFrom(alphabet string, n int) string {
var sb strings.Builder
max := big.NewInt(int64(len(alphabet)))
for i := 0; i < n; i++ {
k, _ := rand.Int(rand.Reader, max)
sb.WriteByte(alphabet[k.Int64()])
}
return sb.String()
}
// newActivationCode — BJ-XXXX-XXXX-XXXX (60 бит случайности).
func newActivationCode() string {
c := randomFrom(codeAlphabet, 12)
return "BJ-" + c[0:4] + "-" + c[4:8] + "-" + c[8:12]
}
// normalizeCode — код в каноническом виде: верхний регистр, дефисы по местам.
func normalizeCode(s string) string {
s = strings.ToUpper(strings.NewReplacer("-", "", " ", "").Replace(strings.TrimSpace(s)))
if len(s) == 14 && strings.HasPrefix(s, "BJ") {
s = s[2:]
}
if len(s) != 12 {
return ""
}
return "BJ-" + s[0:4] + "-" + s[4:8] + "-" + s[8:12]
}
func randomPassword(n int) string {
return randomFrom("abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789", n)
}
-484
View File
@@ -1,484 +0,0 @@
{{define "style"}}<style>
:root{--bg:#f4f5f9;--card:#fff;--card2:#f8f9fc;--text:#1a1d26;--muted:#6b7280;--accent:#3b4fd8;--accent2:#e8562a;--border:#e3e5ee;--ok:#15803d;--warn:#b45309;--bad:#b91c1c;--okbg:#e8f6ee;--warnbg:#fdf3e2;--badbg:#fdecec}
@media (prefers-color-scheme:dark){:root{--bg:#131620;--card:#1c202d;--card2:#232838;--text:#e6e8f0;--muted:#9aa0b3;--accent:#8190ff;--border:#2d3244;--ok:#4ade80;--warn:#fbbf24;--bad:#f87171;--okbg:#16301f;--warnbg:#33280f;--badbg:#3a1a1a}}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--text);font:14px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}
header{background:var(--card);border-bottom:1px solid var(--border);padding:0 20px;display:flex;align-items:center;gap:24px;flex-wrap:wrap;min-height:56px}
.brand{display:flex;align-items:center;gap:9px;font-weight:700;font-size:16px;white-space:nowrap;color:var(--text)}
.brand em{font-style:normal;color:var(--accent)}.brand small{font-weight:500;color:var(--muted);font-size:13px}
nav{display:flex;gap:4px;flex-wrap:wrap;flex:1}
nav a{padding:7px 11px;border-radius:8px;color:var(--muted);font-weight:500}
nav a.on{background:var(--card2);color:var(--text)}nav a:hover{text-decoration:none;color:var(--text)}
.me{display:flex;align-items:center;gap:10px;color:var(--muted);font-size:13px}
main{max-width:1180px;margin:0 auto;padding:22px 20px 60px}
h1{font-size:22px;margin:0 0 16px}h2{font-size:16px;margin:0 0 12px}
.card{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin-bottom:16px}
.grid{display:grid;gap:16px;grid-template-columns:repeat(auto-fit,minmax(240px,1fr))}
.stats{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));margin-bottom:16px}
.stat{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:14px 16px}
.stat b{display:block;font-size:26px;line-height:1.2}.stat span{color:var(--muted);font-size:13px}
.table-wrap{overflow-x:auto}
table{width:100%;border-collapse:collapse}
th{text-align:left;font-size:12px;font-weight:600;color:var(--muted);padding:8px 10px;border-bottom:1px solid var(--border);white-space:nowrap}
td{padding:9px 10px;border-bottom:1px solid var(--border);vertical-align:top}
tr:last-child td{border-bottom:0}
.muted{color:var(--muted)}.small{font-size:12px}
.badge{display:inline-block;padding:2px 8px;border-radius:999px;font-size:12px;font-weight:600;white-space:nowrap}
.badge.ok{background:var(--okbg);color:var(--ok)}.badge.warn{background:var(--warnbg);color:var(--warn)}.badge.bad{background:var(--badbg);color:var(--bad)}
.chip{display:inline-block;padding:1px 7px;margin:1px 2px 1px 0;border:1px solid var(--border);border-radius:6px;font-size:12px;background:var(--card2)}
.flash{padding:10px 14px;border-radius:10px;margin-bottom:16px;font-weight:500}
.flash.ok{background:var(--okbg);color:var(--ok)}.flash.err{background:var(--badbg);color:var(--bad)}
form.f{display:grid;gap:12px}
label{display:block;font-size:12px;color:var(--muted);margin-bottom:4px}
input[type=text],input[type=password],input[type=number],input[type=date],select,textarea{width:100%;padding:8px 10px;border:1px solid var(--border);border-radius:8px;background:var(--card);color:var(--text);font:inherit}
textarea{min-height:64px;resize:vertical}
input:focus,select:focus,textarea:focus{outline:2px solid var(--accent);outline-offset:1px;border-color:transparent}
.row2{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(200px,1fr))}
.checks{display:flex;flex-wrap:wrap;gap:6px 16px}.checks label{display:flex;gap:6px;align-items:center;font-size:14px;color:var(--text);margin:0}
.btn{display:inline-block;padding:8px 14px;border:1px solid var(--border);border-radius:8px;background:var(--card);color:var(--text);font:inherit;font-weight:600;cursor:pointer}
.btn:hover{background:var(--card2);text-decoration:none}
.btn.primary{background:var(--accent);border-color:var(--accent);color:#fff}.btn.primary:hover{filter:brightness(1.07)}
.btn.danger{color:var(--bad)}.btn.sm{padding:4px 9px;font-size:12px}
.actions{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
.code{font:600 20px/1.3 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.06em;padding:10px 14px;background:var(--card2);border:1px dashed var(--border);border-radius:10px;display:inline-block;user-select:all}
.token{font:12px/1.4 ui-monospace,monospace;word-break:break-all;background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:10px;user-select:all}
.kv{display:grid;grid-template-columns:minmax(140px,max-content) 1fr;gap:6px 16px}.kv dt{color:var(--muted)}.kv dd{margin:0}
details summary{cursor:pointer;font-weight:600}
.mono{font-family:ui-monospace,monospace;font-size:12px}
.none::before{content:"\2014";color:var(--muted)} /* пустое значение */
@media (max-width:640px){header{padding:8px 16px;gap:8px}main{padding:16px}.kv{grid-template-columns:1fr}}
</style>{{end}}
{{define "logo"}}<svg width="24" height="24" viewBox="0 0 26 26" fill="none" aria-hidden="true"><path d="M5.5 16 C 9 8.5, 17 8.5, 20.5 16" stroke="#3b4fd8" stroke-width="1.9" stroke-linecap="round"/><circle cx="5.5" cy="16" r="3.1" fill="#3b4fd8"/><circle cx="20.5" cy="16" r="3.1" fill="#e8562a"/></svg>{{end}}
{{define "head"}}<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{.Title}} · лицензии Bridge&amp;Join</title>{{template "style"}}</head><body>
<header>
<a class="brand" href="{{adm}}/">{{template "logo"}}<span>Bridge<em>&amp;Join</em></span><small>лицензии</small></a>
<nav>
<a href="{{adm}}/" {{if eq .Nav "dash"}}class="on"{{end}}>Обзор</a>
<a href="{{adm}}/tenants" {{if eq .Nav "tenants"}}class="on"{{end}}>Клиенты</a>
<a href="{{adm}}/licenses" {{if eq .Nav "licenses"}}class="on"{{end}}>Лицензии</a>
<a href="{{adm}}/installs" {{if eq .Nav "installs"}}class="on"{{end}}>Установки</a>
<a href="{{adm}}/security" {{if eq .Nav "security"}}class="on"{{end}}>Защита</a>
<a href="{{adm}}/events" {{if eq .Nav "events"}}class="on"{{end}}>Журнал</a>
<a href="{{adm}}/users" {{if eq .Nav "users"}}class="on"{{end}}>Администраторы</a>
</nav>
<div class="me">{{if .Me}}<span>{{if .Me.Name}}{{.Me.Name}}{{else}}{{.Me.Login}}{{end}}</span>
<form method="post" action="{{adm}}/logout" style="margin:0"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="btn sm" type="submit">Выйти</button></form>{{end}}</div>
</header>
<main>
{{if .OK}}<div class="flash ok">{{.OK}}</div>{{end}}
{{if .Err}}<div class="flash err">{{.Err}}</div>{{end}}
{{end}}
{{define "foot"}}</main></body></html>{{end}}
{{define "login"}}<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Вход · лицензии Bridge&amp;Join</title>{{template "style"}}</head>
<body style="min-height:100vh;display:flex;align-items:center;justify-content:center;padding:16px">
<div class="card" style="width:100%;max-width:380px;padding:28px 30px">
<div class="brand" style="font-size:20px;margin-bottom:4px">{{template "logo"}}<span>Bridge<em>&amp;Join</em></span></div>
<p class="muted" style="margin:0 0 18px;font-size:13px">Сервер лицензирования: вход администратора</p>
<form class="f" method="post" action="{{adm}}/login">
<input type="hidden" name="next" value="{{.Data.Next}}">
<div><label for="login">Логин</label><input id="login" type="text" name="login" value="{{.Data.Login}}" autocomplete="username" autofocus required></div>
<div><label for="password">Пароль</label><input id="password" type="password" name="password" autocomplete="current-password" required></div>
<div><label for="code">Код из приложения <span class="muted" style="font-weight:400">(если подключён второй фактор)</span></label><input id="code" type="text" name="code" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" maxlength="6"></div>
<button class="btn primary" type="submit" style="width:100%;padding:10px">Войти</button>
{{if .Err}}<div class="flash err" style="margin:0">{{.Err}}</div>{{end}}
</form>
</div></body></html>{{end}}
{{define "login2fa"}}<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Подтверждение входа · лицензии Bridge&amp;Join</title>{{template "style"}}</head>
<body style="min-height:100vh;display:flex;align-items:center;justify-content:center;padding:16px">
<div class="card" style="width:100%;max-width:400px;padding:28px 30px">
<div class="brand" style="font-size:20px;margin-bottom:4px">{{template "logo"}}<span>Bridge<em>&amp;Join</em></span></div>
<h2 style="margin:10px 0 6px">Подтвердите вход</h2>
<p class="muted" style="margin:0 0 6px;font-size:13px" id="zk-msg">Запрос отправлен в приложение «ЗЕТИТ Ключ» ({{.Data.Email}}). Подтвердите вход на телефоне, и страница продолжит сама.</p>
<p style="font-size:30px;font-weight:700;margin:4px 0 14px" id="zk-count">{{.Data.Left}}</p>
<form id="zk-finish" method="post" action="{{adm}}/login/2fa/finish"><input type="hidden" name="t" value="{{.Data.Token}}"></form>
<form class="f" method="post" action="{{adm}}/login/2fa/code">
<input type="hidden" name="t" value="{{.Data.Token}}">
<div><label for="code">Или введите код из приложения</label><input id="code" type="text" name="code" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" maxlength="6" required></div>
<button class="btn primary" type="submit" style="width:100%;padding:10px">Войти с кодом</button>
{{if .Err}}<div class="flash err" style="margin:0">{{.Err}}</div>{{end}}
</form>
<p class="small" style="margin:14px 0 0"><a href="{{adm}}/login">Отмена</a></p>
</div>
<script>
(function(){
var token = {{.Data.Token}}, left = {{.Data.Left}}, base = {{adm}}, done = false;
var cnt = document.getElementById('zk-count'), msg = document.getElementById('zk-msg');
function expired(){ msg.textContent = 'Время подтверждения истекло. Введите код из приложения или войдите заново.'; }
var tick = setInterval(function(){
left = Math.max(0, left - 1); cnt.textContent = left;
if (left === 0) { clearInterval(tick); if (!done) expired(); }
}, 1000);
function poll(){
if (done || left <= 0) return;
fetch(base + '/login/2fa/status?t=' + encodeURIComponent(token), {credentials: 'same-origin', cache: 'no-store'})
.then(function(r){ return r.json(); })
.then(function(j){
if (j.status === 'approved' || j.status === 'denied') {
done = true;
msg.textContent = j.status === 'approved' ? 'Вход подтверждён.' : 'Вход отклонён в приложении.';
document.getElementById('zk-finish').submit();
return;
}
if (j.status === 'expired') { done = true; expired(); return; }
setTimeout(poll, 2500);
})
.catch(function(){ setTimeout(poll, 4000); });
}
if (left > 0) setTimeout(poll, 2000); else expired();
})();
</script></body></html>{{end}}
{{define "evtable"}}<div class="table-wrap"><table>
<thead><tr><th>Когда</th><th>Событие</th><th>Клиент</th><th>Кто</th><th>IP</th></tr></thead><tbody>
{{range .}}<tr>
<td class="small" style="white-space:nowrap">{{dt .At}}</td>
<td>{{.Text}}{{if .InstallID}} <span class="mono muted">{{short .InstallID}}</span>{{end}}</td>
<td>{{if .LicenseID}}<a href="{{adm}}/licenses/{{.LicenseID}}">{{if .TenantName}}{{.TenantName}}{{else}}{{short .LicenseID}}{{end}}</a>{{else}}<span class="none"></span>{{end}}</td>
<td class="small">{{.Actor}}</td><td class="small mono">{{.IP}}</td>
</tr>{{else}}<tr><td colspan="5" class="muted">Событий пока нет</td></tr>{{end}}
</tbody></table></div>{{end}}
{{define "dashboard"}}{{template "head" .}}
<h1>Обзор</h1>
<div class="stats">
<div class="stat"><b>{{.Data.Tenants}}</b><span>клиентов</span></div>
<div class="stat"><b>{{.Data.Active}}</b><span>действующих лицензий</span></div>
<div class="stat"><b>{{.Data.Revoked}}</b><span>отозвано</span></div>
<div class="stat"><b>{{.Data.Installs}}</b><span>установок всего</span></div>
<div class="stat"><b>{{.Data.Online}}</b><span>на связи за 48 ч</span></div>
</div>
{{if .Data.Expiring}}<div class="card"><h2>Требуют внимания</h2><div class="table-wrap"><table>
<thead><tr><th>Клиент</th><th>План</th><th>Договор до</th><th>Статус</th></tr></thead><tbody>
{{range .Data.Expiring}}<tr><td><a href="{{adm}}/licenses/{{.ID}}">{{.TenantName}}</a></td><td>{{plan .Plan}}</td><td>{{d .ContractUntil}}</td><td><span class="badge {{.StatusClass}}">{{.Status}}</span></td></tr>{{end}}
</tbody></table></div></div>{{end}}
<div class="card"><h2>Последние события</h2>{{template "evtable" .Data.Events}}<p class="small" style="margin:10px 0 0"><a href="{{adm}}/events">Весь журнал →</a></p></div>
{{template "foot" .}}{{end}}
{{define "tenants"}}{{template "head" .}}
<h1>Клиенты</h1>
<div class="card"><div class="table-wrap"><table>
<thead><tr><th>Клиент</th><th>ИНН</th><th>Контакт</th><th>Лицензий</th><th>Установок</th><th>Добавлен</th></tr></thead><tbody>
{{range .Data}}<tr><td><a href="{{adm}}/tenants/{{.ID}}"><b>{{.Name}}</b></a></td><td class="mono">{{.INN}}</td><td class="small">{{.Contact}}</td><td>{{.Licenses}}</td><td>{{.Installs}}</td><td class="small">{{d .CreatedAt}}</td></tr>
{{else}}<tr><td colspan="6" class="muted">Клиентов пока нет. Добавьте первого ниже.</td></tr>{{end}}
</tbody></table></div></div>
<div class="card"><h2>Новый клиент</h2>
<form class="f" method="post" action="{{adm}}/tenants"><input type="hidden" name="csrf" value="{{.CSRF}}">
<div class="row2">
<div><label>Название</label><input type="text" name="name" required placeholder="АО «ИК Фонтвьель»"></div>
<div><label>ИНН</label><input type="text" name="inn" inputmode="numeric" pattern="\d{10}|\d{12}" placeholder="10 или 12 цифр"></div>
</div>
<div><label>Контакт</label><input type="text" name="contact" placeholder="ответственный, почта, телефон"></div>
<div><label>Примечание</label><textarea name="note"></textarea></div>
<div><button class="btn primary" type="submit">Добавить клиента</button></div>
</form></div>
{{template "foot" .}}{{end}}
{{define "tenant"}}{{template "head" .}}
<p class="small" style="margin:0 0 6px"><a href="{{adm}}/tenants">← Клиенты</a></p>
<h1>{{.Data.Tenant.Name}}</h1>
<div class="card"><div class="actions" style="justify-content:space-between;margin-bottom:10px"><h2 style="margin:0">Лицензии</h2><a class="btn primary" href="{{adm}}/licenses?tenant={{.Data.Tenant.ID}}#new">Выпустить лицензию</a></div>
<div class="table-wrap"><table>
<thead><tr><th>Лицензия</th><th>План</th><th>Установок</th><th>Договор до</th><th>Статус</th></tr></thead><tbody>
{{range .Data.Licenses}}<tr><td><a class="mono" href="{{adm}}/licenses/{{.ID}}">{{.Code}}</a></td><td>{{plan .Plan}}</td><td>{{.Active}} из {{if .MaxNodes}}{{.MaxNodes}}{{else}}∞{{end}}</td><td>{{d .ContractUntil}}</td><td><span class="badge {{.StatusClass}}">{{.Status}}</span></td></tr>
{{else}}<tr><td colspan="5" class="muted">Лицензий нет</td></tr>{{end}}
</tbody></table></div></div>
<div class="card"><h2>Реквизиты</h2>
<form class="f" method="post" action="{{adm}}/tenants/{{.Data.Tenant.ID}}"><input type="hidden" name="csrf" value="{{.CSRF}}">
<div class="row2">
<div><label>Название</label><input type="text" name="name" value="{{.Data.Tenant.Name}}" required></div>
<div><label>ИНН</label><input type="text" name="inn" value="{{.Data.Tenant.INN}}" inputmode="numeric" pattern="\d{10}|\d{12}"></div>
</div>
<div><label>Контакт</label><input type="text" name="contact" value="{{.Data.Tenant.Contact}}"></div>
<div><label>Примечание</label><textarea name="note">{{.Data.Tenant.Note}}</textarea></div>
<div class="actions"><button class="btn primary" type="submit">Сохранить</button><span class="muted small">добавлен {{dt .Data.Tenant.CreatedAt}}</span></div>
</form></div>
{{template "foot" .}}{{end}}
{{define "terms"}}
<div class="row2">
<div><label>План</label><select name="plan">{{$cur := .Plan}}{{range $.Plans}}<option value="{{.Key}}" {{if eq .Key $cur}}selected{{end}}>{{.Title}}</option>{{end}}</select></div>
<div><label>Установок (0 = без лимита)</label><input type="number" name="max_nodes" min="0" max="1000" value="{{.MaxNodes}}"></div>
<div><label>Договор действует до</label><input type="date" name="contract_until" value="{{dateInput .ContractUntil}}" required></div>
</div>
<div><label>Модули</label><div class="checks">{{$f := .Features}}{{range $.All}}<label><input type="checkbox" name="features" value="{{.Key}}" {{if has $f .Key}}checked{{end}}> {{.Title}}</label>{{end}}</div></div>
<div><label>Примечание (видно только здесь)</label><textarea name="note">{{.Note}}</textarea></div>
{{end}}
{{define "licenses"}}{{template "head" .}}
<h1>Лицензии</h1>
<div class="card"><div class="table-wrap"><table>
<thead><tr><th>Клиент</th><th>Код активации</th><th>План</th><th>Модули</th><th>Установок</th><th>Договор до</th><th>Статус</th></tr></thead><tbody>
{{range .Data.Rows}}<tr>
<td><a href="{{adm}}/licenses/{{.ID}}"><b>{{.TenantName}}</b></a></td><td class="mono">{{.Code}}</td><td>{{plan .Plan}}</td>
<td>{{range .Features}}<span class="chip">{{feature .}}</span>{{else}}<span class="none"></span>{{end}}</td>
<td>{{.Active}} из {{if .MaxNodes}}{{.MaxNodes}}{{else}}∞{{end}}</td><td>{{d .ContractUntil}}</td><td><span class="badge {{.StatusClass}}">{{.Status}}</span></td>
</tr>{{else}}<tr><td colspan="7" class="muted">Лицензий пока нет.</td></tr>{{end}}
</tbody></table></div></div>
<div class="card" id="new"><h2>Выпустить лицензию</h2>
{{if .Data.Tenants}}
<form class="f" method="post" action="{{adm}}/licenses"><input type="hidden" name="csrf" value="{{.CSRF}}">
<div><label>Клиент</label><select name="tenant_id" required>{{$sel := .Data.Tenant}}{{range .Data.Tenants}}<option value="{{.ID}}" {{if eq .ID $sel}}selected{{end}}>{{.Name}}{{if .INN}} · ИНН {{.INN}}{{end}}</option>{{end}}</select></div>
{{template "terms" (dict2 .Plans .Features)}}
<div><button class="btn primary" type="submit">Выпустить и получить код активации</button></div>
</form>
{{else}}<p class="muted">Сначала <a href="{{adm}}/tenants">добавьте клиента</a>.</p>{{end}}
</div>
{{template "foot" .}}{{end}}
{{define "license"}}{{template "head" .}}{{$row := .Data.Row}}{{$csrf := .CSRF}}
<p class="small" style="margin:0 0 6px"><a href="{{adm}}/licenses">← Лицензии</a> · <a href="{{adm}}/tenants/{{$row.TenantID}}">{{$row.TenantName}}</a></p>
<h1>{{$row.TenantName}} <span class="badge {{$row.StatusClass}}" style="vertical-align:middle">{{$row.Status}}</span></h1>
{{if .Data.OfflineToken}}<div class="card" style="border-color:var(--accent)"><h2>Офлайн-ключ выпущен</h2>
<p class="muted small" style="margin-top:0">Ключ показывается один раз. Вставьте его в bj: Настройка → Лицензия. Действует до {{d $row.ContractUntil}} без связи с сервером; отзыв на него не распространяется.</p>
<div class="token">{{.Data.OfflineToken}}</div></div>{{end}}
<div class="grid">
<div class="card"><h2>Код активации</h2>
<div class="code">{{$row.Code}}</div>
<p class="muted small">Клиент вводит код в bj (Настройка → Лицензия → «Активировать»). Одним кодом активируется до {{if $row.MaxNodes}}{{$row.MaxNodes}}{{else}}любого числа{{end}} установок; сейчас активных {{$row.Active}}.</p>
<form method="post" action="{{adm}}/licenses/{{$row.ID}}/code" onsubmit="return confirm('Выпустить новый код? Прежний перестанет действовать; активированные установки продолжат работать.')">
<input type="hidden" name="csrf" value="{{$csrf}}"><button class="btn sm" type="submit">Выпустить новый код</button></form>
</div>
<div class="card"><h2>Сводка</h2><dl class="kv">
<dt>План</dt><dd>{{plan $row.Plan}}</dd>
<dt>Модули</dt><dd>{{range $row.Features}}<span class="chip">{{feature .}}</span>{{else}}<span class="none"></span>{{end}}</dd>
<dt>Договор до</dt><dd>{{d $row.ContractUntil}}{{if not $row.Revoked}} <span class="muted small">(осталось {{daysLeft $row.ContractUntil}} дн.)</span>{{end}}</dd>
<dt>Выпущена</dt><dd>{{dt $row.CreatedAt}}{{if $row.CreatedBy}} · {{$row.CreatedBy}}{{end}}</dd>
<dt>ID</dt><dd class="mono">{{$row.ID}}</dd>
{{if $row.Revoked}}<dt>Отозвана</dt><dd>{{dt $row.RevokedAt}}: {{$row.RevokeReason}}</dd>{{end}}
</dl></div>
</div>
<div class="card"><h2>Установки</h2><div class="table-wrap"><table>
<thead><tr><th>Сервер</th><th>Версия</th><th>IP</th><th>Первая активация</th><th>Последнее продление</th><th>Токен до</th><th></th></tr></thead><tbody>
{{range .Data.Installs}}<tr>
<td><b>{{if .Hostname}}{{.Hostname}}{{else}}<span class="none"></span>{{end}}</b><div class="mono muted">{{.InstallID}}</div>{{if .Blocked}}<span class="badge bad">заблокирована</span> <span class="small">{{.BlockReason}}</span>{{else if .Stale}}<span class="badge warn">нет связи</span>{{end}}</td>
<td class="small">{{.Version}}</td><td class="small mono">{{.IP}}</td><td class="small">{{dt .FirstSeen}}</td>
<td class="small">{{dt .LastSeen}}<div class="muted">{{ago .LastSeen}}</div></td><td class="small">{{d .TokenExpires}}</td>
<td>{{if .Blocked}}<form method="post" action="{{adm}}/licenses/{{$row.ID}}/installs/{{.InstallID}}/unblock"><input type="hidden" name="csrf" value="{{$csrf}}"><button class="btn sm" type="submit">Разблокировать</button></form>
{{else}}<details><summary class="small">Заблокировать</summary><form class="f" method="post" action="{{adm}}/licenses/{{$row.ID}}/installs/{{.InstallID}}/block" style="margin-top:6px;min-width:200px"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="text" name="reason" placeholder="причина" required><button class="btn sm danger" type="submit">Заблокировать</button></form></details>{{end}}</td>
</tr>{{else}}<tr><td colspan="7" class="muted">Установок ещё нет: они появятся после активации кодом.</td></tr>{{end}}
</tbody></table></div></div>
<div class="card"><h2>Условия</h2>
<form class="f" method="post" action="{{adm}}/licenses/{{$row.ID}}/edit"><input type="hidden" name="csrf" value="{{$csrf}}">
{{template "terms" (dict3 .Plans .Features $row.License)}}
<div class="actions"><button class="btn primary" type="submit">Сохранить условия</button><span class="muted small">установки получат их при следующем продлении</span></div>
</form></div>
<div class="grid">
<div class="card"><h2>Офлайн-ключ</h2>
<p class="muted small" style="margin-top:0">Для закрытого контура без доступа к серверу: ключ без привязки к установке, действует до окончания договора. Отозвать его нельзя.</p>
<form method="post" action="{{adm}}/licenses/{{$row.ID}}/offline" onsubmit="return confirm('Выпустить офлайн-ключ до {{d $row.ContractUntil}}? Отозвать его будет нельзя.')"><input type="hidden" name="csrf" value="{{$csrf}}"><button class="btn" type="submit" {{if ne $row.StatusClass "ok"}}{{if ne $row.StatusClass "warn"}}disabled{{end}}{{end}}>Выпустить офлайн-ключ</button></form>
</div>
<div class="card"><h2>{{if $row.Revoked}}Восстановление{{else}}Отзыв{{end}}</h2>
{{if $row.Revoked}}
<form method="post" action="{{adm}}/licenses/{{$row.ID}}/restore"><input type="hidden" name="csrf" value="{{$csrf}}"><button class="btn" type="submit">Восстановить лицензию</button></form>
{{else}}
<p class="muted small" style="margin-top:0">Продления прекращаются; выданные токены доживают свой срок (до 60 дней).</p>
<form class="f" method="post" action="{{adm}}/licenses/{{$row.ID}}/revoke" onsubmit="return confirm('Отозвать лицензию?')"><input type="hidden" name="csrf" value="{{$csrf}}">
<input type="text" name="reason" placeholder="причина отзыва" required><div><button class="btn danger" type="submit">Отозвать лицензию</button></div></form>
{{end}}
</div>
</div>
<div class="card"><h2>Журнал лицензии</h2>{{template "evtable" .Data.Events}}</div>
{{template "foot" .}}{{end}}
{{define "installs"}}{{template "head" .}}{{$csrf := .CSRF}}
<h1>Установки</h1>
<div class="card"><div class="table-wrap"><table>
<thead><tr><th>Клиент</th><th>Сервер</th><th>Версия</th><th>IP</th><th>Последнее продление</th><th>Токен до</th><th>Статус</th></tr></thead><tbody>
{{range .Data.Installs}}<tr>
<td><a href="{{adm}}/licenses/{{.LicenseID}}">{{if .TenantName}}{{.TenantName}}{{else}}{{short .LicenseID}}{{end}}</a></td>
<td><b>{{if .Hostname}}{{.Hostname}}{{else}}<span class="none"></span>{{end}}</b><div class="mono muted">{{short .InstallID}}</div></td>
<td class="small">{{.Version}}</td><td class="small mono">{{.IP}}</td><td class="small">{{dt .LastSeen}}<div class="muted">{{ago .LastSeen}}</div></td><td class="small">{{d .TokenExpires}}</td>
<td>{{if .Blocked}}<span class="badge bad">заблокирована</span>{{else if .Stale}}<span class="badge warn">нет связи</span>{{else}}<span class="badge ok">на связи</span>{{end}}</td>
</tr>{{else}}<tr><td colspan="7" class="muted">Установок пока нет.</td></tr>{{end}}
</tbody></table></div></div>
<div class="card"><h2>Доверенные IP (fail2ban)</h2>
<p class="muted small" style="margin:0 0 10px">Адрес попадает сюда после успешной активации или продления лицензии. fail2ban не блокирует его за ошибки API (неверный код, испорченный токен). На вход в кабинет и SSH доверие не распространяется. При отзыве лицензии или блокировке установки адрес исключается автоматически.</p>
<div class="table-wrap"><table>
<thead><tr><th>IP</th><th>Клиент</th><th>Сервер</th><th>Добавлен</th><th>Последнее обращение</th><th></th></tr></thead><tbody>
{{range .Data.Trusted}}<tr>
<td class="mono"><b>{{.IP}}</b></td>
<td><a href="{{adm}}/licenses/{{.LicenseID}}">{{if .TenantName}}{{.TenantName}}{{else}}{{short .LicenseID}}{{end}}</a></td>
<td class="small">{{if .Hostname}}{{.Hostname}}{{else}}<span class="none"></span>{{end}}<div class="mono muted">{{short .InstallID}}</div></td>
<td class="small">{{dt .AddedAt}}</td><td class="small">{{dt .LastSeen}}<div class="muted">{{ago .LastSeen}}</div></td>
<td><form method="post" action="{{adm}}/trusted/remove"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="ip" value="{{.IP}}"><input type="hidden" name="install" value="{{.InstallID}}"><button class="btn sm danger" type="submit">Исключить</button></form></td>
</tr>{{else}}<tr><td colspan="6" class="muted">Доверенных адресов пока нет: они появятся после первой активации или продления.</td></tr>{{end}}
</tbody></table></div></div>
{{template "foot" .}}{{end}}
{{define "security"}}{{template "head" .}}{{$csrf := .CSRF}}{{$d := .Data}}
<h1>Защита</h1>
<div class="card"><h2>Доступ к кабинету</h2>
<div class="grid">
<div>
<h3 style="margin:0 0 6px;font-size:15px">Адрес кабинета</h3>
<p style="margin:0 0 8px"><span class="mono"><b>https://{{$d.Host}}{{$d.AdminBase}}/</b></span> {{if $d.AdminDefault}}<span class="badge warn">стандартный</span>{{else}}<span class="badge ok">свой</span>{{end}}</p>
<form method="post" action="{{adm}}/security/access" class="actions" onsubmit="return confirm('Сменить адрес кабинета? Прежний перестанет открываться.')"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="op" value="path"><input name="path" placeholder="свой адрес, 8–64 символа: a-z, 0-9, дефис" pattern="[a-z0-9][a-z0-9\-]{7,63}" required style="max-width:280px"><button class="btn" type="submit">Сохранить</button></form>
<div class="actions" style="margin-top:6px">
<form method="post" action="{{adm}}/security/access" onsubmit="return confirm('Сгенерировать новый адрес кабинета? Прежний перестанет открываться.')"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="op" value="path-random"><button class="btn" type="submit">Сгенерировать случайный</button></form>
{{if not $d.AdminDefault}}<form method="post" action="{{adm}}/security/access" onsubmit="return confirm('Вернуть стандартный адрес /admin?')"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="op" value="path-default"><button class="btn sm" type="submit">Вернуть /admin</button></form>{{end}}
</div>
<p class="muted small" style="margin:8px 0 0">При своём адресе /admin и главная страница отвечают «не найдено», и боты не находят форму входа. Сохраните адрес в закладки и не пересылайте. Узнать по SSH: <span class="mono">/opt/bj-license/bj-license-server admin-info</span></p>
</div>
<div>
<h3 style="margin:0 0 6px;font-size:15px">Только из белого списка</h3>
<p style="margin:0 0 8px">{{if and $d.AdminAllowOnly $d.AdminOpen}}<span class="badge warn">открыт аварийно по SSH</span> Ограничение включено, но сейчас не действует.{{else if $d.AdminAllowOnly}}<span class="badge ok">включено</span> Кабинет и страница входа видны только адресам белого списка администраторов, остальным отвечает «не найдено».{{else}}<span class="badge warn">выключено</span> Страница входа открывается с любого адреса.{{end}}</p>
{{if and $d.AdminAllowOnly (not $d.AdminOpen)}}<form method="post" action="{{adm}}/security/access" onsubmit="return confirm('Снять ограничение? Страница входа станет доступна с любого адреса.')"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="op" value="allow-only-off"><button class="btn" type="submit">Снять ограничение</button></form>
{{else}}<form method="post" action="{{adm}}/security/access" onsubmit="return confirm('Открывать кабинет только с адресов белого списка?')"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="op" value="allow-only-on"><button class="btn primary" type="submit">Включить ограничение</button></form>{{end}}
<p class="muted small" style="margin:8px 0 0">API активации и продления это не затрагивает. Ваш адрес <span class="mono">{{$d.MyIP}}</span> {{if $d.MyIPCovered}}в белом списке{{else}}добавится в белый список при включении{{end}}. Аварийно открыть кабинет по SSH: <span class="mono">/opt/bj-license/bj-license-server fw-apply -off</span></p>
</div>
<div>
<h3 style="margin:0 0 6px;font-size:15px">Второй фактор</h3>
<p style="margin:0 0 8px">{{if eq $d.TwoFA $d.Admins}}<span class="badge ok">у всех</span>{{else}}<span class="badge warn">{{$d.TwoFA}} из {{$d.Admins}}</span>{{end}} администраторов входят с кодом из приложения.</p>
<div class="actions"><a class="btn" href="{{adm}}/me/2fa">Мой второй фактор</a><a class="btn" href="{{adm}}/users">Администраторы</a></div>
<p class="muted small" style="margin:8px 0 0">Даже зная пароль, без телефона войти нельзя. Потерянный телефон: второй фактор сбрасывает другой администратор или по SSH <span class="mono">admin-config -reset-2fa логин</span> при остановленном сервере.</p>
</div>
</div></div>
<div class="card"><h2>ЗЕТИТ Ключ</h2>
<p style="margin:0 0 10px">{{if $d.ZKEnabled}}<span class="badge ok">включён</span> После пароля вход подтверждается push-уведомлением в приложении «ЗЕТИТ Ключ» или кодом из него. Действует для администраторов с указанным e-mail ({{$d.ZKWithEmail}} из {{$d.Admins}}).{{else}}<span class="badge warn">выключен</span> Второй фактор через приложение «ЗЕТИТ Ключ», как при входе в ZETIT Connect.{{end}}</p>
<form class="f" method="post" action="{{adm}}/security/zetit-key" style="max-width:620px"><input type="hidden" name="csrf" value="{{$csrf}}">
<div><label>Адрес API</label><input type="url" name="url" value="{{$d.ZKURL}}" placeholder="https://key.zetit.ru/api/v1/connect-auth"></div>
<div><label>Ключ приложения (X-Connect-Key)</label><input type="password" name="app_key" autocomplete="off" placeholder="{{if $d.ZKHasKey}}задан, оставьте пустым, чтобы не менять{{else}}панель ЗЕТИТ Ключ → Система → Внешние приложения → Добавить{{end}}"></div>
<label style="display:flex;gap:8px;align-items:center;font-weight:600"><input type="checkbox" name="enabled" value="1" {{if $d.ZKEnabled}}checked{{end}}> Требовать подтверждение в ЗЕТИТ Ключ при входе</label>
<div class="actions"><button class="btn primary" type="submit" name="op" value="save">Сохранить</button><button class="btn" type="submit" name="op" value="test">Проверить мой e-mail</button></div>
</form>
<p class="muted small" style="margin:10px 0 0">E-mail администраторов задаётся на странице <a href="{{adm}}/users">«Администраторы»</a> и должен совпадать с учётной записью в ЗЕТИТ Ключ. Включить можно, только если ваш e-mail найден в Ключе с привязанным приложением. У администраторов без e-mail действует пароль и код приложения-аутентификатора, если он подключён. Если Ключ недоступен, вход с e-mail невозможен. Аварийно выключить по SSH при остановленном сервере: <span class="mono">bj-license-server admin-config -zetit-key off</span></p>
</div>
{{if not $d.StatusFresh}}<div class="card"><span class="badge bad">Помощник защиты не отвечает</span>
<p class="small" style="margin:8px 0 0">{{if $d.Status.At.IsZero}}Статуса ещё нет.{{else}}Последний отчёт {{dt $d.Status.At}} ({{ago $d.Status.At}}).{{end}} Команды из этого раздела не применяются. На сервере: <span class="mono">systemctl status bj-license-fw.timer bj-license-fw.path</span></p></div>{{end}}
<div class="card">
<h2>Режим обороны</h2>
<p>{{if $d.Status.Lockdown}}<span class="badge bad">Оборона включена</span> На сайт и API пускаются только адреса из белого списка ({{$d.Status.AllowCount}}){{if not $d.Status.Until.IsZero}}, автоматически выключится {{dt $d.Status.Until}}{{end}}.
{{else if $d.Status.Emergency}}<span class="badge warn">Оборона выключена аварийно по SSH</span> Включить снова можно кнопкой ниже.
{{else}}<span class="badge ok">Обычный режим</span> Доступ открыт всем, fail2ban блокирует адреса, которые перебирают пароли и коды.{{end}}</p>
{{if $d.WantedActive}}
<form method="post" action="{{adm}}/security/mode" class="actions" onsubmit="return confirm('Выключить режим обороны и открыть доступ всем?')"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="op" value="off"><button class="btn primary" type="submit">Выключить оборону</button></form>
{{else}}
<form method="post" action="{{adm}}/security/mode" class="actions" onsubmit="return confirm('Включить режим обороны? Сайт и API станут недоступны всем, кроме белого списка.')"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="op" value="on">
<select name="hours"><option value="1">на 1 час</option><option value="6">на 6 часов</option><option value="24" selected>на 24 часа</option><option value="0">без срока</option></select>
<button class="btn danger" type="submit">Включить оборону</button></form>
{{end}}
<p class="muted small" style="margin:10px 0 0">Для атаки на сервер: все, кроме белого списка, получают отказ на уровне файрвола (порты 80 и 443). В белом списке: доверенные IP установок ({{$d.TrustedCount}}, <a href="{{adm}}/installs">Установки</a>) и адреса администраторов ниже. Ваш адрес <span class="mono">{{$d.MyIP}}</span> {{if $d.MyIPCovered}}уже в белом списке{{else}}добавится в белый список автоматически при включении{{end}}. SSH не затрагивается. Пока оборона включена, Let's Encrypt не сможет продлить сертификат. Аварийно выключить по SSH: <span class="mono">/opt/bj-license/bj-license-server fw-apply -off</span></p>
{{if $d.Status.Errors}}<div style="margin-top:10px">{{range $d.Status.Errors}}<div class="badge bad" style="display:block;margin-top:4px;white-space:normal">{{.}}</div>{{end}}</div>{{end}}
</div>
<div class="card"><h2>Белый список администраторов</h2>
<p class="muted small" style="margin:0 0 10px">Адреса офиса и администраторов. Они не блокируются fail2ban (ни за вход в кабинет, ни за API) и пропускаются в режиме обороны.</p>
<div class="table-wrap"><table>
<thead><tr><th>IP или сеть</th><th>Комментарий</th><th>Добавил</th><th></th></tr></thead><tbody>
{{range $d.Allow}}<tr><td class="mono"><b>{{.Net}}</b></td><td class="small">{{.Comment}}</td><td class="small">{{.AddedBy}}<div class="muted">{{dt .AddedAt}}</div></td>
<td><form method="post" action="{{adm}}/security/allow"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="op" value="remove"><input type="hidden" name="net" value="{{.Net}}"><button class="btn sm danger" type="submit">Убрать</button></form></td></tr>
{{else}}<tr><td colspan="4" class="muted">Пока пусто.</td></tr>{{end}}
</tbody></table></div>
<form method="post" action="{{adm}}/security/allow" class="actions" style="margin-top:10px"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="op" value="add">
<input name="net" placeholder="IP или сеть, например 203.0.113.10 или 203.0.113.0/24" required style="max-width:340px"><input name="comment" placeholder="комментарий" style="max-width:260px"><button class="btn" type="submit">Добавить</button></form>
</div>
<div class="card"><h2>Параметры блокировок</h2>
<p class="muted small" style="margin:0 0 10px">Сколько неудачных попыток за период приводят к блокировке адреса и на какой срок. Долгие блокировки сохраняются и после перезапуска fail2ban. Белый список и доверенные установки не блокируются.</p>
<form method="post" action="{{adm}}/security/limits"><input type="hidden" name="csrf" value="{{$csrf}}">
<div class="table-wrap"><table>
<thead><tr><th>Что защищаем</th><th>Попыток</th><th>За период</th><th>Блокировать на</th></tr></thead><tbody>
{{range $d.Limits}}{{$row := .}}<tr><td><b>{{.Title}}</b>{{if .HasEffective}}<div class="muted small">{{.Effective}}</div>{{end}}</td>
<td><select name="retry_{{.Jail}}">{{range .RetryOpts}}<option value="{{.}}" {{if eq . $row.Conf.MaxRetry}}selected{{end}}>{{.}}</option>{{end}}</select></td>
<td><select name="find_{{.Jail}}">{{range .FindOpts}}<option value="{{.Sec}}" {{if eq .Sec $row.Conf.FindTime}}selected{{end}}>{{.Label}}</option>{{end}}</select></td>
<td><select name="ban_{{.Jail}}">{{range .BanOpts}}<option value="{{.Sec}}" {{if eq .Sec $row.Conf.BanTime}}selected{{end}}>{{.Label}}</option>{{end}}</select></td></tr>
{{end}}
<tr><td><b>Вход в кабинет (приложение)</b><div class="muted small">срабатывает раньше fail2ban: после N неудачных попыток форма входа отказывает этому адресу и логину</div></td>
<td><select name="login_tries">{{range $d.RetryPresets}}<option value="{{.}}" {{if eq . $d.LoginTries}}selected{{end}}>{{.}}</option>{{end}}{{if and (ne $d.LoginTries 3) (ne $d.LoginTries 5) (ne $d.LoginTries 10) (ne $d.LoginTries 20)}}<option value="{{$d.LoginTries}}" selected>{{$d.LoginTries}}</option>{{end}}</select></td>
<td class="muted small">за срок блокировки</td>
<td><select name="login_lock">{{range $d.BanPresets}}{{if ne .Sec -1}}<option value="{{.Sec}}" {{if eq .Sec $d.LoginLockSec}}selected{{end}}>{{.Label}}</option>{{end}}{{end}}</select></td></tr>
</tbody></table></div>
<div class="actions" style="margin-top:10px"><button class="btn primary" type="submit">Сохранить параметры</button></div>
</form>
</div>
<div class="card"><h2>fail2ban</h2>
<div class="table-wrap"><table>
<thead><tr><th>Jail</th><th>Ошибок сейчас / всего</th><th>Заблокировано сейчас / всего</th><th>Заблокированные адреса</th></tr></thead><tbody>
{{range $d.Status.Jails}}{{$jail := .Name}}<tr><td><b>{{.Name}}</b><div class="muted small">{{if eq .Name "bj-license-api"}}коды активации и токены{{else if eq .Name "bj-license-admin"}}вход в кабинет{{else if eq .Name "sshd"}}SSH{{end}}</div></td>
<td>{{.CurrentlyFailed}} / {{.TotalFailed}}</td><td>{{.CurrentlyBanned}} / {{.TotalBanned}}</td>
<td>{{range .Banned}}<form method="post" action="{{adm}}/security/ban" class="actions" style="margin:0 0 4px"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="op" value="unban"><input type="hidden" name="jail" value="{{$jail}}"><input type="hidden" name="ip" value="{{.}}"><span class="mono">{{.}}</span><button class="btn sm" type="submit">Разблокировать</button></form>{{else}}<span class="muted">нет</span>{{end}}</td></tr>
{{else}}<tr><td colspan="4" class="muted">Данных пока нет.</td></tr>{{end}}
</tbody></table></div>
{{if $d.Pending}}<p class="small" style="margin:10px 0 0"><b>Ожидают применения:</b> {{range $d.Pending}}<span class="mono">{{.Op}} {{.IP}} ({{.Jail}})</span> {{end}}</p>{{end}}
<form method="post" action="{{adm}}/security/ban" class="actions" style="margin-top:12px"><input type="hidden" name="csrf" value="{{$csrf}}">
<input name="ip" placeholder="IP-адрес" required style="max-width:220px">
<select name="jail">{{range $d.Jails}}<option value="{{.}}">{{.}}</option>{{end}}</select>
<select name="op"><option value="ban">Заблокировать</option><option value="unban">Разблокировать</option></select>
<button class="btn" type="submit">Выполнить</button></form>
<p class="muted small" style="margin:8px 0 0">Ручная блокировка действует срок блокировки jail из «Параметров блокировок». Статус обновляется раз в 30 секунд{{if not $d.Status.At.IsZero}}, последний раз {{dt $d.Status.At}}{{end}}.</p>
</div>
{{template "foot" .}}{{end}}
{{define "totp"}}{{template "head" .}}{{$csrf := .CSRF}}{{$d := .Data}}
<h1>Второй фактор входа</h1>
<div class="card">
{{if $d.Enabled}}
<p><span class="badge ok">Подключён</span> При входе в кабинет, кроме пароля, нужен шестизначный код из приложения.</p>
<form class="f" method="post" action="{{adm}}/me/2fa/disable" style="max-width:360px"><input type="hidden" name="csrf" value="{{$csrf}}">
<div><label>Код из приложения, чтобы отключить</label><input name="code" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" maxlength="6" required></div>
<div><button class="btn danger" type="submit">Отключить второй фактор</button></div></form>
{{else}}
<p style="margin-top:0">Установите на телефон приложение для одноразовых кодов: Яндекс Ключ, Google Authenticator, Microsoft Authenticator или любое с поддержкой TOTP.</p>
<div class="grid">
<div><h2>1. Добавьте ключ в приложение</h2>
{{if $d.QR}}<div style="background:#fff;display:inline-block;padding:8px;border-radius:8px">{{$d.QR}}</div>{{end}}
<p class="small">Отсканируйте QR-код или введите ключ вручную: <span class="mono"><b>{{$d.SecretGrouped}}</b></span>. Если открыли кабинет на телефоне, <a href="{{$d.URI}}">добавить в приложение</a>.</p></div>
<div><h2>2. Подтвердите кодом</h2>
<form class="f" method="post" action="{{adm}}/me/2fa/enable"><input type="hidden" name="csrf" value="{{$csrf}}">
<div><label>Шестизначный код из приложения</label><input name="code" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" maxlength="6" required autofocus></div>
<div><button class="btn primary" type="submit">Подключить</button></div></form>
<p class="muted small">Ключ показывается только на этой странице. Если телефон потеряется, второй фактор сбросит другой администратор на странице «Администраторы».</p></div>
</div>
{{end}}
</div>
{{template "foot" .}}{{end}}
{{define "events"}}{{template "head" .}}
<h1>Журнал</h1>
<div class="card">{{template "evtable" .Data}}<p class="muted small" style="margin:10px 0 0">Последние 500 событий.</p></div>
{{template "foot" .}}{{end}}
{{define "users"}}{{template "head" .}}{{$csrf := .CSRF}}{{$me := .Me}}
<h1>Администраторы</h1>
<div class="card"><div class="table-wrap"><table>
<thead><tr><th>Логин</th><th>Имя</th><th>E-mail (ЗЕТИТ Ключ)</th><th>Последний вход</th><th>Статус</th><th>Второй фактор</th><th>Сменить пароль</th><th></th></tr></thead><tbody>
{{range .Data}}<tr>
<td><b>{{.Login}}</b></td><td>{{.Name}}</td><td><form method="post" action="{{adm}}/users/{{.Login}}/email" class="actions" style="margin:0"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="email" name="email" value="{{.Email}}" placeholder="e-mail в ЗЕТИТ Ключ" style="max-width:220px"><button class="btn sm" type="submit">Сохранить</button></form></td><td class="small">{{dt .LastLogin}}</td>
<td>{{if .Disabled}}<span class="badge bad">отключён</span>{{else}}<span class="badge ok">активен</span>{{end}}</td>
<td>{{if .HasTOTP}}<span class="badge ok">подключён</span>{{if ne .Login $me.Login}}<form method="post" action="{{adm}}/users/{{.Login}}/2fa-reset" style="margin:4px 0 0" onsubmit="return confirm('Сбросить второй фактор администратора?')"><input type="hidden" name="csrf" value="{{$csrf}}"><button class="btn sm" type="submit">Сбросить</button></form>{{end}}{{else}}<span class="badge warn">нет</span>{{end}}{{if eq .Login $me.Login}}<div><a class="small" href="{{adm}}/me/2fa">{{if .HasTOTP}}управлять{{else}}подключить{{end}}</a></div>{{end}}</td>
<td><form class="actions" method="post" action="{{adm}}/users/{{.Login}}/password"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="password" name="password" placeholder="новый пароль, от 10 символов" minlength="10" required autocomplete="new-password" style="max-width:230px"><button class="btn sm" type="submit">Задать</button></form></td>
<td>{{if ne .Login $me.Login}}<form method="post" action="{{adm}}/users/{{.Login}}/toggle"><input type="hidden" name="csrf" value="{{$csrf}}"><button class="btn sm {{if not .Disabled}}danger{{end}}" type="submit">{{if .Disabled}}Включить{{else}}Отключить{{end}}</button></form>{{end}}</td>
</tr>{{end}}
</tbody></table></div></div>
<div class="grid">
<div class="card"><h2>Новый администратор</h2>
<form class="f" method="post" action="{{adm}}/users"><input type="hidden" name="csrf" value="{{$csrf}}">
<div class="row2"><div><label>Логин</label><input type="text" name="login" required pattern="[A-Za-z0-9_.\-]{2,40}"></div><div><label>Имя</label><input type="text" name="name"></div></div>
<div><label>E-mail в ЗЕТИТ Ключ <span class="muted" style="font-weight:400">(для подтверждения входа в приложении)</span></label><input type="email" name="email"></div>
<div><label>Пароль (от 10 символов)</label><input type="password" name="password" minlength="10" required autocomplete="new-password"></div>
<div><button class="btn primary" type="submit">Добавить</button></div>
</form></div>
<div class="card"><h2>Мой пароль</h2>
<form class="f" method="post" action="{{adm}}/me/password"><input type="hidden" name="csrf" value="{{$csrf}}">
<div><label>Текущий пароль</label><input type="password" name="current" required autocomplete="current-password"></div>
<div class="row2"><div><label>Новый пароль</label><input type="password" name="password" minlength="10" required autocomplete="new-password"></div><div><label>Повтор</label><input type="password" name="password2" minlength="10" required autocomplete="new-password"></div></div>
<div><button class="btn primary" type="submit">Сменить пароль</button></div>
</form></div>
</div>
{{template "foot" .}}{{end}}
-389
View File
@@ -1,389 +0,0 @@
package main
// Второй фактор через «ЗЕТИТ Ключ» — так же, как вход в ZETIT Connect. После пароля сервер лицензий
// просит Ключ подтвердить вход: push-уведомление на телефон администратора или код из приложения.
// Статус опрашивает только сервер; вход разрешается лишь при approved (push) или success (код).
// API: Zetit_key/docs/EXTERNAL_APP_2FA.md, способ 1 (приложение с X-Connect-Key).
//
// Действует для администраторов с указанным e-mail, когда интеграция включена в разделе «Защита».
// Остальные входят по паролю и, если подключён, по коду приложения-аутентификатора (access.go).
// Аварийно выключить по SSH: bj-license-server admin-config -zetit-key off (сервер остановлен).
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
const (
zetitKeyDefaultURL = "https://key.zetit.ru/api/v1/connect-auth"
zkPendingTTL = 5 * time.Minute // push живёт 60 секунд, код из приложения можно ввести и позже
)
// ZetitKeyConf — настройки интеграции с «ЗЕТИТ Ключ».
type ZetitKeyConf struct {
Enabled bool `json:"enabled,omitempty"`
URL string `json:"url,omitempty"` // по умолчанию https://key.zetit.ru/api/v1/connect-auth
AppKey string `json:"app_key,omitempty"` // X-Connect-Key приложения из панели Ключа
}
func (c ZetitKeyConf) active() bool { return c.Enabled && c.AppKey != "" }
var zkClient = &http.Client{Timeout: 8 * time.Second}
func zkCall(c ZetitKeyConf, method, path string, body, out any) error {
var rd io.Reader
if body != nil {
b, _ := json.Marshal(body)
rd = bytes.NewReader(b)
}
req, err := http.NewRequest(method, strings.TrimRight(pickStr(c.URL, zetitKeyDefaultURL), "/")+path, rd)
if err != nil {
return err
}
req.Header.Set("X-Connect-Key", c.AppKey)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := zkClient.Do(req)
if err != nil {
return fmt.Errorf("сервер недоступен: %v", err)
}
defer resp.Body.Close()
data, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("ответ %d: %s", resp.StatusCode, clip(string(data), 200))
}
if out != nil {
if err := json.Unmarshal(data, out); err != nil {
return fmt.Errorf("ответ не разобран: %v", err)
}
}
return nil
}
type zkLookupResult struct {
Exists bool `json:"exists"`
Has2FA bool `json:"has_2fa"`
FullName string `json:"full_name"`
}
type zkVerifyResult struct {
RequestID string `json:"request_id"`
Status string `json:"status"`
NeedsSetup bool `json:"needs_setup"`
}
func zkLookup(c ZetitKeyConf, email string) (zkLookupResult, error) {
var r zkLookupResult
return r, zkCall(c, http.MethodGet, "/lookup?email="+url.QueryEscape(email), nil, &r)
}
func zkStart(c ZetitKeyConf, email, ip string) (zkVerifyResult, error) {
var r zkVerifyResult
return r, zkCall(c, http.MethodPost, "/verify", map[string]string{"email": email, "source_ip": ip}, &r)
}
func zkStatus(c ZetitKeyConf, requestID string) (string, error) {
var r zkVerifyResult
err := zkCall(c, http.MethodGet, "/verify/"+url.PathEscape(requestID), nil, &r)
return r.Status, err
}
func zkCode(c ZetitKeyConf, email, code string) (bool, error) {
var r struct {
Success bool `json:"success"`
}
err := zkCall(c, http.MethodPost, "/verify-totp", map[string]string{"email": email, "code": code}, &r)
return r.Success, err
}
// ---- вход с подтверждением ----
// pendingLogin — пароль проверен, ждём подтверждения в Ключе.
type pendingLogin struct {
Token, Login, Email, IP, RequestID, Next string
Created time.Time
}
type pendingLogins struct {
mu sync.Mutex
m map[string]*pendingLogin
}
var zkPending = &pendingLogins{m: map[string]*pendingLogin{}}
func (p *pendingLogins) put(pl *pendingLogin) {
p.mu.Lock()
defer p.mu.Unlock()
for k, v := range p.m {
if time.Since(v.Created) > zkPendingTTL {
delete(p.m, k)
}
}
p.m[pl.Token] = pl
}
// get — ожидающий вход по токену с того же адреса.
func (p *pendingLogins) get(token, ip string) *pendingLogin {
p.mu.Lock()
defer p.mu.Unlock()
pl := p.m[token]
if pl == nil || pl.IP != ip || time.Since(pl.Created) > zkPendingTTL {
return nil
}
return pl
}
func (p *pendingLogins) drop(token string) {
p.mu.Lock()
delete(p.m, token)
p.mu.Unlock()
}
func (srv *server) zetitKey() ZetitKeyConf {
var c ZetitKeyConf
srv.st.read(func(s *State) { c = s.ZetitKey })
return c
}
// finishLogin — второй фактор пройден (или не требуется): сессия и переход в кабинет.
func (srv *server) finishLogin(w http.ResponseWriter, r *http.Request, login, ip, next string, totpStep int64, how string) {
srv.fails.reset(ip + "|" + strings.ToLower(login))
sess := srv.sessions.create(login)
_ = srv.st.write(func(s *State) error {
if u := s.user(login); u != nil {
u.LastLogin = time.Now()
sess.Login = u.Login
if totpStep > 0 {
u.TOTPLast = totpStep
}
}
s.addEvent("login", "", "", login, ip, "вход в кабинет"+how)
return nil
})
srv.setSessionCookie(w, r, sess.Token)
http.Redirect(w, r, next, http.StatusSeeOther)
}
func (srv *server) renderLoginErr(w http.ResponseWriter, login, next, msg string, status int) {
p := page{Title: "Вход", Err: msg, Data: map[string]string{"Next": next, "Login": login}}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
_ = srv.tpl.ExecuteTemplate(w, "login", p)
}
// loginReject — отказ на втором факторе: счётчик неудач, строка для fail2ban, журнал.
func (srv *server) loginReject(w http.ResponseWriter, login, ip, next, eventText, msg string) {
srv.fails.add(ip + "|" + strings.ToLower(login))
srv.fails.add(ip)
log.Printf("login failed: user=%q ip=%s", login, ip) // строку ловит fail2ban (deploy/license/fail2ban)
time.Sleep(600 * time.Millisecond)
_ = srv.st.write(func(s *State) error {
s.addEvent("login_failed", "", "", login, ip, eventText)
return nil
})
srv.renderLoginErr(w, login, next, msg, http.StatusUnauthorized)
}
func (srv *server) renderLogin2FA(w http.ResponseWriter, pl *pendingLogin, errMsg string) {
left := 60 - int(time.Since(pl.Created).Seconds())
if left < 0 {
left = 0
}
p := page{Title: "Подтверждение входа", Err: errMsg, Data: map[string]any{"Token": pl.Token, "Email": pl.Email, "Left": left}}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_ = srv.tpl.ExecuteTemplate(w, "login2fa", p)
}
// login2FAStatus — GET /admin/login/2fa/status?t=…: статус push, опрос идёт с сервера.
func (srv *server) login2FAStatus(w http.ResponseWriter, r *http.Request) {
pl := zkPending.get(r.URL.Query().Get("t"), clientIP(r))
status := "expired"
if pl != nil && time.Since(pl.Created) <= 70*time.Second {
if st, err := zkStatus(srv.zetitKey(), pl.RequestID); err == nil {
status = st
} else {
status = "pending" // сбой связи — страница спросит ещё раз
}
}
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, http.StatusOK, map[string]string{"status": status})
}
// login2FAFinish — POST /admin/login/2fa/finish: страница увидела ответ, сервер перепроверяет его сам.
func (srv *server) login2FAFinish(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
ip := clientIP(r)
pl := zkPending.get(r.PostFormValue("t"), ip)
if pl == nil {
srv.renderLoginErr(w, "", "/admin/", "Время подтверждения истекло, войдите заново.", http.StatusUnauthorized)
return
}
st, err := zkStatus(srv.zetitKey(), pl.RequestID)
switch {
case err != nil:
srv.renderLogin2FA(w, pl, "ЗЕТИТ Ключ не ответил: "+err.Error()+". Введите код из приложения.")
case st == "approved":
zkPending.drop(pl.Token)
srv.finishLogin(w, r, pl.Login, ip, pl.Next, 0, " (ЗЕТИТ Ключ, push)")
case st == "denied":
zkPending.drop(pl.Token)
srv.loginReject(w, pl.Login, ip, pl.Next, "вход отклонён в ЗЕТИТ Ключ", "Вход отклонён в приложении «ЗЕТИТ Ключ».")
case st == "expired":
srv.renderLogin2FA(w, pl, "Время подтверждения истекло. Введите код из приложения или войдите заново.")
default:
srv.renderLogin2FA(w, pl, "Вход ещё не подтверждён в приложении.")
}
}
// login2FACode — POST /admin/login/2fa/code: код из приложения «ЗЕТИТ Ключ».
func (srv *server) login2FACode(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
ip := clientIP(r)
pl := zkPending.get(r.PostFormValue("t"), ip)
if pl == nil {
srv.renderLoginErr(w, "", "/admin/", "Время подтверждения истекло, войдите заново.", http.StatusUnauthorized)
return
}
if srv.fails.blocked(ip+"|"+strings.ToLower(pl.Login)) || srv.fails.blocked(ip) {
zkPending.drop(pl.Token)
srv.renderLoginErr(w, pl.Login, pl.Next, "Слишком много неудачных попыток. Вход заблокирован на "+durRu(int64(srv.fails.window()/time.Second))+".", http.StatusTooManyRequests)
return
}
ok, err := zkCode(srv.zetitKey(), pl.Email, strings.TrimSpace(r.PostFormValue("code")))
switch {
case err != nil:
srv.renderLogin2FA(w, pl, "Не удалось проверить код: "+err.Error())
case ok:
zkPending.drop(pl.Token)
srv.finishLogin(w, r, pl.Login, ip, pl.Next, 0, " (ЗЕТИТ Ключ, код)")
default:
srv.fails.add(ip + "|" + strings.ToLower(pl.Login))
srv.fails.add(ip)
log.Printf("login failed: user=%q ip=%s", pl.Login, ip)
_ = srv.st.write(func(s *State) error {
s.addEvent("login_failed", "", "", pl.Login, ip, "неверный код ЗЕТИТ Ключ")
return nil
})
srv.renderLogin2FA(w, pl, "Код неверен или уже использован.")
}
}
// ---- настройки ----
func validEmail(e string) bool {
at := strings.Index(e, "@")
return len(e) <= 120 && at > 0 && at < len(e)-3 && strings.Contains(e[at:], ".") && !strings.ContainsAny(e, " <>\"'")
}
// userEmail — POST /admin/users/{login}/email: e-mail администратора в «ЗЕТИТ Ключ».
func (srv *server) userEmail(w http.ResponseWriter, r *http.Request, sess *session) {
login, email := r.PathValue("login"), strings.TrimSpace(r.PostFormValue("email"))
if email != "" && !validEmail(email) {
back(w, r, "/admin/users", "", "неверный e-mail")
return
}
var bad string
err := srv.st.write(func(s *State) error {
u := s.user(login)
if u == nil {
return fmt.Errorf("администратор не найден")
}
if strings.EqualFold(u.Login, sess.Login) && email == "" && s.ZetitKey.active() {
bad = "вход через ЗЕТИТ Ключ включён: без e-mail вы войдёте только по паролю. Сначала выключите интеграцию в «Защите»."
return nil
}
u.Email = email
s.addEvent("user_email", "", "", sess.Login, clientIP(r), "e-mail администратора "+u.Login+": "+pickStr(email, "удалён"))
return nil
})
if err != nil || bad != "" {
back(w, r, "/admin/users", "", pickStr(bad, fmt.Sprint(err)))
return
}
back(w, r, "/admin/users", "E-mail "+login+" сохранён", "")
}
// securityZetitKey — POST /admin/security/zetit-key: адрес API, ключ приложения, включение, проверка.
func (srv *server) securityZetitKey(w http.ResponseWriter, r *http.Request, sess *session) {
op := r.PostFormValue("op")
var conf ZetitKeyConf
var myEmail string
srv.st.read(func(s *State) {
conf = s.ZetitKey
if u := s.user(sess.Login); u != nil {
myEmail = u.Email
}
})
if u := strings.TrimSpace(r.PostFormValue("url")); u != "" {
pu, err := url.Parse(u)
if err != nil || pu.Scheme != "https" || pu.Host == "" {
back(w, r, "/admin/security", "", "адрес API ЗЕТИТ Ключ: https://…")
return
}
conf.URL = strings.TrimRight(u, "/")
}
if k := strings.TrimSpace(r.PostFormValue("app_key")); k != "" {
conf.AppKey = k
}
check := func() (string, bool) {
switch {
case conf.AppKey == "":
return "не задан ключ приложения", false
case myEmail == "":
return "укажите свой e-mail на странице «Администраторы»", false
}
lk, err := zkLookup(conf, myEmail)
switch {
case err != nil:
return "ЗЕТИТ Ключ: " + err.Error(), false
case !lk.Exists:
return myEmail + " не найден в ЗЕТИТ Ключ", false
case !lk.Has2FA:
return myEmail + " найден в ЗЕТИТ Ключ, но приложение не привязано", false
}
return "ЗЕТИТ Ключ отвечает: " + myEmail + pickStr(" ("+lk.FullName+")", "") + " найден, приложение привязано", true
}
msg, ok := "", true
if op == "test" || r.PostFormValue("enabled") == "1" {
msg, ok = check()
}
switch {
case op == "test":
case r.PostFormValue("enabled") == "1" && !ok:
back(w, r, "/admin/security", "", "Не включено: "+msg)
return
default:
conf.Enabled = r.PostFormValue("enabled") == "1"
}
err := srv.st.write(func(s *State) error {
s.ZetitKey = conf
state := "выключен"
if conf.Enabled {
state = "включён"
}
s.addEvent("zetit_key", "", "", sess.Login, clientIP(r), "настройки ЗЕТИТ Ключ сохранены, вход через Ключ "+state)
return nil
})
switch {
case err != nil:
back(w, r, "/admin/security", "", err.Error())
case op == "test" && !ok:
back(w, r, "/admin/security", "", "Проверка: "+msg)
case op == "test":
back(w, r, "/admin/security", "Проверка: "+msg, "")
case conf.Enabled:
back(w, r, "/admin/security", "Вход через ЗЕТИТ Ключ включён. "+msg+".", "")
default:
back(w, r, "/admin/security", "Настройки ЗЕТИТ Ключ сохранены, вход через Ключ выключен.", "")
}
}
-192
View File
@@ -1,192 +0,0 @@
// Command bj-license — инструмент издателя: генерация ключей подписи,
// выпуск годовых лицензий и проверка.
//
// bj-license keygen -out ./keys/license
// bj-license issue -tenant "ООО Ромашка" -plan pro -days 365 \
// -features updates,web-cabinet -key ./keys/license.priv -keyid main
// bj-license verify -key-file license.key -pub ./keys/license.pub
package main
import (
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"fmt"
"os"
"strings"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/license"
)
// newUUID — UUID v4 без внешних зависимостей.
func newUUID() string {
var b [16]byte
_, _ = rand.Read(b[:])
b[6] = (b[6] & 0x0f) | 0x40 // версия 4
b[8] = (b[8] & 0x3f) | 0x80 // вариант
h := hex.EncodeToString(b[:])
return h[0:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:32]
}
func main() {
if len(os.Args) < 2 {
usage()
}
switch os.Args[1] {
case "keygen":
keygen(os.Args[2:])
case "issue":
issue(os.Args[2:])
case "verify":
verify(os.Args[2:])
default:
usage()
}
}
func usage() {
fmt.Fprintln(os.Stderr, "bj-license keygen -out <prefix>")
fmt.Fprintln(os.Stderr, "bj-license issue -tenant <name> -plan free|pro|enterprise -days <n> -features a,b -key <priv> [-keyid id] [-max-nodes n] [-note txt]")
fmt.Fprintln(os.Stderr, "bj-license verify -key-file <license.key> -pub <pubkey.pub>")
os.Exit(2)
}
func keygen(args []string) {
out := "license"
for i := 0; i < len(args)-1; i++ {
if args[i] == "-out" {
out = args[i+1]
}
}
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
fatal("keygen: %v", err)
}
if err := os.WriteFile(out+".priv", []byte(base64.StdEncoding.EncodeToString(priv.Seed())+"\n"), 0o600); err != nil {
fatal("write priv: %v", err)
}
if err := os.WriteFile(out+".pub", []byte(base64.StdEncoding.EncodeToString(pub)+"\n"), 0o644); err != nil {
fatal("write pub: %v", err)
}
fmt.Printf("Приватный ключ лицензий: %s.priv (СЕКРЕТ)\n", out)
fmt.Printf("Публичный ключ (зашить в bj-server):\n %s\n", base64.StdEncoding.EncodeToString(pub))
}
func issue(args []string) {
a := parseArgs(args)
tenant := a["tenant"]
keyPath := a["key"]
if tenant == "" || keyPath == "" {
fatal("issue: требуются -tenant и -key")
}
plan := license.Plan(orDefault(a["plan"], "pro"))
days := atoiDefault(a["days"], 365)
keyID := orDefault(a["keyid"], "main")
priv, err := license.LoadPrivateKey(keyPath)
if err != nil {
fatal("load key: %v", err)
}
now := time.Now().UTC()
var feats []string
if a["features"] != "" {
feats = strings.Split(a["features"], ",")
}
l := &license.License{
Schema: license.CurrentSchema,
ID: newUUID(),
Tenant: tenant,
Product: "bj-server",
Plan: plan,
IssuedAt: now,
ExpiresAt: now.AddDate(0, 0, days),
Features: feats,
MaxNodes: atoiDefault(a["max-nodes"], 0),
Note: a["note"],
}
tok, err := license.Sign(l, priv, keyID)
if err != nil {
fatal("sign: %v", err)
}
fmt.Printf("Лицензия выпущена: tenant=%q plan=%s до %s (%d дней)\n",
tenant, plan, l.ExpiresAt.Format("02.01.2006"), days)
fmt.Printf("ID: %s\n", l.ID)
fmt.Println("Ключ для клиента (вставить в bj-server → Лицензия):")
fmt.Println(tok.Encode())
}
func verify(args []string) {
a := parseArgs(args)
if a["key-file"] == "" || a["pub"] == "" {
fatal("verify: требуются -key-file и -pub")
}
raw, err := os.ReadFile(a["key-file"])
if err != nil {
fatal("read key-file: %v", err)
}
pubB, err := os.ReadFile(a["pub"])
if err != nil {
fatal("read pub: %v", err)
}
pub, err := license.ParsePublicKey(strings.TrimSpace(string(pubB)))
if err != nil {
fatal("pub: %v", err)
}
tok, err := license.DecodeToken(string(raw))
if err != nil {
fatal("decode: %v", err)
}
l, err := license.Verify(tok, pub)
if err != nil {
fatal("verify: %v", err)
}
now := time.Now().UTC()
fmt.Printf("Подпись валидна. tenant=%q plan=%s\n", l.Tenant, l.Plan)
fmt.Printf("Действует: %s — %s (осталось %d дней)\n",
l.IssuedAt.Format("02.01.2006"), l.ExpiresAt.Format("02.01.2006"), l.DaysLeft(now))
if err := l.Valid(now); err != nil {
fmt.Printf("СТАТУС: %v\n", err)
} else {
fmt.Printf("СТАТУС: активна, обновления %v\n", l.AllowsUpdates())
}
}
// --- helpers ---
func parseArgs(args []string) map[string]string {
m := map[string]string{}
for i := 0; i < len(args); i++ {
if strings.HasPrefix(args[i], "-") && i+1 < len(args) {
m[strings.TrimPrefix(args[i], "-")] = args[i+1]
i++
}
}
return m
}
func orDefault(s, def string) string {
if s == "" {
return def
}
return s
}
func atoiDefault(s string, def int) int {
if s == "" {
return def
}
var n int
_, err := fmt.Sscanf(s, "%d", &n)
if err != nil {
return def
}
return n
}
func fatal(format string, a ...any) {
fmt.Fprintf(os.Stderr, "bj-license: "+format+"\n", a...)
os.Exit(1)
}
+27 -417
View File
@@ -1,10 +1,10 @@
// Команда bj-lk — Личный кабинет клиента (отдельный модуль).
// Команда bj-lk — Личный кабинет клиента (modules/lk), отдельный модуль, обновляется независимо.
//
// Ставится на любой веб-сервер клиента. Сам к базам данных НЕ подключается —
// работает только с bj-server по HTTP API с ключом доступа. Клиент входит по
// логину/паролю, видит свои бумаги и подаёт заявку на перевод другому брокеру.
//
// Настройка — через переменные окружения или флаги:
// Настройка — через переменные окружения (служба: /etc/bj/lk.env) или флаги:
//
// BJ_URL адрес bj-server (напр. https://bj.example.ru)
// BJ_KEY ключ доступа модуля (выдаётся в админке bj-server)
@@ -13,435 +13,45 @@
package main
import (
"bytes"
"context"
"encoding/json"
"flag"
"fmt"
"html/template"
"log"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/modules/lk"
)
// cookieTTL — срок жизни кук кабинета. Должен быть не меньше срока сессии на
// bj-server (12ч), иначе браузер забудет токен раньше, чем тот протухнет, и
// клиент увидит «сессия истекла». Кука продлевается при каждом запросе.
const cookieTTL = 12 * 3600
type config struct {
BJURL string
BJKey string
Listen string
Title string
}
func loadConfig() config {
c := config{
BJURL: os.Getenv("BJ_URL"),
BJKey: os.Getenv("BJ_KEY"),
Listen: os.Getenv("LISTEN"),
Title: os.Getenv("LK_TITLE"),
}
flag.StringVar(&c.BJURL, "bj-url", c.BJURL, "адрес bj-server (https://...)")
flag.StringVar(&c.BJKey, "bj-key", c.BJKey, "ключ доступа модуля")
flag.StringVar(&c.Listen, "listen", c.Listen, "адрес прослушивания (:8081)")
flag.StringVar(&c.Title, "title", c.Title, "заголовок кабинета")
flag.Parse()
if c.Listen == "" {
c.Listen = ":8081"
}
if c.Title == "" {
c.Title = "Личный кабинет"
}
c.BJURL = strings.TrimRight(c.BJURL, "/")
return c
}
// ---- клиент к bj-server ----
type bjClient struct {
cfg config
hc *http.Client
}
func (b *bjClient) call(ctx context.Context, method, path, session string, body any, out any) error {
var rdr *bytes.Reader
if body != nil {
raw, _ := json.Marshal(body)
rdr = bytes.NewReader(raw)
} else {
rdr = bytes.NewReader(nil)
}
req, err := http.NewRequestWithContext(ctx, method, b.cfg.BJURL+path, rdr)
if err != nil {
return err
}
req.Header.Set("X-BJ-Key", b.cfg.BJKey)
if session != "" {
req.Header.Set("X-BJ-Session", session)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := b.hc.Do(req)
if err != nil {
return fmt.Errorf("нет связи с сервером: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
var e struct{ Error string }
_ = json.NewDecoder(resp.Body).Decode(&e)
if e.Error == "" {
e.Error = fmt.Sprintf("ошибка сервера (%d)", resp.StatusCode)
}
return fmt.Errorf("%s", e.Error)
}
if out != nil {
return json.NewDecoder(resp.Body).Decode(out)
}
return nil
}
// ---- модели ответов ----
type position struct {
ISIN string `json:"isin"`
SecurityCode string `json:"security_code"`
SecurityName string `json:"security_name"`
Quantity float64 `json:"quantity"`
Blocked float64 `json:"blocked"`
Available float64 `json:"available"`
UpdatedAt string `json:"updated_at"`
Transferable bool `json:"transferable"`
Note string `json:"note"`
}
type claimRow struct {
ID int64 `json:"id"`
GUID string `json:"guid"`
StatusRu string `json:"status_ru"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
Securit int `json:"securities"`
Reason string `json:"reason"`
}
// version — версия модуля (go build -ldflags "-X main.version=…").
var version = "0.1.0"
func main() {
cfg := loadConfig()
if cfg.BJURL == "" || cfg.BJKey == "" {
log.Fatal("не заданы BJ_URL и BJ_KEY — укажите адрес bj-server и ключ доступа (см. инструкцию)")
cfg := lk.Config{BJURL: os.Getenv("BJ_URL"), BJKey: os.Getenv("BJ_KEY"), Title: os.Getenv("LK_TITLE")}
listen := os.Getenv("LISTEN")
flag.StringVar(&cfg.BJURL, "bj-url", cfg.BJURL, "адрес bj-server (https://...)")
flag.StringVar(&cfg.BJKey, "bj-key", cfg.BJKey, "ключ доступа модуля")
flag.StringVar(&listen, "listen", listen, "адрес прослушивания (:8081)")
flag.StringVar(&cfg.Title, "title", cfg.Title, "заголовок кабинета")
showVersion := flag.Bool("version", false, "показать версию")
flag.Parse()
if *showVersion {
fmt.Println("bj-lk", version)
return
}
bj := &bjClient{cfg: cfg, hc: &http.Client{Timeout: 20 * time.Second}}
tpl := template.Must(template.New("t").Funcs(template.FuncMap{
"fmtnum": func(f float64) string {
if f == float64(int64(f)) {
return strconv.FormatInt(int64(f), 10)
}
return strconv.FormatFloat(f, 'f', -1, 64)
},
"shortid": func(s string) string {
if len(s) > 8 {
return s[:8] + "…"
}
return s
},
}).Parse(pageTemplates))
mux := http.NewServeMux()
// признаки режима (админ / просмотр кабинета клиента) — из кук
var curReq *http.Request
render := func(w http.ResponseWriter, name string, data map[string]any) {
if data == nil {
data = map[string]any{}
}
data["Title"] = cfg.Title
if _, ok := data["Nav"]; !ok {
data["Nav"] = ""
}
if curReq != nil {
if _, ok := data["IsAdmin"]; !ok {
if c, err := curReq.Cookie("bj_lk_admin"); err == nil && c.Value == "1" {
data["IsAdmin"] = true
}
}
if c, err := curReq.Cookie("bj_lk_as"); err == nil && c.Value != "" {
data["AsClient"] = c.Value
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tpl.ExecuteTemplate(w, name, data); err != nil {
log.Printf("шаблон %s: %v", name, err)
}
if listen == "" {
listen = ":8081"
}
setFlag := func(w http.ResponseWriter, name, val string) {
age := cookieTTL
if val == "" {
age = -1
}
http.SetCookie(w, &http.Cookie{Name: name, Value: val, Path: "/", MaxAge: age, SameSite: http.SameSiteLaxMode})
srv, err := lk.New(cfg)
if err != nil {
log.Fatalf("bj-lk: %v", err)
}
// sessionW — как session, но заодно продлевает куку, чтобы у активного
// клиента кабинет не сбрасывался на середине работы.
sessionW := func(w http.ResponseWriter, r *http.Request) string {
curReq = r
c, err := r.Cookie("bj_lk")
if err != nil || c.Value == "" {
return ""
}
http.SetCookie(w, &http.Cookie{Name: "bj_lk", Value: c.Value, Path: "/",
HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: cookieTTL})
return c.Value
if !srv.Configured() {
log.Printf("bj-lk: не заданы BJ_URL и BJ_KEY — кабинет показывает страницу настройки (см. /etc/bj/lk.env)")
}
session := func(r *http.Request) string {
curReq = r
c, err := r.Cookie("bj_lk")
if err != nil {
return ""
}
return c.Value
}
// Вход
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
render(w, "login", map[string]any{"Flash": r.URL.Query().Get("e")})
return
}
_ = r.ParseForm()
var out struct {
Session string `json:"session"`
FullName string `json:"full_name"`
Contract string `json:"contract"`
IsAdmin bool `json:"is_admin"`
}
err := bj.call(r.Context(), http.MethodPost, "/api/lk/login", "", map[string]string{
"login": r.FormValue("login"), "password": r.FormValue("password"),
}, &out)
if err != nil {
render(w, "login", map[string]any{"Flash": err.Error()})
return
}
http.SetCookie(w, &http.Cookie{Name: "bj_lk", Value: out.Session, Path: "/",
HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: cookieTTL})
if out.IsAdmin {
setFlag(w, "bj_lk_admin", "1")
http.Redirect(w, r, "/clients", http.StatusSeeOther)
return
}
setFlag(w, "bj_lk_admin", "")
http.Redirect(w, r, "/", http.StatusSeeOther)
})
mux.HandleFunc("/logout", func(w http.ResponseWriter, r *http.Request) {
_ = bj.call(r.Context(), http.MethodPost, "/api/lk/logout", session(r), nil, nil)
http.SetCookie(w, &http.Cookie{Name: "bj_lk", Value: "", Path: "/", MaxAge: -1})
http.Redirect(w, r, "/login", http.StatusSeeOther)
})
// Портфель
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
s := sessionW(w, r)
if s == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
var out struct {
Contract string `json:"contract"`
FullName string `json:"full_name"`
Positions []position `json:"positions"`
Source string `json:"source"`
ReadOnly bool `json:"read_only"`
}
if err := bj.call(r.Context(), http.MethodGet, "/api/lk/portfolio", s, nil, &out); err != nil {
render(w, "portfolio", map[string]any{"Nav": "p", "Error": err.Error()})
return
}
render(w, "portfolio", map[string]any{
"Nav": "p", "Contract": out.Contract, "FullName": out.FullName,
"Positions": out.Positions, "Flash": r.URL.Query().Get("m"),
"Source": out.Source, "ReadOnly": out.ReadOnly,
})
})
// Заявка на перевод
mux.HandleFunc("/transfer", func(w http.ResponseWriter, r *http.Request) {
s := sessionW(w, r)
if s == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if r.Method == http.MethodGet {
var out struct {
Positions []position `json:"positions"`
FullName string `json:"full_name"`
}
if err := bj.call(r.Context(), http.MethodGet, "/api/lk/portfolio", s, nil, &out); err != nil {
render(w, "transfer", map[string]any{"Nav": "t", "Error": err.Error()})
return
}
render(w, "transfer", map[string]any{"Nav": "t", "Positions": out.Positions, "FullName": out.FullName})
return
}
_ = r.ParseForm()
var secs []map[string]any
for _, isin := range r.Form["isin"] {
q := strings.TrimSpace(r.FormValue("qty_" + isin))
if q == "" || q == "0" {
continue
}
f, err := strconv.ParseFloat(strings.ReplaceAll(q, ",", "."), 64)
if err != nil || f <= 0 {
continue
}
secs = append(secs, map[string]any{"isin": isin, "quantity": f})
}
if len(secs) == 0 {
render(w, "transfer", map[string]any{"Nav": "t", "Error": "Укажите количество хотя бы по одной бумаге"})
return
}
var res struct {
Message string `json:"message"`
GUID string `json:"guid"`
}
err := bj.call(r.Context(), http.MethodPost, "/api/lk/transfer", s, map[string]any{
"receiving_inn": strings.TrimSpace(r.FormValue("receiving_inn")),
"receiving_name": strings.TrimSpace(r.FormValue("receiving_name")),
"account_at_recv": strings.TrimSpace(r.FormValue("account_at_recv")),
"comment": strings.TrimSpace(r.FormValue("comment")),
"securities": secs,
}, &res)
if err != nil {
render(w, "transfer", map[string]any{"Nav": "t", "Error": err.Error()})
return
}
http.Redirect(w, r, "/claims?m="+res.Message, http.StatusSeeOther)
})
// Мои заявки
mux.HandleFunc("/claims", func(w http.ResponseWriter, r *http.Request) {
s := sessionW(w, r)
if s == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
var out struct {
Claims []claimRow `json:"claims"`
}
if err := bj.call(r.Context(), http.MethodGet, "/api/lk/claims", s, nil, &out); err != nil {
render(w, "claims", map[string]any{"Nav": "c", "Error": err.Error()})
return
}
render(w, "claims", map[string]any{"Nav": "c", "Claims": out.Claims, "Flash": r.URL.Query().Get("m")})
})
// Администратор: список клиентов
mux.HandleFunc("/clients", func(w http.ResponseWriter, r *http.Request) {
s := sessionW(w, r)
if s == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
var out struct {
Clients []struct {
Login string `json:"login"`
Contract string `json:"contract_number"`
FullName string `json:"full_name"`
Email string `json:"email"`
Active bool `json:"active"`
IsAdmin bool `json:"is_admin"`
} `json:"clients"`
Depo []struct {
Contract string `json:"contract"`
Name string `json:"name"`
Securities int `json:"securities"`
} `json:"depo"`
DepoError string `json:"depo_error"`
}
search := strings.TrimSpace(r.URL.Query().Get("search"))
path := "/api/lk/admin/clients"
if search != "" {
path += "?search=" + url.QueryEscape(search)
}
if err := bj.call(r.Context(), http.MethodGet, path, s, nil, &out); err != nil {
render(w, "clients", map[string]any{"Nav": "a", "IsAdmin": true, "Error": err.Error()})
return
}
render(w, "clients", map[string]any{"Nav": "a", "IsAdmin": true, "Clients": out.Clients,
"Depo": out.Depo, "DepoError": out.DepoError, "Search": search,
"Flash": r.URL.Query().Get("m")})
})
// Администратор: войти в кабинет клиента
mux.HandleFunc("/impersonate", func(w http.ResponseWriter, r *http.Request) {
s := sessionW(w, r)
if s == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
login := r.FormValue("login")
if login == "" {
login = r.URL.Query().Get("login")
}
contract := r.FormValue("contract")
if contract == "" {
contract = r.URL.Query().Get("contract")
}
var out struct {
Session string `json:"session"`
AsClient string `json:"as_client"`
}
if err := bj.call(r.Context(), http.MethodPost, "/api/lk/admin/impersonate", s,
map[string]string{"login": login, "contract": contract}, &out); err != nil {
http.Redirect(w, r, "/clients?m="+err.Error(), http.StatusSeeOther)
return
}
// сохраняем админскую сессию, чтобы можно было вернуться
if c, err := r.Cookie("bj_lk"); err == nil {
setFlag(w, "bj_lk_admin_sess", c.Value)
}
http.SetCookie(w, &http.Cookie{Name: "bj_lk", Value: out.Session, Path: "/",
HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: cookieTTL})
setFlag(w, "bj_lk_as", out.AsClient)
http.Redirect(w, r, "/", http.StatusSeeOther)
})
// Администратор: вернуться из кабинета клиента
mux.HandleFunc("/back-to-admin", func(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie("bj_lk_admin_sess"); err == nil && c.Value != "" {
http.SetCookie(w, &http.Cookie{Name: "bj_lk", Value: c.Value, Path: "/",
HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: cookieTTL})
}
setFlag(w, "bj_lk_as", "")
setFlag(w, "bj_lk_admin_sess", "")
http.Redirect(w, r, "/clients", http.StatusSeeOther)
})
// Проверка связи с bj-server (для админа при установке)
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
var out map[string]any
err := bj.call(r.Context(), http.MethodGet, "/api/lk/ping", "", nil, &out)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if err != nil {
w.WriteHeader(http.StatusBadGateway)
_ = json.NewEncoder(w).Encode(map[string]any{"ok": false, "error": err.Error(), "bj_url": cfg.BJURL})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "bj": out, "bj_url": cfg.BJURL})
})
log.Printf("bj-lk (Личный кабинет) слушает %s, bj-server: %s", cfg.Listen, cfg.BJURL)
srv := &http.Server{Addr: cfg.Listen, Handler: mux, ReadHeaderTimeout: 5 * time.Second}
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Printf("bj-lk %s (Личный кабинет) слушает %s, bj-server: %s", version, listen, cfg.BJURL)
hs := &http.Server{Addr: listen, Handler: srv.Handler(), ReadHeaderTimeout: 5 * time.Second}
if err := hs.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
_ = context.Background
}
-311
View File
@@ -1,311 +0,0 @@
package main
// pageTemplates — встроенные страницы Личного кабинета (без внешних зависимостей,
// чтобы модуль был одним файлом и ставился на любой веб-сервер).
const pageTemplates = `
{{define "head"}}<!DOCTYPE html><html lang="ru"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{.Title}}</title>
<style>
:root{--bg:#f6f7f9;--card:#fff;--bd:#e3e6ec;--tx:#1b2029;--mut:#5b6472;--acc:#2563eb;--ok:#15803d;--err:#c22a2a;
--bd-strong:#c9cfda;--bg-2:#eef0f4;
--bg-neu:rgba(37,99,235,.12);--tx-neu:#1d4ed8;--bg-ok:rgba(21,128,61,.14);--tx-ok:#15803d;--bg-err:rgba(194,42,42,.12);--tx-err:#b91c1c;--bg-warn:rgba(180,120,0,.16);--tx-warn:#a15c07}
@media(prefers-color-scheme:dark){:root{--bg:#0f1116;--card:#181b22;--bd:#2a2f3a;--tx:#e8eaed;--mut:#a2acbd;--acc:#7ba7ff;--ok:#5bd085;--err:#ff8f8f;
--bd-strong:#3a4150;--bg-2:#20242e;
--bg-neu:rgba(123,167,255,.16);--tx-neu:#93c5fd;--bg-ok:rgba(91,208,133,.18);--tx-ok:#86efac;--bg-err:rgba(255,143,143,.18);--tx-err:#fca5a5;--bg-warn:rgba(245,180,80,.18);--tx-warn:#fcd34d}}
*{box-sizing:border-box}body{margin:0;font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
background:var(--bg);color:var(--tx);line-height:1.5}
.top{background:var(--card);border-bottom:1px solid var(--bd);padding:0 20px;height:56px;display:flex;align-items:center;gap:8px;position:sticky;top:0;z-index:5;overflow-x:auto}
.brand{font-weight:700;font-size:16px;white-space:nowrap;margin-right:8px}.top a{color:var(--mut);text-decoration:none;font-size:14px;padding:8px 10px;border-radius:7px;white-space:nowrap}
.top a:hover{background:var(--bg);color:var(--tx)}.top a.on{background:var(--bg-neu);color:var(--tx-neu)}
.top .right{margin-left:auto;display:flex;align-items:center;gap:12px;font-size:13px;color:var(--mut);white-space:nowrap}
main{max-width:980px;margin:0 auto;padding:22px 20px}
h1{font-size:22px;margin:0 0 14px}h2{font-size:16px;margin:0 0 12px}
.card{background:var(--card);border:1px solid var(--bd);border-radius:12px;padding:18px 20px;margin-bottom:16px}
.tw{overflow-x:auto;-webkit-overflow-scrolling:touch;margin:0 -4px}
table{width:100%;border-collapse:collapse;font-size:14px}th,td{text-align:left;padding:10px 12px;border-bottom:1px solid var(--bd);white-space:nowrap}
th{font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--mut);font-weight:600}
.num{text-align:right;font-variant-numeric:tabular-nums}
code{font-size:12px;background:var(--bg-2);padding:1px 5px;border-radius:4px}
input,select,textarea{padding:10px 12px;border:1px solid var(--bd-strong);border-radius:8px;background:var(--card);color:var(--tx);font:inherit;font-size:16px;width:100%}
input:focus,select:focus,textarea:focus{outline:2px solid var(--acc);outline-offset:1px;border-color:var(--acc)}
label{font-size:13px;font-weight:500;display:block;margin-bottom:4px}
.btn{background:var(--acc);color:#fff;border:0;border-radius:8px;padding:11px 18px;font:inherit;font-size:14px;font-weight:600;cursor:pointer;text-decoration:none;display:inline-block;min-height:44px;line-height:22px}
.btn:hover{filter:brightness(1.06)}.btn.sec{background:var(--card);color:var(--tx);border:1px solid var(--bd-strong)}
.btn.sm{min-height:0;padding:6px 10px;font-size:12px}
.msg{padding:12px 14px;border-radius:9px;margin-bottom:14px;font-size:14px}
.msg.err{background:var(--bg-err);color:var(--tx-err)}.msg.ok{background:var(--bg-ok);color:var(--tx-ok)}
.mut{color:var(--mut);font-size:13px}
.badge{display:inline-block;padding:3px 10px;border-radius:20px;font-size:12px;font-weight:700;background:var(--bg-neu);color:var(--tx-neu)}
.badge.ok{background:var(--bg-ok);color:var(--tx-ok)}.badge.err{background:var(--bg-err);color:var(--tx-err)}.badge.warn{background:var(--bg-warn);color:var(--tx-warn)}
.login-wrap{max-width:380px;margin:8vh auto;padding:0 20px}
.ovl{position:fixed;inset:0;background:rgba(10,12,18,.55);display:none;align-items:center;justify-content:center;padding:16px;z-index:20}
.ovl.on{display:flex}
.modal{background:var(--card);border:1px solid var(--bd);border-radius:14px;max-width:460px;width:100%;padding:22px;max-height:90vh;overflow:auto}
.modal h2{margin-top:0}
.sumrow{display:flex;justify-content:space-between;gap:12px;padding:8px 0;border-bottom:1px solid var(--bd);font-size:14px}
.warnbox{background:var(--bg-warn);color:var(--tx-warn);border-radius:9px;padding:11px 13px;font-size:13px;font-weight:500;margin:14px 0}
@media(max-width:600px){main{padding:16px 14px}.card{padding:15px 15px}h1{font-size:20px}
input,select,textarea{font-size:16px}}
</style></head><body>{{end}}
{{define "nav"}}<div class="top"><span class="brand">{{.Title}}</span>
{{if .IsAdmin}}<a href="/clients" class="{{if eq .Nav "a"}}on{{end}}">Клиенты</a>{{end}}
<a href="/" class="{{if eq .Nav "p"}}on{{end}}">Мои бумаги</a>
<a href="/transfer" class="{{if eq .Nav "t"}}on{{end}}">Перевести бумаги</a>
<a href="/claims" class="{{if eq .Nav "c"}}on{{end}}">Мои заявки</a>
<span class="right">{{with .FullName}}{{.}}{{end}}{{with .Contract}} · договор {{.}}{{end}} <a href="/logout">Выйти</a></span>
</div>
{{if .AsClient}}<div style="background:#fff4d6;border-bottom:1px solid #e8c877;padding:9px 20px;font-size:13px">
👤 Вы просматриваете кабинет клиента <b>{{.AsClient}}</b> от имени администратора ·
<a href="/back-to-admin">вернуться к списку клиентов</a></div>{{end}}{{end}}
{{define "login"}}{{template "head" .}}
<div class="login-wrap">
<div class="card">
<h1>{{.Title}}</h1>
<p class="mut">Войдите, чтобы посмотреть свои ценные бумаги и подать заявку на перевод к другому брокеру.</p>
{{with .Flash}}<div class="msg err">{{.}}</div>{{end}}
<form method="post" action="/login" style="display:grid;gap:12px;margin-top:14px">
<div><label for="f-login">Логин</label><input id="f-login" name="login" autocomplete="username" required autofocus></div>
<div><label for="f-pass">Пароль</label><input id="f-pass" name="password" type="password" autocomplete="current-password" required></div>
<button class="btn" type="submit" style="width:100%">Войти</button>
</form>
<p class="mut" style="margin-top:14px">🔒 Соединение защищено. Мы не запрашиваем пароль по телефону или в письмах.</p>
</div>
<p class="mut" style="text-align:center">Логин и пароль выдаёт ваш брокер. Не можете войти — обратитесь в поддержку брокера.</p>
</div>
</body></html>{{end}}
{{define "portfolio"}}{{template "head" .}}{{template "nav" .}}
<main>
<h1>Мои ценные бумаги</h1>
{{if .Source}}<p class="mut">Источник данных: {{.Source}}{{if .Contract}} · договор {{.Contract}}{{end}}</p>{{end}}
{{with .Flash}}<div class="msg ok">{{.}}</div>{{end}}
{{with .Error}}<div class="msg err">{{.}}</div>{{end}}
<div class="card">
{{if .Positions}}
<div class="tw"><table>
<thead><tr><th>Бумага</th><th>ISIN</th><th class="num">Всего</th><th class="num">Заблокировано</th><th class="num">Доступно к переводу</th></tr></thead>
<tbody>
{{range .Positions}}
<tr>
<td>{{if .SecurityName}}{{.SecurityName}}{{else}}{{.SecurityCode}}{{end}}</td>
<td><code>{{.ISIN}}</code></td>
<td class="num">{{fmtnum .Quantity}}</td>
<td class="num">{{if .Blocked}}{{fmtnum .Blocked}}{{else}}—{{end}}</td>
<td class="num">{{if .Transferable}}<b>{{fmtnum .Available}}</b>{{else}}<span class="mut" title="{{.Note}}">не переводится</span>{{end}}</td>
</tr>
{{end}}
</tbody>
</table></div>
<p class="mut" style="margin-top:12px">Через сервис MOEX МОСТ переводятся только российские ценные бумаги (ISIN начинается с RU). Иностранные бумаги показаны для полноты портфеля, но заявку по ним подать нельзя.</p>
{{if .ReadOnly}}
<p class="mut" style="margin-top:16px">📋 Данные загружены из депозитарного учёта. Подача заявок на перевод для этого договора пока недоступна — раздел работает в режиме просмотра.</p>
{{else}}
<p style="margin-top:16px"><a class="btn" href="/transfer">Перевести бумаги другому брокеру</a></p>
{{end}}
{{else}}
<p class="mut">Бумаг на счёте не найдено. Если вы считаете, что это ошибка — обратитесь к вашему брокеру.</p>
{{end}}
</div>
</main></body></html>{{end}}
{{define "transfer"}}{{template "head" .}}{{template "nav" .}}
<main>
<h1>Перевод бумаг другому брокеру</h1>
{{with .Error}}<div class="msg err">{{.}}</div>{{end}}
<form method="post" action="/transfer" id="tform">
<div class="card">
<h2>Куда переводим</h2>
<p class="mut">Укажите депозитарий брокера, к которому переводите бумаги. Перевод оформляется на вас же — как перевод «самому себе» в другой депозитарий.</p>
<div style="display:grid;gap:12px;max-width:520px;margin-top:12px">
<div><label for="rinn">ИНН депозитария-получателя *</label>
<input id="rinn" name="receiving_inn" required inputmode="numeric" pattern="\d{{"{"}}10{{"}"}}|\d{{"{"}}12{{"}"}}" maxlength="12" placeholder="7722061076"
title="ИНН организации — 10 цифр">
<p class="mut" style="margin-top:4px">ИНН депозитария вашего нового брокера — уточните его у брокера-получателя.</p></div>
<div><label for="rname">Наименование брокера/депозитария</label><input id="rname" name="receiving_name" placeholder="например, ООО «Брокер»"></div>
<div><label for="racc">Ваш счёт депо у нового брокера (если известен)</label><input id="racc" name="account_at_recv" placeholder="необязательно"></div>
</div>
</div>
<div class="card">
<h2>Что переводим</h2>
{{if .Positions}}
<div class="tw"><table>
<thead><tr><th>Бумага</th><th>ISIN</th><th class="num">Доступно</th><th style="min-width:190px">Сколько перевести</th></tr></thead>
<tbody>
{{range .Positions}}
<tr>
<td>{{if .SecurityName}}{{.SecurityName}}{{else}}{{.SecurityCode}}{{end}}</td>
<td><code>{{.ISIN}}</code></td>
<td class="num">{{if .Transferable}}{{fmtnum .Available}}{{else}}—{{end}}</td>
<td>{{if .Transferable}}<input type="hidden" name="isin" value="{{.ISIN}}">
<div style="display:flex;gap:6px;align-items:center">
<input class="qty" name="qty_{{.ISIN}}" type="number" min="0" step="any" max="{{printf "%g" .Available}}"
inputmode="decimal" placeholder="0" style="max-width:120px"
aria-label="Сколько перевести: {{if .SecurityName}}{{.SecurityName}}{{else}}{{.ISIN}}{{end}}"
data-name="{{if .SecurityName}}{{.SecurityName}}{{else}}{{.ISIN}}{{end}}" data-isin="{{.ISIN}}" data-avail="{{printf "%g" .Available}}"
{{if not .Available}}disabled{{end}}>
<button type="button" class="btn sec sm qall">всё</button>
</div>
{{else}}<span class="mut">{{.Note}}</span>{{end}}</td>
</tr>
{{end}}
</tbody>
</table></div>
<div style="margin-top:14px;max-width:520px"><label for="cmt">Комментарий (необязательно)</label><textarea id="cmt" name="comment" rows="2"></textarea></div>
<div style="margin-top:16px;display:flex;gap:10px;flex-wrap:wrap">
<button class="btn" type="submit">Проверить и подать</button>
<a class="btn sec" href="/">Отмена</a>
</div>
<p class="mut" style="margin-top:12px">Перед подачей вы увидите сводку для проверки. После подачи заявка проходит проверку у брокера, затем направляется в НРД. Статус виден в разделе «Мои заявки».</p>
{{else}}<p class="mut">Нет доступных бумаг для перевода.</p>{{end}}
</div>
</form>
<div class="ovl" id="cov" role="dialog" aria-modal="true" aria-labelledby="ctitle">
<div class="modal">
<h2 id="ctitle">Проверьте заявку на перевод</h2>
<div class="sumrow"><span class="mut">Депозитарий-получатель</span><span id="c-recv" style="text-align:right"></span></div>
<div id="c-secs" style="margin-top:4px"></div>
<div class="warnbox">⚠️ После подачи заявку нельзя отменить или изменить. Бумаги переводятся другому брокеру. Проверьте получателя и количество.</div>
<div style="display:flex;gap:10px;flex-wrap:wrap">
<button class="btn" id="cyes" type="button">Подтверждаю, подать</button>
<button class="btn sec" id="cno" type="button">Вернуться</button>
</div>
</div>
</div>
<script>
(function(){
var f=document.getElementById('tform'); if(!f) return;
var ov=document.getElementById('cov');
// «всё» — заполнить доступным количеством
f.querySelectorAll('.qall').forEach(function(b){
b.addEventListener('click',function(){
var q=b.parentNode.querySelector('.qty'); if(q){q.value=q.getAttribute('data-avail'); q.focus();}
});
});
function fmt(n){ n=Number(n); return (n%1===0)? String(n): String(n); }
f.addEventListener('submit',function(e){
e.preventDefault();
// собрать выбранные бумаги
var rows=[], bad=null;
f.querySelectorAll('.qty').forEach(function(q){
var v=parseFloat((q.value||'').replace(',','.'));
if(!q.value||isNaN(v)||v<=0) return;
var av=parseFloat(q.getAttribute('data-avail'));
if(v>av){ bad=q.getAttribute('data-name')+': запрошено '+fmt(v)+', доступно '+fmt(av); }
rows.push({name:q.getAttribute('data-name'), isin:q.getAttribute('data-isin'), qty:v});
});
var inn=(f.receiving_inn.value||'').trim();
if(!/^(\d{10}|\d{12})$/.test(inn)){ f.receiving_inn.focus(); alert('Проверьте ИНН депозитария-получателя: должно быть 10 цифр.'); return; }
if(rows.length===0){ alert('Укажите количество хотя бы по одной бумаге.'); return; }
if(bad){ alert('Превышен доступный остаток. '+bad); return; }
// заполнить сводку
var rn=(f.receiving_name.value||'').trim();
document.getElementById('c-recv').innerHTML='<b>ИНН '+inn+'</b>'+(rn?('<br><span class="mut">'+rn+'</span>'):'');
var h=''; rows.forEach(function(r){
h+='<div class="sumrow"><span>'+r.name+' <span class="mut">('+r.isin+')</span></span><b>'+fmt(r.qty)+' шт</b></div>';
});
document.getElementById('c-secs').innerHTML=h;
ov.classList.add('on');
});
document.getElementById('cno').addEventListener('click',function(){ ov.classList.remove('on'); });
ov.addEventListener('click',function(e){ if(e.target===ov) ov.classList.remove('on'); });
document.getElementById('cyes').addEventListener('click',function(){
var b=this; b.disabled=true; b.textContent='Отправляем…'; f.submit();
});
})();
</script>
</main></body></html>{{end}}
{{define "claims"}}{{template "head" .}}{{template "nav" .}}
<main>
<h1>Мои заявки</h1>
{{with .Flash}}<div class="msg ok">{{.}}</div>{{end}}
{{with .Error}}<div class="msg err">{{.}}</div>{{end}}
<div class="card">
{{if .Claims}}
<div class="tw"><table>
<thead><tr><th>Дата</th><th>Номер</th><th class="num">Бумаг</th><th>Статус</th><th>Примечание</th></tr></thead>
<tbody>
{{range .Claims}}
<tr>
<td>{{.CreatedAt}}</td>
<td><code title="{{.GUID}}">{{shortid .GUID}}</code></td>
<td class="num">{{.Securit}}</td>
<td>{{if or (eq .Status "rejected") (eq .Status "declined") (eq .Status "error")}}<span class="badge err">{{.StatusRu}}</span>
{{else if eq .Status "done"}}<span class="badge ok">{{.StatusRu}}</span>
{{else}}<span class="badge">{{.StatusRu}}</span>{{end}}</td>
<td class="mut" style="white-space:normal;min-width:180px">{{.Reason}}</td>
</tr>
{{end}}
</tbody>
</table></div>
<p class="mut" style="margin-top:12px">Наведите на номер, чтобы увидеть полный идентификатор заявки. Он понадобится при обращении в поддержку.</p>
{{else}}
<p class="mut">Заявок пока нет. <a href="/transfer">Подать заявку на перевод</a></p>
{{end}}
</div>
</main></body></html>{{end}}
{{define "clients"}}{{template "head" .}}{{template "nav" .}}
<main>
<h1>Клиенты</h1>
<p class="mut">Список клиентов личного кабинета. Можно открыть кабинет любого клиента, чтобы посмотреть его бумаги и заявки его глазами.</p>
{{with .Flash}}<div class="msg err">{{.}}</div>{{end}}
{{with .Error}}<div class="msg err">{{.}}</div>{{end}}
<div class="card">
{{if .Clients}}
<div class="tw"><table>
<thead><tr><th>Клиент</th><th>Логин</th><th>Договор</th><th>E-mail</th><th>Статус</th><th></th></tr></thead>
<tbody>
{{range .Clients}}
<tr>
<td>{{if .FullName}}{{.FullName}}{{else}}—{{end}}{{if .IsAdmin}} <span class="badge">админ</span>{{end}}</td>
<td><code>{{.Login}}</code></td>
<td>{{.Contract}}</td>
<td class="mut">{{.Email}}</td>
<td>{{if .Active}}<span class="badge ok">активен</span>{{else}}<span class="badge err">заблокирован</span>{{end}}</td>
<td>{{if not .IsAdmin}}<a class="btn sec" href="/impersonate?login={{.Login}}">Открыть кабинет</a>{{end}}</td>
</tr>
{{end}}
</tbody>
</table></div>
{{else if not .Error}}
<p class="mut">Клиентов пока нет.</p>
{{end}}
</div>
<h2>Клиенты депозитария</h2>
<p class="mut">Реальные договоры из депозитарного учёта. Учётная запись в кабинете им не нужна —
администратор открывает кабинет по договору в режиме просмотра.</p>
{{with .DepoError}}<div class="msg err">Депозитарий недоступен: {{.}}</div>{{end}}
<div class="card">
<form method="get" action="/clients" class="row" style="margin-bottom:12px">
<input type="text" name="search" value="{{.Search}}" placeholder="номер договора или ФИО, например БР-1001 или Гравс" style="flex:1">
<button class="btn" type="submit">Найти</button>
{{if .Search}}<a class="btn sec" href="/clients">Сбросить</a>{{end}}
</form>
{{if .Depo}}
<div class="tw"><table>
<thead><tr><th>Клиент</th><th>Договор</th><th>Бумаг</th><th></th></tr></thead>
<tbody>
{{range .Depo}}
<tr>
<td>{{if .Name}}{{.Name}}{{else}}—{{end}}</td>
<td><code>{{.Contract}}</code></td>
<td>{{.Securities}}</td>
<td><a class="btn sec" href="/impersonate?contract={{.Contract}}">Открыть кабинет</a></td>
</tr>
{{end}}
</tbody>
</table></div>
<p class="mut">Показаны первые 100 договоров с ненулевыми остатками. Уточните поиск, чтобы найти нужного клиента.</p>
{{else if not .DepoError}}
<p class="mut">По запросу «{{.Search}}» договоров с бумагами не найдено.</p>
{{end}}
</div>
</main></body></html>{{end}}
`
+44
View File
@@ -0,0 +1,44 @@
// Command bj-npr — служба модуля «НПР / УДС» (modules/npr), обновляется независимо от ядра bj.
//
// Слушает только локальный адрес: пользователи работают через bj-server, который проверяет вход,
// права и лицензию и проксирует раздел /admin/npr со служебным ключом.
//
// bj-npr [-addr 127.0.0.1:8083] [-setup /var/lib/bj/.bj/setup.json] [-key /var/lib/bj/.bj/module.key]
// bj-npr -version
package main
import (
"flag"
"fmt"
"log"
"net/http"
"os"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/modules/npr"
)
// version — версия модуля (go build -ldflags "-X main.version=…").
var version = "0.1.0"
func main() {
addr := flag.String("addr", "127.0.0.1:8083", "адрес службы (только локальный)")
setup := flag.String("setup", "/var/lib/bj/.bj/setup.json", "настройки bj (раздел npr)")
key := flag.String("key", "/var/lib/bj/.bj/module.key", "служебный ключ модулей (создаёт bj-server)")
showVersion := flag.Bool("version", false, "показать версию")
flag.Parse()
if *showVersion {
fmt.Println("bj-npr", version)
return
}
srv, err := npr.New(npr.Config{SetupPath: *setup, KeyPath: *key})
if err != nil {
log.Fatalf("bj-npr: %v", err)
}
log.Printf("bj-npr %s: слушаю %s", version, *addr)
hs := &http.Server{Addr: *addr, Handler: srv.Handler(), ReadHeaderTimeout: 10 * time.Second}
if err := hs.ListenAndServe(); err != nil {
log.Printf("bj-npr: %v", err)
os.Exit(1)
}
}
+49
View File
@@ -0,0 +1,49 @@
// Command bj-quikexport — служба модуля «Экспорт биржевой информации» (modules/quikexport):
// управление ARQA quikexport, обновляется независимо от ядра bj.
//
// Слушает только локальный адрес: раздел настроек показывает bj-server, который проверяет вход,
// права и лицензию и передаёт запросы со служебным ключом. Привилегированные операции
// (установка файлов в каталог экспортёра, служба, база) идут через root-воркер bj-keymedia-worker.
//
// bj-quikexport [-addr 127.0.0.1:8084] [-store /var/lib/bj/.bj/quik-export/settings.json]
// [-setup /var/lib/bj/.bj/setup.json] [-key /var/lib/bj/.bj/module.key]
// bj-quikexport -version
package main
import (
"flag"
"fmt"
"log"
"net/http"
"os"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/modules/quikexport"
)
// version — версия модуля (go build -ldflags "-X main.version=…").
var version = "0.1.0"
func main() {
addr := flag.String("addr", "127.0.0.1:8084", "адрес службы (только локальный)")
store := flag.String("store", "/var/lib/bj/.bj/quik-export/settings.json", "настройки модуля")
setup := flag.String("setup", "/var/lib/bj/.bj/setup.json", "настройки bj (перенос раздела quik_export при первом запуске)")
key := flag.String("key", "/var/lib/bj/.bj/module.key", "служебный ключ модулей (создаёт bj-server)")
showVersion := flag.Bool("version", false, "показать версию")
flag.Parse()
if *showVersion {
fmt.Println("bj-quikexport", version)
return
}
srv, err := quikexport.New(quikexport.Config{StorePath: *store, SetupPath: *setup, KeyPath: *key, Version: version})
if err != nil {
log.Fatalf("bj-quikexport: %v", err)
}
log.Printf("bj-quikexport %s: слушаю %s", version, *addr)
// WriteTimeout не задаём: применение настроек ждёт root-воркер до нескольких минут.
hs := &http.Server{Addr: *addr, Handler: srv.Handler(), ReadHeaderTimeout: 10 * time.Second}
if err := hs.ListenAndServe(); err != nil {
log.Printf("bj-quikexport: %v", err)
os.Exit(1)
}
}
+17 -8
View File
@@ -48,6 +48,7 @@ func main() {
func usage() {
fmt.Fprintln(os.Stderr, "bj-release keygen -out <prefix>")
fmt.Fprintln(os.Stderr, "bj-release build -dir <artifacts> -version <v> -channel <c> -key <priv> -keyid <id> -out <manifest.json> [-notes <txt>]")
fmt.Fprintln(os.Stderr, " версия отдельного файла — в <файл>.version рядом (иначе версия релиза -version)")
os.Exit(2)
}
@@ -97,11 +98,11 @@ func build(args []string) {
// Имена артефактов, которые издаём (логическое имя → ставить +x).
known := map[string]bool{
"bj-server": true, // Go-бинарь
"crypto-service.jar": false, // Java сайдкар
"install-validata.sh": true,
"install.sh": true,
"configure-ish.sql": false,
"bj-server": true, // Go-бинарь
"crypto-service.jar": false, // Java сайдкар
"install-validata.sh": true,
"install.sh": true,
"configure-ish.sql": false,
}
entries, err := os.ReadDir(*dir)
@@ -110,7 +111,7 @@ func build(args []string) {
}
var arts []release.Artifact
for _, e := range entries {
if e.IsDir() || e.Name() == "manifest.json" {
if e.IsDir() || e.Name() == "manifest.json" || strings.HasSuffix(e.Name(), ".version") {
continue
}
full := filepath.Join(*dir, e.Name())
@@ -119,6 +120,14 @@ func build(args []string) {
fatal("hash %s: %v", e.Name(), err)
}
exec, ok := known[e.Name()]
if c, isComp := release.ComponentByName(e.Name()); isComp {
exec, ok = c.Exec, true
}
// Своя версия компонента: <файл>.version рядом с файлом (независимые обновления).
ver := *version
if vb, err := os.ReadFile(full + ".version"); err == nil && strings.TrimSpace(string(vb)) != "" {
ver = strings.TrimSpace(string(vb))
}
if !ok {
// неизвестный файл — включаем, +x по расширению
exec = strings.HasSuffix(e.Name(), ".sh")
@@ -126,7 +135,7 @@ func build(args []string) {
arts = append(arts, release.Artifact{
Name: e.Name(),
File: e.Name(),
Version: *version,
Version: ver,
SHA256: sha,
Size: size,
Exec: exec,
@@ -159,7 +168,7 @@ func build(args []string) {
fmt.Printf("Манифест %s: версия %s, канал %s, артефактов %d, подписан ключом %s\n",
*out, *version, *channel, len(arts), *keyID)
for _, a := range arts {
fmt.Printf(" %-22s %10d B %s\n", a.Name, a.Size, a.SHA256[:16])
fmt.Printf(" %-22s %-10s %10d B %s\n", a.Name, a.Version, a.Size, a.SHA256[:16])
}
}
+10
View File
@@ -0,0 +1,10 @@
// Package deploy — файлы развёртывания bj, которые ставит установщик (cmd/bj-installer):
// root-скрипты для /usr/local/sbin и unit-файлы служб systemd.
package deploy
import "embed"
// Files — linux/sbin/* (в /usr/local/sbin) и systemd/*.service (в /etc/systemd/system).
//
//go:embed linux/sbin/* systemd/bj-server.service systemd/bj-crypto.service systemd/bj-keymedia.service systemd/bj-npr.service systemd/bj-quikexport.service systemd/bj-lk.service
var Files embed.FS
+42
View File
@@ -0,0 +1,42 @@
#!/bin/bash
# Первая загрузка сервера, установленного с образа Bridge&Join: на экране сервера спрашивает ключ
# продукта и запускает bj-installer. После успешной установки служба выключается; адрес первичной
# настройки и одноразовый код остаются на экране входа (/etc/issue.d/bj-setup.issue).
set -u
MARK=/var/lib/bj/.bj/installed-from-iso
SERVER=https://lic.zetit.ru
CHANNEL=stable
[ -s /etc/bj/channel ] && CHANNEL=$(tr -d ' \n' < /etc/bj/channel)
finish() {
mkdir -p "$(dirname "$MARK")" && date -Iseconds > "$MARK"
systemctl disable bj-firstboot.service >/dev/null 2>&1
}
clear
printf '\n Bridge&Join: установка на сервер\n ────────────────────────────────────────────────\n\n'
printf ' Debian установлен. Осталось установить bj.\n'
printf ' Нужен ключ продукта (BJ-XXXX-XXXX-XXXX): он есть в портале клиента %s/portal/\n\n' "$SERVER"
for i in $(seq 1 60); do
curl -fsS -m 5 -o /dev/null "$SERVER/healthz" && break
[ "$i" = 1 ] && printf ' Жду сеть и связь с %s…\n' "$SERVER"
sleep 2
done
while true; do
read -r -p " Ключ продукта (пусто — установить позже): " CODE
CODE=$(printf '%s' "$CODE" | tr -d ' ' | tr '[:lower:]' '[:upper:]')
if [ -z "$CODE" ]; then
printf '\n Установка bj отложена. Позже выполните: sudo bj-installer install --code КЛЮЧ-ПРОДУКТА\n\n'
finish
sleep 3
exit 0
fi
if /usr/local/sbin/bj-installer install --code "$CODE" --server "$SERVER" --channel "$CHANNEL"; then
finish
read -r -p " Нажмите Enter, чтобы перейти к экрану входа. " _
exit 0
fi
printf '\n Установка не завершилась. Проверьте ключ продукта и сеть и попробуйте ещё раз.\n\n'
done
+21
View File
@@ -0,0 +1,21 @@
[Unit]
Description=Bridge&Join: установка bj при первой загрузке (ключ продукта на экране сервера)
After=network-online.target systemd-user-sessions.service
Wants=network-online.target
Before=getty@tty1.service
ConditionPathExists=!/var/lib/bj/.bj/installed-from-iso
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/bj-firstboot
StandardInput=tty
StandardOutput=tty
StandardError=tty
TTYPath=/dev/tty1
TTYReset=yes
TTYVHangup=yes
TimeoutSec=0
RemainAfterExit=no
[Install]
WantedBy=multi-user.target
+73
View File
@@ -0,0 +1,73 @@
#!/bin/sh
# Сборка образа ISO Bridge&Join на основе официального Debian 13 netinst.
#
# deploy/iso/build-iso.sh <debian-13.x-amd64-netinst.iso> <bj-installer> <версия> [канал] [выходной.iso]
#
# В образ добавляются: файл ответов установщику (preseed.cfg), bj-installer и служба первой загрузки.
# Загрузочное меню BIOS (isolinux) и UEFI (grub) по умолчанию запускает установку с этими ответами.
# Загрузочные записи исходного образа сохраняются (xorriso -boot_image any replay).
# Нужен xorriso. Проверка исходного образа — по SHA256SUMS с cdimage.debian.org.
set -eu
SRC=${1:?исходный debian-13.x-amd64-netinst.iso}
INSTALLER=${2:?файл bj-installer}
VERSION=${3:?версия образа, например 2026.10.1}
CHANNEL=${4:-stable}
OUT=${5:-bj-debian13-$VERSION.iso}
HERE=$(cd "$(dirname "$0")" && pwd)
command -v xorriso >/dev/null || { echo "нужен xorriso: apt-get install xorriso"; exit 1; }
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
mkdir -p "$WORK/bj" "$WORK/preseed" "$WORK/isolinux" "$WORK/grub"
cp "$HERE/preseed.cfg" "$WORK/preseed/bj.cfg"
install -m 0755 "$INSTALLER" "$WORK/bj/bj-installer"
install -m 0755 "$HERE/bj-firstboot" "$WORK/bj/bj-firstboot"
cp "$HERE/bj-firstboot.service" "$WORK/bj/bj-firstboot.service"
echo "$CHANNEL" > "$WORK/bj/channel"
echo "$VERSION" > "$WORK/bj/version"
BOOT="auto=true priority=high preseed/file=/cdrom/preseed/bj.cfg language=ru country=RU locale=ru_RU.UTF-8 keyboard-configuration/xkb-keymap=ru"
# BIOS: пункт по умолчанию в isolinux
xorriso -osirrox on -indev "$SRC" -extract /isolinux/txt.cfg "$WORK/isolinux/txt.orig" >/dev/null 2>&1
KERNEL=$(awk '/^\s*kernel/{print $2; exit}' "$WORK/isolinux/txt.orig")
INITRD=$(grep -o 'initrd=[^ ]*' "$WORK/isolinux/txt.orig" | head -1 | cut -d= -f2)
{
echo "default bj"
echo "label bj"
echo " menu label ^Установить Bridge&Join $VERSION (Debian 13)"
echo " menu default"
echo " kernel $KERNEL"
echo " append vga=788 initrd=$INITRD $BOOT --- quiet"
sed '/menu default/d;/^default /d' "$WORK/isolinux/txt.orig"
} > "$WORK/isolinux/txt.cfg"
xorriso -osirrox on -indev "$SRC" -extract /isolinux/isolinux.cfg "$WORK/isolinux/isolinux.cfg" >/dev/null 2>&1
sed -i 's/^timeout .*/timeout 100/; s/^prompt .*/prompt 0/' "$WORK/isolinux/isolinux.cfg"
grep -q '^timeout' "$WORK/isolinux/isolinux.cfg" || echo "timeout 100" >> "$WORK/isolinux/isolinux.cfg"
# UEFI: первый пункт в grub
xorriso -osirrox on -indev "$SRC" -extract /boot/grub/grub.cfg "$WORK/grub/grub.orig" >/dev/null 2>&1
# текстовый установщик (без gtk), как в BIOS: установка идёт без графической среды
LINUX=$(awk '/^\s*linux\s/ && $2 !~ /gtk/ {print $2; exit}' "$WORK/grub/grub.orig")
INITRDG=$(awk '/^\s*initrd\s/ && $2 !~ /gtk/ {print $2; exit}' "$WORK/grub/grub.orig")
{
echo "set default=0"
echo "set timeout=10"
echo "menuentry --hotkey=i 'Установить Bridge&Join $VERSION (Debian 13)' {"
echo " set background_color=black"
echo " linux $LINUX vga=788 $BOOT --- quiet"
echo " initrd $INITRDG"
echo "}"
sed '/^set default/d;/^set timeout/d' "$WORK/grub/grub.orig"
} > "$WORK/grub/grub.cfg"
rm -f "$OUT"
xorriso -indev "$SRC" -outdev "$OUT" -boot_image any replay \
-volid "BJ_DEBIAN13" \
-map "$WORK/preseed" /preseed \
-map "$WORK/bj" /bj \
-map "$WORK/isolinux/txt.cfg" /isolinux/txt.cfg \
-map "$WORK/isolinux/isolinux.cfg" /isolinux/isolinux.cfg \
-map "$WORK/grub/grub.cfg" /boot/grub/grub.cfg \
-end >/dev/null 2>"$WORK/xorriso.log" || { cat "$WORK/xorriso.log"; exit 1; }
echo "Образ собран: $OUT ($(du -h "$OUT" | cut -f1)), канал обновлений $CHANNEL"
+71
View File
@@ -0,0 +1,71 @@
#_preseed_V1
# Ответы установщику Debian 13 для образа Bridge&Join (deploy/iso/build-iso.sh).
# Спрашиваются только пароли, диск (если их несколько) и подтверждение стирания; остальное задано здесь.
# После установки служба bj-firstboot на экране сервера спросит ключ продукта и установит bj.
# Язык, клавиатура, время
d-i debian-installer/language string ru
d-i debian-installer/country string RU
d-i debian-installer/locale string ru_RU.UTF-8
d-i localechooser/supported-locales multiselect ru_RU.UTF-8, en_US.UTF-8
d-i keyboard-configuration/xkb-keymap select ru
d-i keyboard-configuration/toggle select Alt+Shift
d-i clock-setup/utc boolean true
d-i time/zone string Europe/Moscow
d-i clock-setup/ntp boolean true
# Сеть: DHCP на первом подключённом интерфейсе
d-i netcfg/choose_interface select auto
d-i netcfg/get_hostname string bj-server
d-i netcfg/get_domain string
d-i netcfg/hostname string bj-server
d-i hw-detect/load_firmware boolean true
# Зеркало пакетов
d-i mirror/country string manual
d-i mirror/http/hostname string deb.debian.org
d-i mirror/http/directory string /debian
d-i mirror/http/proxy string
d-i apt-setup/cdrom/set-first boolean false
d-i apt-setup/non-free-firmware boolean true
d-i apt-setup/services-select multiselect security, updates
# Учётные записи: пароль root и администратор ОС задаются при установке
d-i passwd/root-login boolean true
d-i passwd/make-user boolean true
d-i passwd/user-fullname string Администратор
d-i passwd/username string bjadmin
# Разметка: весь выбранный диск, одна файловая система; диск и стирание подтверждаются при установке
d-i partman-auto/method string regular
d-i partman-auto/choose_recipe select atomic
d-i partman-auto-lvm/guided_size string max
d-i partman-partitioning/confirm_write_new_label boolean true
d-i partman/choose_partition select finish
d-i partman/default_filesystem string ext4
d-i partman-md/confirm boolean true
d-i partman-lvm/device_remove_lvm boolean true
d-i partman-md/device_remove_md boolean true
# Пакеты: без графики; SSH и всё, что нужно bj, ставится сразу
tasksel tasksel/first multiselect standard, ssh-server
d-i pkgsel/include string ca-certificates curl postgresql postgresql-client default-jre-headless pcscd python3 sudo
d-i pkgsel/upgrade select safe-upgrade
popularity-contest popularity-contest/participate boolean false
# Загрузчик
d-i grub-installer/only_debian boolean true
d-i grub-installer/with_other_os boolean false
d-i grub-installer/bootdev string default
# Установщик bj и служба первой загрузки (файлы лежат в /bj на образе)
d-i preseed/late_command string \
install -m 0755 /cdrom/bj/bj-installer /target/usr/local/sbin/bj-installer; \
install -m 0755 /cdrom/bj/bj-firstboot /target/usr/local/sbin/bj-firstboot; \
install -m 0644 /cdrom/bj/bj-firstboot.service /target/etc/systemd/system/bj-firstboot.service; \
mkdir -p /target/etc/bj; \
if [ -f /cdrom/bj/channel ]; then install -m 0644 /cdrom/bj/channel /target/etc/bj/channel; fi; \
in-target systemctl enable bj-firstboot.service
d-i finish-install/reboot_in_progress note
d-i cdrom-detect/eject boolean true
-71
View File
@@ -1,71 +0,0 @@
# Лицензирование Bridge-and-Join-s (#19)
Годовые лицензии с офлайн-проверкой по подписи Ed25519 + опциональный
онлайн-отзыв.
## Компоненты
- `internal/license` — формат лицензии + подпись/проверка (offline).
- `cmd/bj-license` — издательский CLI: keygen, issue, verify.
- `cmd/bj-license-server` — онлайн-реестр отзывов (revocation).
- Интеграция в bj-server: `internal/lkgateway/licensecheck.go` — проверка
лицензии + гейт обновлений; UI раздел «Лицензия».
## Модель
Лицензия — самодостаточный подписанный токен: `payload.signature.keyid`.
bj-server проверяет подпись зашитым публичным ключом и срок **офлайн** —
работает без связи с сервером. Online-сервер нужен только для отзыва.
**Гейт обновлений:** если лицензирование включено (есть публичный ключ),
авто-обновление (#20) выполняется только при валидной лицензии с фичей
`updates`. Без лицензирования (публичный ключ не зашит) — открытый режим,
гейты не действуют (бесплатная редакция / разработка).
## Издателю
```bash
# 1. Ключи лицензий (однократно; приватный — в секрете!)
bj-license keygen -out ./keys/license
# публичный base64 — зашить в bj-server (DefaultLicensePublicKey)
# 2. Выпустить годовую лицензию клиенту
bj-license issue -tenant "ООО Ромашка" -plan pro -days 365 \
-features updates,web-cabinet -key ./keys/license.priv -keyid main
# → выводит ключ payload.signature.keyid — отдать клиенту
# 3. Проверить
bj-license verify -key-file license.key -pub ./keys/license.pub
```
Планы: `free` (без фич), `pro` (перечисленные features), `enterprise`
(всё включено). Фичи: `updates`, `web-cabinet`, …
## Клиенту (on-prem bj-server)
Админ → Настройка → **Лицензия** → вставить ключ → «Активировать».
Проверка офлайн; статус (организация, план, срок, обновления) виден сразу.
## Онлайн-отзыв (опционально)
```bash
bj-license-server --addr :8091 --revoked /var/lib/bj-license/revoked.json
```
`revoked.json` — JSON-массив отозванных license ID:
```json
["28db4973-fde8-434c-b102-e83623eede2c"]
```
`GET /v1/check?id=<id>` → `{"revoked":true|false}`. Перечитывается раз в
минуту. В проде заменить файл на PostgreSQL + admin API выпуска/отзыва.
## Зашивка публичного ключа в релиз
```bash
go build -ldflags "\
-X .../lkgateway.DefaultLicensePublicKey=<base64-pub> \
-X .../lkgateway.DefaultUpdatePublicKey=<base64-pub-артефактории> \
-X .../lkgateway.BuildVersion=1.0.0" -o bj-server ./cmd/bj-server/
```
-11
View File
@@ -1,11 +0,0 @@
[Unit]
Description=Bridge&Join — команды защиты из кабинета сервера лицензий
[Path]
PathChanged=/var/lib/bj-license/fw-control.json
PathChanged=/var/lib/bj-license/allow-ips.txt
PathChanged=/var/lib/bj-license/trusted-ips.txt
Unit=bj-license-fw.service
[Install]
WantedBy=multi-user.target
-7
View File
@@ -1,7 +0,0 @@
[Unit]
Description=Bridge&Join — применение защиты сервера лицензий (белый список, режим обороны, команды fail2ban)
After=fail2ban.service
[Service]
Type=oneshot
ExecStart=/opt/bj-license/bj-license-server fw-apply -dir /var/lib/bj-license
-11
View File
@@ -1,11 +0,0 @@
[Unit]
Description=Bridge&Join — статус защиты сервера лицензий раз в 30 секунд (и восстановление правил после перезагрузки)
[Timer]
OnBootSec=30s
OnUnitActiveSec=30s
AccuracySec=5s
Unit=bj-license-fw.service
[Install]
WantedBy=timers.target
-22
View File
@@ -1,22 +0,0 @@
[Unit]
Description=Bridge&Join — сервер лицензирования (lic.zetit.ru)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=bj-license
Group=bj-license
ExecStart=/opt/bj-license/bj-license-server serve -addr 127.0.0.1:8091 -data /var/lib/bj-license/state.json -key /etc/bj-license/license.priv -keyid main
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/lib/bj-license
ReadOnlyPaths=/etc/bj-license
[Install]
WantedBy=multi-user.target
@@ -1,6 +0,0 @@
# fail2ban: перебор паролей кабинета сервера лицензий bj
# (строки пишет bj-license-server в журнал systemd; IP клиента берётся из X-Real-IP nginx).
[Definition]
failregex = ^.*login failed: user=".*" ip=<HOST>$
ignoreregex =
journalmatch = _SYSTEMD_UNIT=bj-license-server.service
@@ -1,5 +0,0 @@
# fail2ban: перебор кодов активации и токенов API сервера лицензий bj.
[Definition]
failregex = ^.*api denied: (unknown_code|bad_token) ip=<HOST>$
ignoreregex =
journalmatch = _SYSTEMD_UNIT=bj-license-server.service
@@ -1,30 +0,0 @@
# Сервер лицензий lic.zetit.ru: блокировка IP на уровне файрвола.
# Вход в кабинет: не блокируется только белый список администраторов (раздел «Защита»).
[bj-license-admin]
enabled = true
backend = systemd
filter = bj-license-admin
port = http,https
maxretry = 10
findtime = 10m
bantime = 1h
ignorecommand = /opt/bj-license/bj-license-server fw-ignore -dir /var/lib/bj-license <ip> admin
# API активации и продления: не блокируются адреса установок, успешно активировавших или продливших
# лицензию (trusted-ips.txt), и белый список администраторов (allow-ips.txt).
[bj-license-api]
enabled = true
backend = systemd
filter = bj-license-api
port = http,https
maxretry = 10
findtime = 10m
bantime = 1h
ignorecommand = /opt/bj-license/bj-license-server fw-ignore -dir /var/lib/bj-license <ip> api
[sshd]
enabled = true
maxretry = 5
findtime = 10m
bantime = 1h
+1
View File
@@ -10,6 +10,7 @@ process_one(){
rm -f "$req"
case "$action" in
quikexport-*) out=$(BJ_EXTRA="$extra" /usr/local/sbin/bj-quikexport-admin "${action#quikexport-}" "$staging" 2>&1) ;;
component-install) out=$(/usr/local/sbin/bj-component-install "$staging" 2>&1) ;;
*) out=$(BJ_TARGET_DEV="$tdev" BJ_TARGET_MNT="$tmnt" BJ_EXTRA="$extra" /usr/local/sbin/bj-keymedia-install "$action" "$staging" "$profile" 2>&1) ;;
esac
printf '%s' "$out" > "$res.tmp"; chown bj:bj "$res.tmp" 2>/dev/null
+28
View File
@@ -0,0 +1,28 @@
[Unit]
Description=Bridge&Join: Личный кабинет клиента (bj-lk)
Documentation=https://git.zetit.ru/zuevav/Bridge-and-Join-s
After=network-online.target bj-server.service
Wants=network-online.target
[Service]
Type=simple
User=bj
Group=bj
# Значения по умолчанию. Адрес bj-server, ключ доступа модуля (BJ_KEY), порт (LISTEN) и заголовок
# (LK_TITLE) задаются в /etc/bj/lk.env (root, 0600), чтобы ключ не лежал в unit-файле.
Environment=BJ_URL=http://127.0.0.1:8080
Environment=LISTEN=:8099
EnvironmentFile=-/etc/bj/lk.env
ExecStart=/opt/bj/bj-lk
Restart=on-failure
RestartSec=5
StandardOutput=append:/var/log/bj/bj-lk.log
StandardError=append:/var/log/bj/bj-lk.log
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/log/bj
[Install]
WantedBy=multi-user.target
+24
View File
@@ -0,0 +1,24 @@
[Unit]
Description=Bridge&Join: модуль «НПР / УДС» (риск-монитор по данным QUIK)
Documentation=https://git.zetit.ru/zuevav/Bridge-and-Join-s
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=bj
Group=bj
ExecStart=/opt/bj/bj-npr -addr 127.0.0.1:8083 -setup /var/lib/bj/.bj/setup.json -key /var/lib/bj/.bj/module.key
Restart=on-failure
RestartSec=5
StandardOutput=append:/var/log/bj/bj-npr.log
StandardError=append:/var/log/bj/bj-npr.err
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/log/bj
ReadOnlyPaths=/var/lib/bj
[Install]
WantedBy=multi-user.target
+31
View File
@@ -0,0 +1,31 @@
[Unit]
Description=Bridge&Join: модуль «Экспорт биржевой информации» (управление ARQA quikexport)
Documentation=https://git.zetit.ru/zuevav/Bridge-and-Join-s
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=bj
Group=bj
ExecStart=/opt/bj/bj-quikexport -addr 127.0.0.1:8084 -store /var/lib/bj/.bj/quik-export/settings.json -setup /var/lib/bj/.bj/setup.json -key /var/lib/bj/.bj/module.key
Restart=on-failure
RestartSec=5
StandardOutput=append:/var/log/bj/bj-quikexport.log
StandardError=append:/var/log/bj/bj-quikexport.err
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
ReadOnlyPaths=/var/lib/bj
# Настройки и ключи модуля и staging для root-воркера (systemd создаёт их с владельцем bj),
# очередь запросов root-воркера (создаёт bj-keymedia) и журналы.
StateDirectory=bj/.bj/quik-export bj/quik-export-apply
StateDirectoryMode=0700
ReadWritePaths=-/var/lib/bj/keymedia-requests /var/log/bj
[Install]
WantedBy=multi-user.target
+3 -1
View File
@@ -15,7 +15,7 @@ RestartSec=5
# Конфигурация — через ENV или ~/.bj/setup.json (UI /admin/setup).
Environment=BJ_HTTP_ADDR=:8080
Environment=BJ_SETUP_PATH=/var/lib/bj/setup.json
Environment=BJ_SETUP_PATH=/var/lib/bj/.bj/setup.json
Environment=BJ_M2M_SENDER=MC0079200000
Environment=BJ_M2M_RECEIVER=MC0010300000
@@ -32,6 +32,8 @@ ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/lib/bj /var/log/bj /run/bj
StandardOutput=append:/var/log/bj/bj-server.log
StandardError=append:/var/log/bj/bj-server.err
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
+3
View File
@@ -1,5 +1,8 @@
# Сервер лицензирования и обновлений bj — контракт API
> Сервер лицензий живёт в отдельном репозитории **zuevav/lic_Bridge-and-Join-s** (наша инфраструктура, клиентам не поставляется). Здесь — контракт API для клиентской части bj (`internal/lkgateway/licenseclient.go`). Формат токена общий: `internal/license`, тест `TestTokenCompat` одинаков в обоих репозиториях.
Сервер: **https://lic.zetit.ru** (ВМ 46.173.19.220, Ubuntu 26.04). Адрес зашит в bj
(`internal/lkgateway`, константа), в интерфейсе не меняется. С этого же сервера bj
получает обновления.
-2
View File
@@ -11,8 +11,6 @@ require (
google.golang.org/protobuf v1.36.11
)
require rsc.io/qr v0.2.0 // indirect
require (
github.com/Azure/go-ntlmssp v0.1.1 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect
+16 -2
View File
@@ -12,6 +12,8 @@ github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDP
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -37,6 +39,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -45,6 +49,18 @@ github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg=
github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/microsoft/go-mssqldb v1.10.0 h1:pHEt+Qz6YFPWqREq10mqSE524QQo+/QremwTCQht7TY=
@@ -94,5 +110,3 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
+43
View File
@@ -0,0 +1,43 @@
package license
// Совместимость формата лицензии между bj (проверяет токены) и сервером лицензий
// lic_Bridge-and-Join-s (выпускает их). Пакет лежит копией в обоих репозиториях;
// этот тест с одинаковым ожидаемым токеном есть в обоих — если формат разойдётся,
// упадёт тот, где пакет изменили. Подпись Ed25519 детерминирована.
import (
"crypto/ed25519"
"testing"
"time"
)
const compatToken = "eyJzY2hlbWEiOjEsImlkIjoiMTExMTExMTEtMjIyMi0zMzMzLTQ0NDQtNTU1NTU1NTU1NTU1IiwidGVuYW50Ijoi0J7QntCeIMKr0J/RgNC+0LLQtdGA0LrQsMK7IiwicHJvZHVjdCI6ImJqLXNlcnZlciIsInBsYW4iOiJwcm8iLCJpc3N1ZWRfYXQiOiIyMDI2LTA5LTAxVDAwOjAwOjAwWiIsImV4cGlyZXNfYXQiOiIyMDI2LTExLTAxVDAwOjAwOjAwWiIsImZlYXR1cmVzIjpbInVwZGF0ZXMiLCJucHIiXSwibWF4X25vZGVzIjoyLCJpbnN0YWxsX2lkIjoiYWFhYWFhYWEtYmJiYi1jY2NjLWRkZGQtZWVlZWVlZWVlZWVlIiwiY29udHJhY3RfdW50aWwiOiIyMDI3LTA5LTAxVDAwOjAwOjAwWiJ9.VIKhe7NZxwvTQfCNCNMwrADvlWd/izmzZrE2h1rkwliOBHQd5rGU00/v/V8uArkw4YShc5NW4BLY35wIPQ3GDg==.main"
func TestTokenCompat(t *testing.T) {
seed := make([]byte, ed25519.SeedSize)
for i := range seed {
seed[i] = byte(i + 1)
}
priv := ed25519.NewKeyFromSeed(seed)
l := &License{
ID: "11111111-2222-3333-4444-555555555555", Tenant: "ООО «Проверка»", Product: "bj-server", Plan: PlanPro,
IssuedAt: time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC), ExpiresAt: time.Date(2026, 11, 1, 0, 0, 0, 0, time.UTC),
Features: []string{"updates", "npr"}, MaxNodes: 2,
InstallID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", ContractUntil: time.Date(2027, 9, 1, 0, 0, 0, 0, time.UTC),
}
tok, err := Sign(l, priv, "main")
if err != nil {
t.Fatal(err)
}
if got := tok.Encode(); got != compatToken {
t.Fatalf("формат токена изменился — синхронизируйте internal/license в bj и сервере лицензий\nполучено: %s", got)
}
back, err := DecodeToken(compatToken)
if err != nil {
t.Fatal(err)
}
v, err := Verify(back, priv.Public().(ed25519.PublicKey))
if err != nil || v.InstallID != l.InstallID || !v.ContractUntil.Equal(l.ContractUntil) || v.Tenant != l.Tenant {
t.Fatalf("проверка токена: %v %+v", err, v)
}
}
+5
View File
@@ -0,0 +1,5 @@
package license
// DefaultPublicKey — открытый ключ проверки лицензий Bridge&Join (закрытый — на сервере лицензий,
// /etc/bj-license/license.priv). Общий для bj-server и установщика.
const DefaultPublicKey = "UuvEYFxxzOFk59FjCQTdxKuzkNAJpJyWTuUdlCYfsdI="
+16 -8
View File
@@ -38,14 +38,14 @@ const (
// License — содержимое лицензии (подписывается целиком).
type License struct {
Schema int `json:"schema"`
ID string `json:"id"` // UUID лицензии
Tenant string `json:"tenant"` // организация-клиент
Product string `json:"product"` // "bj-server"
Plan Plan `json:"plan"` // free|pro|enterprise
IssuedAt time.Time `json:"issued_at"` // дата выпуска
ExpiresAt time.Time `json:"expires_at"` // дата окончания (годовой ключ)
Features []string `json:"features,omitempty"` // "updates","web-cabinet",...
MaxNodes int `json:"max_nodes,omitempty"` // лимит инсталляций (0 = без лимита)
ID string `json:"id"` // UUID лицензии
Tenant string `json:"tenant"` // организация-клиент
Product string `json:"product"` // "bj-server"
Plan Plan `json:"plan"` // free|pro|enterprise
IssuedAt time.Time `json:"issued_at"` // дата выпуска
ExpiresAt time.Time `json:"expires_at"` // дата окончания (годовой ключ)
Features []string `json:"features,omitempty"` // "updates","web-cabinet",...
MaxNodes int `json:"max_nodes,omitempty"` // лимит инсталляций (0 = без лимита)
Note string `json:"note,omitempty"`
// Онлайн-лицензирование (lic.zetit.ru, docs/license-server-api.md): токен выдаётся
@@ -53,8 +53,16 @@ type License struct {
// пока действует договор (ContractUntil). У офлайн-ключей оба поля пустые.
InstallID string `json:"install_id,omitempty"`
ContractUntil time.Time `json:"contract_until,omitempty"`
// DevUntil — режим разработки до указанного момента: сервер лицензий временно включает его
// установке, чтобы разработчики bj подключились к серверу клиента (тестовые разделы и
// инструменты). Указатель — чтобы у ключей без режима разработки подписанный JSON не менялся.
DevUntil *time.Time `json:"dev_until,omitempty"`
}
// DevMode — действует ли режим разработки на момент now.
func (l *License) DevMode(now time.Time) bool { return l.DevUntil != nil && now.Before(*l.DevUntil) }
// Token — лицензия + подпись. Именно это вводит клиент (одна base64-строка
// или JSON-файл). Формат: base64url(payload).base64url(sig) — компактно.
type Token struct {
+31 -27
View File
@@ -22,13 +22,13 @@ var templatesFS embed.FS
// конкретный content-шаблон). Так html/template не путается с несколькими
// {{define "content"}} в разных файлах.
type admin struct {
home, claims, claim, status, setup *template.Template
help, helpDatabase, helpLK, helpCrypto, helpSystems, helpRobot, helpArchitecture, helpQuik, helpFancyEvents, npr, nprClient, nprRows *template.Template
incoming, incomingDetail, fancyQueue, quikInstr *template.Template
wizard, news, keyWizard *template.Template
handbook *template.Template
m2mtest, m2mpreview, depoSim *template.Template
journal *template.Template
home, claims, claim, status, setup *template.Template
help, helpDatabase, helpLK, helpCrypto, helpSystems, helpRobot, helpArchitecture, helpQuik, helpFancyEvents, modulePage *template.Template
incoming, incomingDetail, fancyQueue, quikInstr *template.Template
wizard, news, keyWizard *template.Template
handbook *template.Template
m2mtest, m2mpreview, depoSim *template.Template
journal *template.Template
}
// templateFuncs — функции, доступные внутри шаблонов. Главная задача —
@@ -51,7 +51,6 @@ var templateFuncs = template.FuncMap{
// m2mCode — человекочитаемое название кода M2M: «Текст (M2Mxx)».
"m2mCode": m2mCodeLabel,
"quikCodeMap": quikClientCodeMapText,
"nprF": nprF,
"curUser": authTemplateFuncs["curUser"],
"can": authTemplateFuncs["can"],
"authOpen": authOpen,
@@ -277,20 +276,13 @@ func newAdmin() (*admin, error) {
if err != nil {
return nil, fmt.Errorf("parse admin_help_fancy_events: %w", err)
}
parseNPR := func(content string) (*template.Template, error) {
return template.New("layout.html").Funcs(templateFuncs).ParseFS(templatesFS, "web/templates/layout.html", "web/templates/npr_css.html", "web/templates/"+content)
// Страница модуля (служба модуля отдаёт содержимое, макет и меню — bj): npr.go.
modulePage, err := template.New("layout.html").Funcs(templateFuncs).ParseFS(templatesFS, "web/templates/layout.html")
if err == nil {
_, err = modulePage.New("content").Parse(`{{.Content}}`)
}
nprT, err := parseNPR("admin_npr.html")
if err != nil {
return nil, fmt.Errorf("parse admin_npr: %w", err)
}
nprC, err := parseNPR("admin_npr_client.html")
if err != nil {
return nil, fmt.Errorf("parse admin_npr_client: %w", err)
}
nprR, err := template.New("npr_rows").Funcs(templateFuncs).ParseFS(templatesFS, "web/templates/admin_npr_rows.html")
if err != nil {
return nil, fmt.Errorf("parse admin_npr_rows: %w", err)
return nil, fmt.Errorf("parse module page: %w", err)
}
keyWizard, err := parse("admin_keywizard.html")
if err != nil {
@@ -335,7 +327,7 @@ func newAdmin() (*admin, error) {
return &admin{
home: home, claims: claims, claim: claim, status: status, setup: setup,
help: help, helpDatabase: helpDB, helpLK: helpLK, helpCrypto: helpCrypto, helpSystems: helpSys,
helpRobot: helpRobot, helpArchitecture: helpArch, helpQuik: helpQuik, helpFancyEvents: helpFancyEvents, npr: nprT, nprClient: nprC, nprRows: nprR,
helpRobot: helpRobot, helpArchitecture: helpArch, helpQuik: helpQuik, helpFancyEvents: helpFancyEvents, modulePage: modulePage,
wizard: wizard, news: news, keyWizard: keyWizard,
incoming: incoming, incomingDetail: incomingDetail, fancyQueue: fancyQueue, quikInstr: quikInstr,
handbook: handbook,
@@ -351,9 +343,10 @@ type page struct {
Title string
Active string
Now string
IsMockMode bool // true если ИШ не настроен — bj-server в режиме эмуляции
MockReason string // короткое описание почему mock
IsMockMode bool // true если ИШ или СКЗИ не настроены — обмена с НРД нет
MockReason string // короткое описание причины (у клиента — без «mock»)
NPR bool // модуль «НПР / УДС» включён и разрешён лицензией
DevMode bool // режим разработки (сервер лицензий): видны служебные разделы
}
// globalRC — ссылка на runtime-конфиг для template-funcs/page helpers.
@@ -487,7 +480,7 @@ func (a *admin) renderHome(w http.ResponseWriter, r *http.Request, svc *Service,
page: nowPage("Дашборд", "home"),
Status: status,
Recent: recent.Items,
News: topNews(rc.Snapshot().News.Items, 3),
News: topNews(visibleNews(rc, rc.Snapshot().News.Items), 3),
LKUrl: rc.Snapshot().LK.PublicURL,
}
// Готовность системы считаем ТОЛЬКО по обязательным компонентам.
@@ -617,7 +610,11 @@ func buildClaimTimeline(ctx context.Context, v ClaimView) []TimelineEvent {
rej++
}
}
add("Получено решение НРД (робот)", "response",
title := "Получено решение контрагента (НРД)"
if devMode(globalRC) {
title = "Получено решение НРД (робот)"
}
add(title, "response",
fmt.Sprintf("Принято %d, отклонено %d.", conf, rej), v.UpdatedAt, v.CreatedAt)
} else if v.M2MResponse != nil {
var codes string
@@ -712,14 +709,21 @@ func nowPage(title, active string) page {
p := page{Title: title, Active: active, Now: time.Now().Format("02.01.2006 15:04:05")}
if globalRC != nil {
p.NPR = nprAvailable(globalRC)
p.DevMode = devMode(globalRC)
s := globalRC.Snapshot()
switch {
case s.NSD.IGWBaseURL == "":
p.IsMockMode = true
p.MockReason = "ИШ НРД не настроен — заявки идут через внутренний mock (Decision эмитируется через 3 сек)"
p.MockReason = "ИШ НРД не настроен — обмен с НРД не выполняется"
if p.DevMode {
p.MockReason = "ИШ НРД не настроен — заявки идут через внутренний mock (Decision эмитируется через 3 сек)"
}
case s.Crypto.Provider == "" || s.Crypto.Provider == "stub":
p.IsMockMode = true
p.MockReason = "Провайдер СКЗИ = stub — подпись не делается, реальный обмен с НРД невозможен"
p.MockReason = "Не настроено средство подписи (СКЗИ) — обмен с НРД невозможен"
if p.DevMode {
p.MockReason = "Провайдер СКЗИ = stub — подпись не делается, реальный обмен с НРД невозможен"
}
}
}
return p
+3
View File
@@ -37,6 +37,9 @@ func advancedUnlocked(r *http.Request) bool {
if globalRC != nil && moduleAllowed(globalRC, "quik-mnp") {
return true
}
if !devMode(globalRC) { // пароль на модули — инструмент разработчиков
return false
}
c, err := r.Cookie(advancedCookie)
if err != nil {
return false
+46 -9
View File
@@ -54,7 +54,7 @@ type roleDef struct {
// settings — настройка системы и пользователи; npr/quik/export — модули.
var roleDefs = []roleDef{
{"admin", "Администратор", "Полный доступ: переводы, настройки, пользователи, все модули.", []string{"view", "operate", "settings", "npr", "quik", "export", "users"}},
{"operator", "Оператор депозитария", "Журнал, заявки, входящие/исходящие переводы, обмен с DEPO и QUIK, тестирование M2M. Без настроек.", []string{"view", "operate", "quik"}},
{"operator", "Оператор депозитария", "Журнал, заявки, входящие/исходящие переводы, обмен с DEPO и QUIK. Без настроек.", []string{"view", "operate", "quik"}},
{"risk", "Риск-менеджер", "Только риск-монитор НПР / УДС.", []string{"npr"}},
{"viewer", "Наблюдатель", "Просмотр журнала и переводов без права действий.", []string{"view"}},
}
@@ -180,6 +180,9 @@ type User struct {
}
func (u *User) Can(perm string) bool {
if perm == "dev" { // режим разработки: включает сервер лицензий, внутри — права настройки
return u != nil && devMode(globalRC) && u.Can("settings")
}
if u == nil || !permLicensed(perm) {
return false
}
@@ -490,7 +493,7 @@ type AuthSettings struct {
// Перепроверка групп доменных пользователей (adrecheck.go): поиск от имени
// сервисной учётной записи только на чтение. Пусто — роль меняется только при входе.
ADBindUser string `json:"ad_bind_user"` // svc-bj@corp.example.ru, CORP\svc-bj или DN
ADBindUser string `json:"ad_bind_user"` // svc-bj@corp.example.ru, CORP\svc-bj или DN
ADBindPassword string `json:"ad_bind_password"`
ADRecheckMinutes int `json:"ad_recheck_minutes"` // 0 = 15
}
@@ -805,20 +808,34 @@ func currentUser(w http.ResponseWriter) *User {
// authExempt — пути без входа: API модулей (свой ключ/токен), health, статика.
func authExempt(p string) bool {
switch {
case p == "/login" || p == "/logout" || p == "/healthz":
case p == "/login" || p == "/logout" || p == "/healthz" || p == "/first-run":
return true
case strings.HasPrefix(p, "/api/"):
return true
case p == "/admin/api/callback-url": // служебный вызов эмулятора ЛК
return true
case strings.HasPrefix(p, "/admin/npr/static/"):
return true
}
return false
}
// devOnlyPath — служебные разделы разработчиков (тестирование, имитации, прогоны с роботом НРД):
// доступны только в режиме разработки.
func devOnlyPath(p string) bool {
for _, pre := range []string{"/admin/m2m-testing", "/admin/incoming/depo-sim", "/admin/setup/test-nsd", "/admin/setup/test-run",
"/admin/setup/lk-simulate", "/admin/setup/postgres/quick-start", "/admin/help/robot", "/admin/api/clients", "/admin/api/callback-url",
"/admin/setup/advanced-unlock", "/admin/setup/advanced-lock"} {
if p == pre || strings.HasPrefix(p, pre+"/") {
return true
}
}
return false
}
// requiredPerm — какое право нужно для пути и метода.
func requiredPerm(p, method string) string {
if devOnlyPath(p) {
return "dev"
}
switch {
case strings.HasPrefix(p, "/admin/setup") || p == "/admin/wizard":
return "settings"
@@ -853,10 +870,22 @@ func clientIP(r *http.Request) string {
func authMiddleware(rc *RuntimeConfig, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path
if devOnlyPath(p) && !devMode(rc) { // вне режима разработки служебных разделов нет вовсе
http.NotFound(w, r)
return
}
if authExempt(p) {
next.ServeHTTP(w, r)
return
}
if firstRunPending() { // свежая установка: сначала администратор по одноразовому коду
if isAjax(r) {
http.Error(w, "требуется первичная настройка", http.StatusUnauthorized)
return
}
http.Redirect(w, r, "/first-run", http.StatusSeeOther)
return
}
if authOpen() {
next.ServeHTTP(&authRW{ResponseWriter: w, user: nil}, r)
return
@@ -924,9 +953,14 @@ type loginData struct {
var loginTpl = template.Must(template.New("login").Parse(loginHTML))
func registerAuth(mux *http.ServeMux, rc *RuntimeConfig) {
registerFirstRun(mux)
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
a := rc.Snapshot().Auth
next := safeReturnPath(r.FormValue("next"))
if firstRunPending() {
http.Redirect(w, r, "/first-run", http.StatusSeeOther)
return
}
if authOpen() {
http.Redirect(w, r, "/admin/setup#users", http.StatusSeeOther)
return
@@ -978,10 +1012,10 @@ func registerAuth(mux *http.ServeMux, rc *RuntimeConfig) {
// ---------- обработчики настроек ----------
type UsersView struct {
Users []*User
Roles []roleDef // доступные при текущей лицензии
Hidden []struct{ Title, Missing string } // роли, недоступные без модулей
Modules []struct{ Key, Title string } // подключённые модули
Users []*User
Roles []roleDef // доступные при текущей лицензии
Hidden []struct{ Title, Missing string } // роли, недоступные без модулей
Modules []struct{ Key, Title string } // подключённые модули
Sessions []*Session
Open bool
Me *User
@@ -1259,6 +1293,9 @@ func renderFor(w http.ResponseWriter, t *template.Template, data any) error {
t2.Funcs(template.FuncMap{
"curUser": func() *User { return u },
"can": func(perm string) bool {
if perm == "dev" {
return devMode(globalRC) && (authOpen() || u.Can("settings"))
}
if authOpen() {
return true
}
+32
View File
@@ -0,0 +1,32 @@
package lkgateway
import (
"net/http"
"net/http/httptest"
"testing"
)
// Служебные разделы разработчиков вне режима разработки отвечают 404 — даже при открытом входе.
func TestDevOnlyPaths(t *testing.T) {
for _, p := range []string{"/admin/m2m-testing", "/admin/m2m-testing/preview", "/admin/setup/test-run", "/admin/api/callback-url", "/admin/help/robot"} {
if !devOnlyPath(p) || requiredPerm(p, http.MethodGet) != "dev" {
t.Errorf("%s должен быть служебным", p)
}
}
for _, p := range []string{"/admin/m2m-testingx", "/admin/incoming", "/admin/setup", "/admin/help"} {
if devOnlyPath(p) {
t.Errorf("%s не служебный", p)
}
}
rc, err := NewRuntimeConfig(t.TempDir() + "/setup.json")
if err != nil {
t.Fatal(err)
}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusTeapot) })
h := authMiddleware(rc, next)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/admin/m2m-testing", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("вне режима разработки: %d", rec.Code)
}
}
+9 -2
View File
@@ -124,7 +124,11 @@ func checkCryptoSocket(o CheckOptions) Status {
s.Message = "сокет открыт"
s.Detail = o.CryptoSocket
if o.CryptoProvider == "stub" || o.CryptoProvider == "" {
s.Message += ", провайдер stub (реальная криптография не подключена)"
if devMode(globalRC) {
s.Message += ", провайдер stub (реальная криптография не подключена)"
} else {
s.Message += ", средство подписи не настроено"
}
} else {
s.Message += ", провайдер " + o.CryptoProvider
}
@@ -136,7 +140,10 @@ func checkNSDAdapter(ctx context.Context, o CheckOptions) Status {
if o.NSDAdapterURL == "" {
s.OK = true
s.Optional = true
s.Message = "не подключён — режим эмуляции (mock)"
s.Message = "не настроен — обмен с НРД не выполняется"
if devMode(globalRC) {
s.Message = "не подключён — режим эмуляции (mock)"
}
return s
}
// У ИШ нет /healthz — проверяем рабочий эндпоинт Web API (engine/state
+118 -26
View File
@@ -44,6 +44,7 @@ type depoCheck struct {
OK bool
Code string // код причины отказа (M2Mxx или QTY/PLACE)
Warn bool // пройдено с предупреждением
Strict bool // предупреждение, которое автоматический режим считает отказом
}
// depoSecResult — результат по одной бумаге.
@@ -84,6 +85,7 @@ type depoParty struct {
iisDet string
qualOK bool
qualWarn bool
qualAll bool // квалифицирован по всем видам бумаг
qualDet string
}
@@ -109,7 +111,10 @@ func depoResolveParty(ctx context.Context, db *sql.DB, fs FancyMSSQLSettings, se
num = strings.TrimSpace(num)
if ser != "" && num != "" {
e := db.QueryRowContext(ctx,
`SELECT TOP 1 ID, ISNULL(NAME,''), DATE_BIRTH FROM OD_FACES WHERE REPLACE(ISNULL(DOC_SER,''),' ','')=@p1 AND DOC_NUM=@p2`,
// Карточка лица в DEPO версионная: действующая версия (LAST_FLAG=1) даёт ФИО и дату рождения,
// а счета и опции привязаны к исходной записи (SELF_ID).
`SELECT TOP 1 ISNULL(NULLIF(SELF_ID,0),ID), ISNULL(NAME,''), DATE_BIRTH FROM OD_FACES WHERE REPLACE(ISNULL(DOC_SER,''),' ','')=@p1 AND DOC_NUM=@p2
ORDER BY ISNULL(LAST_FLAG,0) DESC, ID DESC`,
sql.Named("p1", ser), sql.Named("p2", num)).Scan(&p.faceID, &p.faceName, &p.birth)
p.faceOK = e == nil
}
@@ -150,7 +155,7 @@ func depoResolveParty(ctx context.Context, db *sql.DB, fs FancyMSSQLSettings, se
p.qualWarn, p.qualDet = true, "инвестор не найден — квал не проверялся"
return p
}
p.qualWarn, p.qualDet = depoFaceQualified(ctx, db, p.faceID)
p.qualWarn, p.qualAll, p.qualDet = depoFaceQualified(ctx, db, p.faceID)
return p
}
@@ -169,8 +174,8 @@ var depoQualKinds = []string{
// depoFaceQualified — признак квалифицированного инвестора: действующая на сегодня опция лица
// IS_QUAL («Вхождение в реестр лиц, признанных квалифицированными инвесторами»). Значение —
// маска видов бумаг (511 и -1 — все виды, 0 — ни одного). Возвращает (предупреждение, пояснение).
func depoFaceQualified(ctx context.Context, db *sql.DB, faceID int64) (bool, string) {
// маска видов бумаг (511 и -1 — все виды, 0 — ни одного). Возвращает (предупреждение, по всем видам, пояснение).
func depoFaceQualified(ctx context.Context, db *sql.DB, faceID int64) (warn, all bool, det string) {
var val sql.NullString
err := db.QueryRowContext(ctx, `SELECT TOP 1 CAST(o.VAL AS varchar(20)) FROM OD_OPTIONS o
JOIN OP_FIELDS f ON f.ID = o.DESCR AND f.AS_NAME = 'IS_QUAL'
@@ -178,21 +183,20 @@ func depoFaceQualified(ctx context.Context, db *sql.DB, faceID int64) (bool, str
ORDER BY o.B_DATE DESC`, sql.Named("p1", faceID)).Scan(&val)
const notQual = " — если бумага только для квал., нужен отказ M2M08; решает оператор"
if errors.Is(err, sql.ErrNoRows) {
return true, "инвестор НЕ квалифицированный (нет опции «Вхождение в реестр квалифицированных инвесторов» в DEPO)" + notQual
return true, false, "инвестор НЕ квалифицированный (нет опции «Вхождение в реестр квалифицированных инвесторов» в DEPO)" + notQual
}
if err != nil {
return true, "признак квалифицированного инвестора не прочитан: " + quikErrorDetail(err)
return true, false, "признак квалифицированного инвестора не прочитан: " + quikErrorDetail(err)
}
mask, e := strconv.ParseInt(strings.TrimSpace(val.String), 10, 64)
if e != nil {
return true, fmt.Sprintf("признак квалифицированного инвестора в DEPO не распознан (%q) — решает оператор", val.String)
return true, false, fmt.Sprintf("признак квалифицированного инвестора в DEPO не распознан (%q) — решает оператор", val.String)
}
if mask == 0 {
return true, "инвестор НЕ квалифицированный (опция в DEPO = 0)" + notQual
return true, false, "инвестор НЕ квалифицированный (опция в DEPO = 0)" + notQual
}
all := mask < 0 || mask&511 == 511
if all {
return false, "инвестор — квалифицированный по всем видам бумаг (опция DEPO)"
if mask < 0 || mask&511 == 511 {
return false, true, "инвестор — квалифицированный по всем видам бумаг (опция DEPO)"
}
var kinds []string
for bit, name := range depoQualKinds {
@@ -200,7 +204,7 @@ func depoFaceQualified(ctx context.Context, db *sql.DB, faceID int64) (bool, str
kinds = append(kinds, name)
}
}
return false, "инвестор — квалифицированный (опция DEPO): " + strings.Join(kinds, "; ")
return false, false, "инвестор — квалифицированный (опция DEPO): " + strings.Join(kinds, "; ")
}
// depoAccountIIS — относится ли торговый счёт (OD_CLIENTS.ID) к договору ИИС: название договора
@@ -248,6 +252,68 @@ func depoSecurity(ctx context.Context, db *sql.DB, isin string) (id int64, name
return
}
// depoSecurityQualOnly — бумага «Предназначена для квалифицированных инвесторов»: действующая на сегодня
// опция бумаги RESTRICTED («Ограничение ценной бумаги по обращению», 1 — для квал. инвесторов).
func depoSecurityQualOnly(ctx context.Context, db *sql.DB, valID int64) (bool, string) {
var val sql.NullString
err := db.QueryRowContext(ctx, `SELECT TOP 1 CAST(o.VAL AS varchar(20)) FROM OD_OPTIONS o
JOIN OP_FIELDS f ON f.ID = o.DESCR AND f.AS_NAME = 'RESTRICTED'
WHERE o.OBJECT = @p1 AND o.B_DATE <= GETDATE() AND (o.E_DATE IS NULL OR o.E_DATE > GETDATE())
ORDER BY o.B_DATE DESC`, sql.Named("p1", valID)).Scan(&val)
switch {
case errors.Is(err, sql.ErrNoRows):
return false, "ограничений по обращению нет (опция DEPO)"
case err != nil:
return false, "ограничение бумаги по обращению не прочитано: " + quikErrorDetail(err)
case strings.TrimSpace(val.String) == "1":
return true, "бумага предназначена для квалифицированных инвесторов (опция DEPO)"
}
return false, "ограничений по обращению нет (опция DEPO)"
}
// qualOnlyCheck — ЗАЧИСЛЕНИЕ: бумага только для квал. инвесторов против квалификации инвестора у нас
// (не квалифицирован — отказ M2M08; бумага без ограничений — квалификация не требуется).
func qualOnlyCheck(found, qualOnly bool, qdet string, p depoParty) depoCheck {
c := depoCheck{Name: "Бумага только для квал. инвесторов", OK: true, Code: "M2M08", Detail: qdet}
switch {
case !found:
c.Detail = "не проверяется — бумаги нет в справочнике"
case !qualOnly:
c.Detail += " — квалификация клиента не требуется"
case !p.faceOK:
c.Detail += " — инвестор не найден, квалификация не проверялась"
case p.qualAll:
c.Detail += " — инвестор квалифицированный по всем видам бумаг"
case p.qualWarn:
c.OK = false
c.Detail += " — инвестор НЕ квалифицированный: операция нарушит закон РФ"
default:
c.Warn, c.Strict = true, true
c.Detail += " — " + p.qualDet + "; вид бумаги сверяет оператор (в автоматическом режиме — отказ M2M08)"
}
return c
}
// outQualCheck — СПИСАНИЕ: бумага только для квал. инвесторов, а наш клиент у нас не квалифицирован —
// предупреждение, не отказ: у принимающего депозитария клиент может быть квалифицирован, решает он.
func outQualCheck(found, qualOnly bool, qdet string, p depoParty) depoCheck {
c := depoCheck{Name: "Бумага только для квал. инвесторов", OK: true, Code: "M2M08", Detail: qdet}
switch {
case !found:
c.Detail = "не проверяется — бумаги нет в справочнике"
case !qualOnly:
c.Detail += " — квалификация клиента не требуется"
case !p.faceOK:
c.Detail += " — инвестор не найден, квалификация не проверялась"
case p.qualAll:
c.Detail += " — клиент квалифицированный по всем видам бумаг"
default:
c.Warn = true
c.Detail += " — у нас клиент не квалифицирован полностью (" + p.qualDet + "); принимающий депозитарий проверит квалификацию у себя и может отказать M2M08"
}
return c
}
// depoBlocking — есть ли блокировка бумаги на счёте (BLOCK_DATE либо остаток на 98070.05/.07/.08/.09).
func depoBlocking(ctx context.Context, db *sql.DB, valID, clientID int64) (ok bool, det string) {
var blockDate sql.NullTime
@@ -281,22 +347,35 @@ func finishSec(sr *depoSecResult) {
}
}
// finishResult — итог по переводу. Пакет принимается только целиком (решение 16.09.2026): если хотя
// бы по одной бумаге отказ, отказ рекомендуется по всем бумагам с кодом первой отказной.
func finishResult(res *depoResult) {
confirmN, rejectN := 0, 0
for _, s := range res.Secs {
if s.Recommend == "confirm" {
confirmN++
} else {
rejectN++
first := -1
for i, s := range res.Secs {
if s.Recommend != "confirm" {
first = i
break
}
}
switch {
case rejectN == 0 && confirmN > 0:
case len(res.Secs) == 0:
case first < 0:
res.Recommend, res.Summary = "confirm", "принять"
case confirmN == 0 && rejectN > 0:
res.Recommend, res.Summary = "reject", "отказ "+res.Secs[0].RejectCode
case confirmN > 0 && rejectN > 0:
res.Recommend, res.Summary = "mixed", fmt.Sprintf("принять %d, отказ %d", confirmN, rejectN)
default:
code, sec := res.Secs[first].RejectCode, res.Secs[first].Security
passed := 0
for i := range res.Secs {
if res.Secs[i].Recommend == "confirm" {
passed++
res.Secs[i].Recommend, res.Secs[i].RejectCode = "reject", code
res.Secs[i].Checks = append(res.Secs[i].Checks, depoCheck{Name: "Пакет принимается только целиком", OK: false, Code: code,
Detail: "сама бумага проверку прошла; отказ всем пакетом из-за " + sec + " (" + code + ")"})
}
}
res.Recommend, res.Summary = "reject", "отказ "+code
if passed > 0 {
res.Summary = fmt.Sprintf("отказ пакетом %s (из-за %s)", code, sec)
}
}
}
@@ -304,15 +383,18 @@ func finishResult(res *depoResult) {
func partyChecks(p depoParty, ser, num, fio string) []depoCheck {
ser = strings.ReplaceAll(strings.TrimSpace(ser), " ", "")
return []depoCheck{
{Name: "ФИО + документ инвестора", OK: p.faceOK && p.fioMatch, Code: "M2M04",
Detail: pick3(p.faceOK, pick3(p.fioMatch, p.faceName, "паспорт найден, но ФИО не совпадает: в картотеке «"+p.faceName+"», в запросе «"+fio+"»"),
// Инвестор — наш клиент: лицо по паспорту с тем же ФИО И хотя бы один торговый счёт (договор).
// Физлицо в картотеке без счёта у нас — не клиент депозитария: отказ M2M04, а не M2M02 (16.09.2026).
{Name: "ФИО + документ инвестора", OK: p.faceOK && p.fioMatch && p.accAny != "", Code: "M2M04",
Detail: pick3(p.faceOK, pick3(p.fioMatch,
pick3(p.accAny != "", p.faceName, p.faceName+" — есть в картотеке лиц, но торгового счёта (договора) у нас нет: не наш клиент"),
"паспорт найден, но ФИО не совпадает: в картотеке «"+p.faceName+"», в запросе «"+fio+"»"),
"паспорт "+ser+" № "+num+" не найден в картотеке (OD_FACES)")},
{Name: "Торговый счёт депо есть", OK: p.accAny != "", Code: "M2M02",
Detail: pick3(p.accAny != "", p.accAny, pick3(p.faceOK, "у инвестора нет счёта ВТНКЦ… в OD_CLIENTS", "не проверяется — инвестор не найден"))},
{Name: "Статус счёта строго «открыт»", OK: p.accOpen != "", Code: "M2M02",
Detail: pick3(p.accAny == "", "не проверяется — счёта нет", pick3(p.accOpen != "", p.accOpen+" — открыт (STATE=0, без даты закрытия)", p.accAny+" — НЕ открыт (закрыт или STATE≠0)"))},
{Name: "Счёт не ИИС", OK: p.iisOK, Code: "M2M08", Detail: p.iisDet},
{Name: "Квал-инвестор (если бумага только для квал.)", OK: p.qualOK, Warn: p.qualWarn, Code: "M2M08", Detail: p.qualDet},
}
}
@@ -352,6 +434,11 @@ func runDepoCheck(it *IncomingTransfer) depoResult {
Detail: pick3(found, vname+" (ID "+fmt.Sprint(valID)+")", "ISIN "+isin+" в справочнике OD_VALUES нет")})
sr.Checks = append(sr.Checks, depoCheck{Name: "Принята на обслуживание (галка)", OK: accepted, Code: "M2M05",
Detail: pick3(!found, "не проверяется — бумаги нет в справочнике", pick3(accepted, "галка стоит (IS_IN=1)", "бумага есть, но галка НЕ стоит (IS_IN≠1)"))})
qualOnly, qdet := false, ""
if found {
qualOnly, qdet = depoSecurityQualOnly(ctx, db, valID)
}
sr.Checks = append(sr.Checks, qualOnlyCheck(found, qualOnly, qdet, p))
sr.Checks = append(sr.Checks, partyChecks(p, it.DocSeries, it.DocNumber, fio)...)
blockOK, blockDet := true, "не проверяется — нет открытого счёта или бумаги"
if p.accOpen != "" && found {
@@ -472,6 +559,11 @@ func runDepoCheckOutAccount(last, first, middle, ser, num, birth string, secs []
valID, vname, found, _ := depoSecurity(ctx, db, isin)
sr.Checks = append(sr.Checks, depoCheck{Name: "Бумага есть в справочнике", OK: found, Code: "M2M05",
Detail: pick3(found, vname+" (ID "+fmt.Sprint(valID)+")", "ISIN "+isin+" в справочнике OD_VALUES нет")})
qualOnly, qdet := false, ""
if found {
qualOnly, qdet = depoSecurityQualOnly(ctx, db, valID)
}
sr.Checks = append(sr.Checks, outQualCheck(found, qualOnly, qdet, p))
sr.Checks = append(sr.Checks, partyChecks(p, ser, num, fio)...)
if birthCheck != nil {
sr.Checks = append(sr.Checks, *birthCheck)
+12 -1
View File
@@ -3,7 +3,7 @@ package lkgateway
import "testing"
func TestPartyChecksFIO(t *testing.T) {
p := depoParty{faceOK: true, faceName: "Сёмин Пётр Ильич"}
p := depoParty{faceOK: true, faceName: "Сёмин Пётр Ильич", accAny: "ВТНКЦ00000001"}
p.fioMatch = normFIO(p.faceName) == normFIO("СЕМИН петр ильич")
if c := partyChecks(p, "4500", "123456", "СЕМИН петр ильич")[0]; !c.OK {
t.Errorf("регистр, пробелы и ё не должны давать расхождение: %+v", c)
@@ -13,4 +13,15 @@ func TestPartyChecksFIO(t *testing.T) {
if c.OK || c.Code != "M2M04" {
t.Errorf("паспорт совпал, ФИО нет — ожидали отказ M2M04: %+v", c)
}
// Физлицо в картотеке без торгового счёта — не наш клиент: M2M04 первой проверкой.
noAcc := depoParty{faceOK: true, faceName: "Баранкин Сергей Сергеевич", fioMatch: true}
checks := partyChecks(noAcc, "3763", "290089", "Баранкин Сергей Сергеевич")
if c := checks[0]; c.OK || c.Code != "M2M04" {
t.Errorf("лицо без счёта — ожидали отказ M2M04: %+v", c)
}
sr := depoSecResult{Checks: checks}
finishSec(&sr)
if sr.RejectCode != "M2M04" {
t.Errorf("итоговый код по бумаге %q, ожидали M2M04", sr.RejectCode)
}
}
+93
View File
@@ -0,0 +1,93 @@
package lkgateway
import (
"strings"
"testing"
)
// Пакет принимается только целиком: отказ по одной бумаге — отказ по всем с тем же кодом.
func TestFinishResultWholePackage(t *testing.T) {
res := depoResult{Secs: []depoSecResult{
{Security: "RU0009029540", Recommend: "confirm"},
{Security: "RU0007661625", Recommend: "reject", RejectCode: "M2M05"},
{Security: "RU000A0JUR61", Recommend: "confirm"},
}}
finishResult(&res)
if res.Recommend != "reject" || !strings.Contains(res.Summary, "отказ пакетом M2M05") {
t.Fatalf("итог: %s %q", res.Recommend, res.Summary)
}
for _, s := range res.Secs {
if s.Recommend != "reject" || s.RejectCode != "M2M05" {
t.Fatalf("бумага %s: %s %s", s.Security, s.Recommend, s.RejectCode)
}
}
if last := res.Secs[0].Checks[len(res.Secs[0].Checks)-1]; last.OK || !strings.Contains(last.Detail, "RU0007661625") {
t.Fatalf("пояснение отказа пакетом: %+v", last)
}
ok := depoResult{Secs: []depoSecResult{{Recommend: "confirm"}, {Recommend: "confirm"}}}
finishResult(&ok)
if ok.Recommend != "confirm" {
t.Fatalf("все бумаги прошли: %s", ok.Recommend)
}
}
func TestPackageDecisions(t *testing.T) {
in := []SecurityDecision{{ReferenceID: "A", Confirm: true}, {ReferenceID: "B", RejectCode: "M2M08"}, {ReferenceID: "C", Confirm: true}}
out := packageDecisions(in)
for _, d := range out {
if d.Confirm || d.RejectCode != "M2M08" {
t.Fatalf("частичное согласие не должно уходить: %+v", out)
}
}
if !in[0].Confirm {
t.Fatal("исходный срез не должен меняться")
}
all := []SecurityDecision{{ReferenceID: "A", Confirm: true}, {ReferenceID: "B", Confirm: true}}
if got := packageDecisions(all); !got[0].Confirm || !got[1].Confirm {
t.Fatalf("согласие по всем: %+v", got)
}
}
func TestQualOnlyCheck(t *testing.T) {
notQual := depoParty{faceOK: true, qualWarn: true, qualDet: "инвестор НЕ квалифицированный"}
qualAll := depoParty{faceOK: true, qualAll: true}
partial := depoParty{faceOK: true, qualDet: "инвестор — квалифицированный (опция DEPO): облигации"}
const q = "бумага предназначена для квалифицированных инвесторов (опция DEPO)"
if c := qualOnlyCheck(true, true, q, notQual); c.OK || c.Code != "M2M08" {
t.Fatalf("неквал + бумага для квал: %+v", c)
}
if c := qualOnlyCheck(true, true, q, qualAll); !c.OK || c.Warn {
t.Fatalf("квал по всем видам: %+v", c)
}
if c := qualOnlyCheck(true, true, q, partial); !c.OK || !c.Warn {
t.Fatalf("квал по части видов — решает оператор: %+v", c)
}
if c := qualOnlyCheck(true, false, "ограничений по обращению нет (опция DEPO)", notQual); !c.OK || c.Warn {
t.Fatalf("бумага без ограничений: %+v", c)
}
if c := qualOnlyCheck(true, true, q, depoParty{}); !c.OK {
t.Fatalf("инвестор не найден — отказ даёт M2M04, не эта проверка: %+v", c)
}
}
// Списание: бумага для квал. инвесторов у неквалифицированного у нас клиента — предупреждение, не отказ.
func TestOutQualCheck(t *testing.T) {
const q = "бумага предназначена для квалифицированных инвесторов (опция DEPO)"
notQual := depoParty{faceOK: true, qualWarn: true, qualDet: "инвестор НЕ квалифицированный"}
if c := outQualCheck(true, true, q, notQual); !c.OK || !c.Warn || c.Strict {
t.Fatalf("неквал у нас отправляет бумагу для квал: %+v", c)
}
if c := outQualCheck(true, true, q, depoParty{faceOK: true, qualAll: true}); !c.OK || c.Warn {
t.Fatalf("квал по всем видам: %+v", c)
}
if c := outQualCheck(true, false, "ограничений по обращению нет (опция DEPO)", notQual); !c.OK || c.Warn {
t.Fatalf("бумага без ограничений — зелёный: %+v", c)
}
for _, c := range partyChecks(notQual, "1", "2", "x") {
if strings.HasPrefix(c.Name, "Квал") {
t.Fatalf("общая жёлтая строка квалификации больше не нужна: %+v", c)
}
}
}
+2 -2
View File
@@ -87,8 +87,8 @@ func registerDepoSim(mux *http.ServeMux, a *admin) {
Mode, Last, First, Middle, Ser, Num, ISIN, Code, Qty, Account, Section string
}{
Mode: v("mode", "in"),
Last: v("last", "Силаев"), First: v("first", "Михаил"), Middle: v("middle", "Иванович"),
Ser: v("ser", "4505"), Num: v("num", "620343"),
Last: v("last", "Иванов"), First: v("first", "Иван"), Middle: v("middle", "Иванович"),
Ser: v("ser", "0000"), Num: v("num", "000000"),
ISIN: strings.ToUpper(v("isin", "RU0009029540")), Code: v("code", ""), Qty: v("qty", "10"),
Account: v("account", defAcc), Section: v("section", defSec),
}
+87 -109
View File
@@ -2,11 +2,12 @@ package lkgateway
// Обмен с DEPO (Fancy) через трансферные таблицы в нашей копии базы
// (BJ_DEPO_DATA): FTV_M2M / FTV_M2M_LINES / FTV_M2M_EVENTS / FTV_M2M_HANDBOOK.
// Согласовано с программистом Fancy 09–10.09.2026 (страница /admin/help/fancy-events):
// - bj пишет поручения и события BJ_*; джоба bj2depo создаёт сущности DEPO и
// пишет в ответ только DEPO_ORDER_CREATED / DEPO_ERROR;
// - справочник участников — TRUNCATE + полная перезапись при каждом получении;
// - STATE: TODO при записи → SUCCESS/ERROR после обработки получателем.
// Согласовано с программистом Fancy (страница /admin/help/fancy-events), уточнено 17.09.2026:
// - bj — мастер: пишет поручения и события BJ_*; джоба bj2depo создаёт сущности DEPO, событий от
// себя не пишет — результат bj читает в FTV_M2M.DOC и в STATE/COMMENT своих событий;
// - COMMENT и STATE заполняет только bj2depo (STATE по умолчанию TODO); параметры события bj пишет
// в PARAMETERS; EVENT_DATE — фактическое время события в bj;
// - справочник участников — TRUNCATE + полная перезапись при каждом получении.
import (
"context"
@@ -124,13 +125,15 @@ func depoTableExists(ctx context.Context, db *sql.DB, name string) bool {
var depoTablesWarned time.Time
// depoEvent — одна строка FTV_M2M_EVENTS (идемпотентно по GUID+REFERENCE+EVENT_TYPE), дата = сейчас.
func depoEvent(rc *RuntimeConfig, guid, ref, typ, codes, comment string) {
depoEventAt(rc, time.Time{}, guid, ref, typ, codes, comment)
// depoEvent — одна строка FTV_M2M_EVENTS (идемпотентно по GUID+REFERENCE+EVENT_TYPE), время события — сейчас.
// params — параметры события для bj2depo (PARAMETERS): номер поручения НРД, номер поручения МНП, текст причины отказа.
func depoEvent(rc *RuntimeConfig, guid, ref, typ, codes, params string) {
depoEventAt(rc, time.Now(), guid, ref, typ, codes, params)
}
// depoEventAt — то же с явной датой события (для выгрузки истории).
func depoEventAt(rc *RuntimeConfig, at time.Time, guid, ref, typ, codes, comment string) {
// depoEventAt — то же с явным временем события (выгрузка истории). COMMENT и STATE не пишем:
// их заполняет bj2depo (STATE по умолчанию TODO).
func depoEventAt(rc *RuntimeConfig, at time.Time, guid, ref, typ, codes, params string) {
guid = strings.ToUpper(strings.TrimSpace(guid))
if !uuidRe.MatchString(guid) {
return // тестовые/служебные идентификаторы в DEPO не отдаём
@@ -149,8 +152,11 @@ func depoEventAt(rc *RuntimeConfig, at time.Time, guid, ref, typ, codes, comment
}
return
}
if len([]rune(comment)) > 4000 {
comment = string([]rune(comment)[:4000])
if at.IsZero() {
at = time.Now()
}
if len([]rune(params)) > 4000 {
params = string([]rune(params)[:4000])
}
var n int
_ = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM dbo.FTV_M2M_EVENTS WHERE GUID=@g AND ISNULL(REFERENCE,'')=@r AND EVENT_TYPE=@t",
@@ -158,17 +164,18 @@ func depoEventAt(rc *RuntimeConfig, at time.Time, guid, ref, typ, codes, comment
if n > 0 {
return
}
// Параметр даты — типизированный NULL: при нетипизированном nil драйвер шлёт nvarchar(1),
// ISNULL берёт тип первого аргумента и GETDATE() обрезается до одного символа.
evDate := sql.NullTime{Time: at, Valid: !at.IsZero()}
_, err = db.ExecContext(ctx, `INSERT INTO dbo.FTV_M2M_EVENTS (GUID, REFERENCE, STATE, EVENT_TYPE, EVENT_DATE, M2M_REJECT_CODE, COMMENT)
VALUES (@g, @r, 'TODO', @t, COALESCE(@at, GETDATE()), @c, @cm)`,
sql.Named("g", guid), sql.Named("r", nullStr(ref)), sql.Named("t", typ), sql.Named("at", evDate), sql.Named("c", nullStr(codes)), sql.Named("cm", nullStr(comment)))
q := `INSERT INTO dbo.FTV_M2M_EVENTS (GUID, REFERENCE, EVENT_TYPE, EVENT_DATE, M2M_REJECT_CODE) VALUES (@g, @r, @t, @at, @c)`
if depoHasColumn(ctx, db, "FTV_M2M_EVENTS", "PARAMETERS") {
q = `INSERT INTO dbo.FTV_M2M_EVENTS (GUID, REFERENCE, EVENT_TYPE, EVENT_DATE, M2M_REJECT_CODE, PARAMETERS) VALUES (@g, @r, @t, @at, @c, @p)`
}
_, err = db.ExecContext(ctx, q,
sql.Named("g", guid), sql.Named("r", nullStr(ref)), sql.Named("t", typ), sql.Named("at", at.In(time.FixedZone("МСК", 3*3600)).Format("2006-01-02T15:04:05.000")),
sql.Named("c", nullStr(codes)), sql.Named("p", nullStr(params)))
if err != nil {
log.Printf("depo-events: %s %s/%s: %v", typ, shortGUID(guid), ref, err)
return
}
log.Printf("depo-events: %s %s %s %s", typ, shortGUID(guid), ref, codes)
log.Printf("depo-events: %s %s %s %s %s", typ, shortGUID(guid), ref, codes, params)
}
type depoLine struct {
@@ -206,15 +213,27 @@ func depoUpsertOrder(rc *RuntimeConfig, guid, direction, cpCode, deponentName, d
if serN != "" && strings.TrimSpace(num) != "" {
_ = db.QueryRowContext(ctx, "SELECT TOP 1 SELF_ID FROM OD_FACES WHERE REPLACE(DOC_SER,' ','')=@s AND DOC_NUM=@n ORDER BY ID", sql.Named("s", serN), sql.Named("n", strings.TrimSpace(num))).Scan(&deponentID)
}
// счёт клиента для DEPO_NUMBER строк: открытый торговый ВТНКЦ…, при нескольких договорах — самый
// ранний (то же правило, что при зачислении в QUIK); джоба по номеру находит OD_CLIENTS.ID (DEPO).
var clientAcc sql.NullString
if serN != "" && strings.TrimSpace(num) != "" {
_ = db.QueryRowContext(ctx, `SELECT TOP 1 c.NUMBER FROM OD_FACES f
JOIN OD_CLIENTS c ON c.FACE=f.ID LEFT JOIN OD_CLIENTS_PORTS p ON p.CLIENT=c.ID
WHERE REPLACE(f.DOC_SER,' ','')=@s AND f.DOC_NUM=@n AND c.NUMBER LIKE N'ВТНКЦ%' AND c.STATE=0 AND c.D_CLOSE IS NULL
ORDER BY ISNULL(p.PORT_DATE,'2099-01-01'), ISNULL(c.D_OPEN,'2099-01-01'), c.NUMBER`,
sql.Named("s", serN), sql.Named("n", strings.TrimSpace(num))).Scan(&clientAcc)
}
hasDepoNumber := depoHasColumn(ctx, db, "FTV_M2M_LINES", "DEPO_NUMBER")
hasDeponentID := depoHasColumn(ctx, db, "FTV_M2M", "DEPONENT_ID")
tx, err := db.BeginTx(ctx, nil)
if err != nil {
log.Printf("depo-events: FTV_M2M %s: %v", shortGUID(guid), err)
return false
}
head := `INSERT INTO dbo.FTV_M2M (GUID, DIRECTION, CONTRAGENT_M2M_CODE, DEPONENT_NAME, DEPONENT_DOC, COMMENT) VALUES (@g, @d, @cp, @nm, @doc, @cm)`
// COMMENT не пишем — его заполняет bj2depo (17.09.2026); comment остаётся только для журнала bj.
head := `INSERT INTO dbo.FTV_M2M (GUID, DIRECTION, CONTRAGENT_M2M_CODE, DEPONENT_NAME, DEPONENT_DOC) VALUES (@g, @d, @cp, @nm, @doc)`
if hasDeponentID {
head = `INSERT INTO dbo.FTV_M2M (GUID, DIRECTION, CONTRAGENT_M2M_CODE, DEPONENT_NAME, DEPONENT_DOC, DEPONENT_ID, COMMENT) VALUES (@g, @d, @cp, @nm, @doc, @did, @cm)`
head = `INSERT INTO dbo.FTV_M2M (GUID, DIRECTION, CONTRAGENT_M2M_CODE, DEPONENT_NAME, DEPONENT_DOC, DEPONENT_ID) VALUES (@g, @d, @cp, @nm, @doc, @did)`
}
if _, err := tx.ExecContext(ctx, head,
sql.Named("g", guid), sql.Named("d", direction), sql.Named("cp", nullStr(cpCode)), sql.Named("nm", nullStr(deponentName)), sql.Named("doc", nullStr(deponentDoc)),
@@ -230,12 +249,16 @@ func depoUpsertOrder(rc *RuntimeConfig, guid, direction, cpCode, deponentName, d
if ln.ISIN != "" && hasShare {
_ = db.QueryRowContext(ctx, "SELECT TOP 1 ID FROM OD_VALUES WHERE ISIN=@i ORDER BY ID", sql.Named("i", ln.ISIN)).Scan(&share)
}
q := `INSERT INTO dbo.FTV_M2M_LINES (GUID, REFERENCE, ISIN, SEC_CODE, AMOUNT, PLACE_NUMBER, LINK_NUMBER) VALUES (@g, @r, @i, @c, @a, @p, '')`
cols, vals := "GUID, REFERENCE, ISIN, SEC_CODE, AMOUNT, PLACE_NUMBER, LINK_NUMBER", "@g, @r, @i, @c, @a, @p, ''"
if hasShare {
q = `INSERT INTO dbo.FTV_M2M_LINES (GUID, REFERENCE, ISIN, SEC_CODE, SHARE, AMOUNT, PLACE_NUMBER, LINK_NUMBER) VALUES (@g, @r, @i, @c, @sh, @a, @p, '')`
cols, vals = cols+", SHARE", vals+", @sh"
}
if hasDepoNumber {
cols, vals = cols+", DEPO_NUMBER", vals+", @acc"
}
q := `INSERT INTO dbo.FTV_M2M_LINES (` + cols + `) VALUES (` + vals + `)`
if _, err := tx.ExecContext(ctx, q,
sql.Named("g", guid), sql.Named("r", ln.Ref), sql.Named("i", nullStr(ln.ISIN)), sql.Named("c", nullStr(ln.Code)), sql.Named("sh", share), sql.Named("a", ln.Amount), sql.Named("p", nullStr(ln.Place))); err != nil {
sql.Named("g", guid), sql.Named("r", ln.Ref), sql.Named("i", nullStr(ln.ISIN)), sql.Named("c", nullStr(ln.Code)), sql.Named("sh", share), sql.Named("a", ln.Amount), sql.Named("p", nullStr(ln.Place)), sql.Named("acc", clientAcc)); err != nil {
tx.Rollback()
log.Printf("depo-events: FTV_M2M_LINES %s/%s: %v", shortGUID(guid), ln.Ref, err)
return false
@@ -245,7 +268,7 @@ func depoUpsertOrder(rc *RuntimeConfig, guid, direction, cpCode, deponentName, d
log.Printf("depo-events: commit %s: %v", shortGUID(guid), err)
return false
}
log.Printf("depo-events: FTV_M2M %s %s создано (%d строк)", direction, shortGUID(guid), len(lines))
log.Printf("depo-events: FTV_M2M %s %s создано (%d строк, счёт клиента %s)", direction, shortGUID(guid), len(lines), pickStr(clientAcc.String, "не найден"))
return true
}
@@ -295,7 +318,7 @@ func depoOnIncomingReceived(rc *RuntimeConfig, it *IncomingTransfer) {
name := strings.TrimSpace(strings.Join([]string{it.InvestorLast, it.InvestorFirst, it.InvestorMiddle}, " "))
doc := strings.TrimSpace(it.DocSeries + " " + it.DocNumber)
if depoUpsertOrder(rc, it.GUID, "IN", it.SenderCode, name, doc, it.DocSeries, it.DocNumber, lines, "входящий перевод M2M от "+it.SenderCode) {
depoEvent(rc, it.GUID, "", "BJ_RECEIVED", "", "входящий перевод получен, ждёт решения")
depoEvent(rc, it.GUID, "", "BJ_RECEIVED", "", "")
}
}
@@ -320,7 +343,7 @@ func depoOnOutgoingSent(rc *RuntimeConfig, req *m2m.M2MTransferRequest) {
name := strings.TrimSpace(strings.Join([]string{inv.LastName, inv.FirstName, inv.MiddleName}, " "))
guid := string(req.Header.GUID)
if depoUpsertOrder(rc, guid, "OUT", cp, name, strings.TrimSpace(ser+" "+num), ser, num, lines, "исходящий перевод M2M, получатель ИНН "+inn) {
depoEvent(rc, guid, "", "BJ_SENT", "", "запрос на перевод отправлен в НРД")
depoEvent(rc, guid, "", "BJ_SENT", "", "")
}
}
@@ -331,9 +354,9 @@ func depoOnIncomingDecision(rc *RuntimeConfig, it *IncomingTransfer, decisions [
}
for _, d := range decisions {
if d.Confirm {
depoEvent(rc, it.GUID, d.ReferenceID, "BJ_DECISION_CONFIRM", "", "приём бумаги подтверждён контрагенту")
depoEvent(rc, it.GUID, d.ReferenceID, "BJ_DECISION_CONFIRM", "", "")
} else {
depoEvent(rc, it.GUID, d.ReferenceID, "BJ_DECISION_REJECT", d.RejectCode, "отказ контрагенту: "+m2mCodeLabel(d.RejectCode))
depoEvent(rc, it.GUID, d.ReferenceID, "BJ_DECISION_REJECT", d.RejectCode, m2mCodeLabel(d.RejectCode))
}
}
}
@@ -348,33 +371,33 @@ func depoOnCounterpartyDecision(rc *RuntimeConfig, decision *m2m.M2MTransferDeci
ref := string(sec.ReferenceID)
if sec.TransferDecision.Rejection != nil {
codes := strings.Join(sec.TransferDecision.Rejection.Codes, ";")
depoEvent(rc, guid, ref, "BJ_CP_REJECT", codes, "отказ принимающего депозитария: "+rejectCodesLabel(sec.TransferDecision.Rejection.Codes))
depoEvent(rc, guid, ref, "BJ_CP_REJECT", codes, rejectCodesLabel(sec.TransferDecision.Rejection.Codes))
} else if sec.TransferDecision.Confirmation != nil {
depoEvent(rc, guid, ref, "BJ_CP_CONFIRM", "", "принимающий депозитарий подтвердил")
depoEvent(rc, guid, ref, "BJ_CP_CONFIRM", "", "")
}
}
}
func depoOnServiceError(rc *RuntimeConfig, guid, codes, reason string) {
depoEvent(rc, guid, "", "BJ_SERVICE_ERROR", strings.ReplaceAll(strings.TrimSpace(codes), " ", ";"), "отклонено сервисом MOEX МОСТ: "+reason)
depoEvent(rc, guid, "", "BJ_SERVICE_ERROR", strings.ReplaceAll(strings.TrimSpace(codes), " ", ";"), reason)
}
func depoOnOrderSent(rc *RuntimeConfig, guid string, contrDIDs []string, form string) {
depoEvent(rc, guid, "", "BJ_ORDER_SENT", "", "подано депо-поручение "+form+" в НРД, CONTR_D_ID "+strings.Join(contrDIDs, ", "))
depoEvent(rc, guid, "", "BJ_ORDER_SENT", "", strings.Join(contrDIDs, ";"))
}
// результат по депо-поручению: accepted | rejected | late | expired
func depoOnOrderResult(rc *RuntimeConfig, guid, kind, text string) {
switch kind {
case "accepted":
depoEvent(rc, guid, "", "BJ_ORDER_ACCEPTED", "", "НРД принял депо-поручение (отчёт MS010)")
depoEvent(rc, guid, "", "BJ_EXECUTED", "", "перевод исполнен (MS010)")
depoEvent(rc, guid, "", "BJ_ORDER_ACCEPTED", "", "")
depoEvent(rc, guid, "", "BJ_EXECUTED", "", "")
case "rejected":
depoEvent(rc, guid, "", "BJ_ORDER_REJECTED", "", "НРД отверг депо-поручение: "+text)
depoEvent(rc, guid, "", "BJ_ORDER_REJECTED", "", text)
case "late":
depoEvent(rc, guid, "", "BJ_ORDER_LATE", "", "отчёт MS010 не получен за 5 минут")
depoEvent(rc, guid, "", "BJ_ORDER_LATE", "", "")
case "expired":
depoEvent(rc, guid, "", "BJ_CANCELLED", "", "истёк срок 30 дней без исполнения — перевод считается отменённым")
depoEvent(rc, guid, "", "BJ_CANCELLED", "", "")
}
}
@@ -383,7 +406,7 @@ func depoOnQuik(rc *RuntimeConfig, guid, isin, evt, instrNo string) {
if typ == "" {
return
}
depoEvent(rc, guid, "", typ, "", "QUIK МНП: поручение №"+instrNo+", "+isin)
depoEvent(rc, guid, "", typ, "", instrNo)
}
// ---- чтение событий DEPO ----
@@ -399,55 +422,10 @@ func depoPollEvents(ctx context.Context, rc *RuntimeConfig) {
if !depoTableExists(cctx, db, "FTV_M2M_EVENTS") {
return
}
docCol := "NULL"
if depoHasColumn(cctx, db, "FTV_M2M", "DOC") {
docCol = "(SELECT TOP 1 CAST(m.DOC AS varchar(32)) FROM dbo.FTV_M2M m WHERE m.GUID=e.GUID)"
} else if depoHasColumn(cctx, db, "FTV_M2M", "FANSY_DOC") {
docCol = "(SELECT TOP 1 CAST(m.FANSY_DOC AS varchar(32)) FROM dbo.FTV_M2M m WHERE m.GUID=e.GUID)"
}
// Событий от bj2depo нет (17.09.2026: мастер — bj): номер документа — FTV_M2M.DOC, результат
// обработки — STATE/COMMENT наших событий и шапки.
depoPollOrders(cctx, db, rc)
depoAlertEventFailures(cctx, db, rc)
rows, err := db.QueryContext(cctx, `SELECT TOP 100 e.ID, CAST(e.GUID AS varchar(36)), ISNULL(e.REFERENCE,''), e.EVENT_TYPE, ISNULL(e.COMMENT,''), ISNULL(e.M2M_REJECT_CODE,''),
ISNULL(`+docCol+`,'')
FROM dbo.FTV_M2M_EVENTS e WHERE e.EVENT_TYPE LIKE 'DEPO[_]%' AND ISNULL(e.STATE,'TODO')='TODO' ORDER BY e.ID`)
if err != nil {
log.Printf("depo-events: чтение: %v", err)
return
}
type ev struct {
id int64
guid, ref, typ, comment, codes, doc string
}
var list []ev
for rows.Next() {
var e ev
if rows.Scan(&e.id, &e.guid, &e.ref, &e.typ, &e.comment, &e.codes, &e.doc) == nil {
list = append(list, e)
}
}
rows.Close()
for _, e := range list {
state, note := "SUCCESS", "применено в bj"
switch e.typ {
case "DEPO_ORDER_CREATED":
doc := strings.TrimSpace(e.comment)
if e.doc != "" && e.doc != "0" {
doc = e.doc
}
globalDepoLinks.update(e.guid, func(l *DepoLink) { l.DocNo, l.DocAt, l.LastEvent = doc, time.Now(), e.typ })
log.Printf("depo-events: DEPO создала поручение по %s: %s", shortGUID(e.guid), doc)
case "DEPO_ERROR":
globalDepoLinks.update(e.guid, func(l *DepoLink) {
l.Errors = append(l.Errors, time.Now().Format("02.01 15:04")+" "+e.comment)
l.LastEvent = e.typ
})
raiseSystemAlert(rc, "DEPO: ошибка обработки перевода "+shortGUID(e.guid), e.comment)
default:
state, note = "ERROR", "тип события не поддерживается bj"
}
_, _ = db.ExecContext(cctx, "UPDATE dbo.FTV_M2M_EVENTS SET STATE=@s, TimeBeg=ISNULL(TimeBeg,GETDATE()), TimeEnd=GETDATE(), COMMENT=ISNULL(COMMENT,'')+@n WHERE ID=@id",
sql.Named("s", state), sql.Named("n", " | bj: "+note), sql.Named("id", e.id))
}
}
// depoPollOrders — джоба bj2depo ставит номер поручения DEPO прямо в FTV_M2M.DOC (и DOL по строкам),
@@ -730,7 +708,7 @@ ALTER ROLE SQLAgentOperatorRole ADD MEMBER %[1]s;
func depoJobPing(ctx context.Context, db *sql.DB, wait time.Duration) (bool, string) {
guid := strings.ToUpper(fmt.Sprintf("%08x-0000-4000-8000-%012x", time.Now().Unix()&0xFFFFFFFF, time.Now().UnixNano()&0xFFFFFFFFFFFF))
if _, err := db.ExecContext(ctx, "INSERT INTO dbo.FTV_M2M_EVENTS (GUID, REFERENCE, STATE, EVENT_TYPE, EVENT_DATE, COMMENT) VALUES (@g, NULL, 'TODO', 'BJ_PING', GETDATE(), N'проверка обработчика из bj')", sql.Named("g", guid)); err != nil {
if _, err := db.ExecContext(ctx, "INSERT INTO dbo.FTV_M2M_EVENTS (GUID, REFERENCE, EVENT_TYPE, EVENT_DATE) VALUES (@g, NULL, 'BJ_PING', GETDATE())", sql.Named("g", guid)); err != nil {
return false, "не удалось записать проверочное событие: " + quikErrorDetail(err)
}
defer db.ExecContext(context.Background(), "DELETE FROM dbo.FTV_M2M_EVENTS WHERE GUID=@g", sql.Named("g", guid))
@@ -883,27 +861,27 @@ func depoReplayHistory(ctx context.Context, rc *RuntimeConfig, svc *Service, onl
cp := handbookNameByCode(it.SenderCode)
depoUpsertOrder(rc, it.GUID, "IN", it.SenderCode, name, strings.TrimSpace(it.DocSeries+" "+it.DocNumber), it.DocSeries, it.DocNumber, lines, "TEST3: входящий от "+it.SenderCode+" "+cp)
nIn++
ev(it.ReceivedAt, it.GUID, "", "BJ_RECEIVED", "", "входящий перевод получен от "+it.SenderCode+" "+cp)
ev(it.ReceivedAt, it.GUID, "", "BJ_RECEIVED", "", "")
if it.Status == "answered" {
for _, s := range it.Securities {
switch s.Decision {
case "confirmed":
ev(it.AnsweredAt, it.GUID, s.ReferenceID, "BJ_DECISION_CONFIRM", "", "приём подтверждён контрагенту")
ev(it.AnsweredAt, it.GUID, s.ReferenceID, "BJ_DECISION_CONFIRM", "", "")
case "rejected":
ev(it.AnsweredAt, it.GUID, s.ReferenceID, "BJ_DECISION_REJECT", s.RejectCode, "отказ контрагенту: "+m2mCodeLabel(s.RejectCode))
ev(it.AnsweredAt, it.GUID, s.ReferenceID, "BJ_DECISION_REJECT", s.RejectCode, m2mCodeLabel(s.RejectCode))
}
}
}
if it.OrderSubmitted {
ev(it.OrderAt, it.GUID, "", "BJ_ORDER_SENT", "", "подано депо-поручение 16/1 (зачисление), CONTR_D_ID "+it.OrderContrDID)
ev(it.OrderAt, it.GUID, "", "BJ_ORDER_SENT", "", it.OrderContrDID)
resps := findOrderResponses(it.OrderContrDID)
if done, rejected, text := orderRespVerdict(resps); done {
at := orderRespAt(resps)
if rejected {
ev(at, it.GUID, "", "BJ_ORDER_REJECTED", "", "НРД отверг депо-поручение: "+text)
ev(at, it.GUID, "", "BJ_ORDER_REJECTED", "", text)
} else {
ev(at, it.GUID, "", "BJ_ORDER_ACCEPTED", "", "НРД принял депо-поручение (отчёт MS010)")
ev(at, it.GUID, "", "BJ_EXECUTED", "", "перевод исполнен (MS010)")
ev(at, it.GUID, "", "BJ_ORDER_ACCEPTED", "", "")
ev(at, it.GUID, "", "BJ_EXECUTED", "", "")
}
}
}
@@ -942,14 +920,14 @@ func depoReplayHistory(ctx context.Context, rc *RuntimeConfig, svc *Service, onl
name := strings.TrimSpace(strings.Join([]string{inv.LastName, inv.FirstName, inv.MiddleName}, " "))
depoUpsertOrder(rc, guid, "OUT", cp, name, strings.TrimSpace(ser+" "+num), ser, num, lines, "TEST3: исходящий, получатель "+cp+" "+handbookNameByCode(cp))
nOut++
ev(d.CreatedAt, guid, "", "BJ_SENT", "", "запрос на перевод отправлен в НРД")
ev(d.CreatedAt, guid, "", "BJ_SENT", "", "")
if d.Decision != nil {
for _, sec := range d.Decision.Data.Securities {
ref := string(sec.ReferenceID)
if sec.TransferDecision.Rejection != nil {
ev(d.UpdatedAt, guid, ref, "BJ_CP_REJECT", strings.Join(sec.TransferDecision.Rejection.Codes, ";"), "отказ принимающего депозитария: "+rejectCodesLabel(sec.TransferDecision.Rejection.Codes))
ev(d.UpdatedAt, guid, ref, "BJ_CP_REJECT", strings.Join(sec.TransferDecision.Rejection.Codes, ";"), rejectCodesLabel(sec.TransferDecision.Rejection.Codes))
} else if sec.TransferDecision.Confirmation != nil {
ev(d.UpdatedAt, guid, ref, "BJ_CP_CONFIRM", "", "принимающий депозитарий подтвердил")
ev(d.UpdatedAt, guid, ref, "BJ_CP_CONFIRM", "", "")
}
}
}
@@ -958,19 +936,19 @@ func depoReplayHistory(ctx context.Context, rc *RuntimeConfig, svc *Service, onl
for _, rr := range d.Response.Responses {
codes = append(codes, string(rr.Code))
}
ev(d.UpdatedAt, guid, "", "BJ_SERVICE_ERROR", strings.Join(codes, ";"), "отклонено сервисом MOEX МОСТ: "+rejectCodesLabel(codes))
ev(d.UpdatedAt, guid, "", "BJ_SERVICE_ERROR", strings.Join(codes, ";"), rejectCodesLabel(codes))
}
if globalOutgoingStore != nil {
if od, ok := scanForm16Deal(globalOutgoingStore, guid); ok && od.Submitted {
ev(od.OrderAt, guid, "", "BJ_ORDER_SENT", "", "подано депо-поручение 16 (списание), CONTR_D_ID "+strings.Join(od.ContrDIDs, ", "))
ev(od.OrderAt, guid, "", "BJ_ORDER_SENT", "", strings.Join(od.ContrDIDs, ";"))
resps, _ := outgoingResponses(od.ContrDIDs)
if done, rejected, text := orderRespVerdict(resps); done {
at := orderRespAt(resps)
if rejected {
ev(at, guid, "", "BJ_ORDER_REJECTED", "", "НРД отверг депо-поручение: "+text)
ev(at, guid, "", "BJ_ORDER_REJECTED", "", text)
} else {
ev(at, guid, "", "BJ_ORDER_ACCEPTED", "", "НРД принял депо-поручение (отчёт MS010)")
ev(at, guid, "", "BJ_EXECUTED", "", "перевод исполнен (MS010)")
ev(at, guid, "", "BJ_ORDER_ACCEPTED", "", "")
ev(at, guid, "", "BJ_EXECUTED", "", "")
}
}
}
@@ -1013,24 +991,24 @@ func depoSimulateIncoming(ctx context.Context, rc *RuntimeConfig, it *IncomingTr
return "", fmt.Errorf("не удалось записать шапку в FTV_M2M")
}
now := time.Now()
depoEventAt(rc, now, guid, "", "BJ_RECEIVED", "", "входящий перевод получен от "+it.SenderCode+" "+cp+" (тест по образцу "+shortGUID(it.GUID)+")")
depoEventAt(rc, now, guid, "", "BJ_RECEIVED", "", "")
anyConfirmed := false
for _, s := range it.Securities {
switch s.Decision {
case "confirmed":
anyConfirmed = true
depoEventAt(rc, now.Add(time.Minute), guid, s.ReferenceID, "BJ_DECISION_CONFIRM", "", "приём подтверждён контрагенту")
depoEventAt(rc, now.Add(time.Minute), guid, s.ReferenceID, "BJ_DECISION_CONFIRM", "", "")
case "rejected":
depoEventAt(rc, now.Add(time.Minute), guid, s.ReferenceID, "BJ_DECISION_REJECT", s.RejectCode, "отказ контрагенту: "+m2mCodeLabel(s.RejectCode))
depoEventAt(rc, now.Add(time.Minute), guid, s.ReferenceID, "BJ_DECISION_REJECT", s.RejectCode, m2mCodeLabel(s.RejectCode))
default:
anyConfirmed = true
depoEventAt(rc, now.Add(time.Minute), guid, s.ReferenceID, "BJ_DECISION_CONFIRM", "", "приём подтверждён контрагенту (тест)")
depoEventAt(rc, now.Add(time.Minute), guid, s.ReferenceID, "BJ_DECISION_CONFIRM", "", "")
}
}
if anyConfirmed {
depoEventAt(rc, now.Add(2*time.Minute), guid, "", "BJ_ORDER_SENT", "", "подано депо-поручение 16/1 (зачисление) в НРД (тест)")
depoEventAt(rc, now.Add(3*time.Minute), guid, "", "BJ_ORDER_ACCEPTED", "", "НРД принял депо-поручение (отчёт MS010) (тест)")
depoEventAt(rc, now.Add(3*time.Minute), guid, "", "BJ_EXECUTED", "", "перевод исполнен (MS010) (тест)")
depoEventAt(rc, now.Add(2*time.Minute), guid, "", "BJ_ORDER_SENT", "", "")
depoEventAt(rc, now.Add(3*time.Minute), guid, "", "BJ_ORDER_ACCEPTED", "", "")
depoEventAt(rc, now.Add(3*time.Minute), guid, "", "BJ_EXECUTED", "", "")
}
return guid, nil
}
+1 -1
View File
@@ -36,7 +36,7 @@ func TestDepoQualifiedLive(t *testing.T) {
if rows.Scan(&v, &face) != nil {
continue
}
warn, det := depoFaceQualified(ctx, db, face)
warn, _, det := depoFaceQualified(ctx, db, face)
t.Logf("VAL=%s лицо %d: предупреждение=%v — %s", v, face, warn, det)
}
}
+38
View File
@@ -0,0 +1,38 @@
//go:build live
package lkgateway
import (
"context"
"database/sql"
"testing"
"time"
)
// Вставка события в новом формате в транзакции с откатом: EVENT_DATE из bj, PARAMETERS, STATE по умолчанию.
func TestEventInsertRollback(t *testing.T) {
rc, _ := NewRuntimeConfig("/var/lib/bj/.bj/setup.json")
db, err := depoEventsDB(rc)
if err != nil {
t.Fatal(err)
}
defer db.Close()
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
tx, err := db.BeginTx(ctx, nil)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
at := time.Date(2026, 9, 17, 9, 22, 24, 0, time.FixedZone("МСК", 3*3600))
guid := "00000000-0000-4000-8000-000000000017"
_, err = tx.ExecContext(ctx, `INSERT INTO dbo.FTV_M2M_EVENTS (GUID, REFERENCE, EVENT_TYPE, EVENT_DATE, M2M_REJECT_CODE, PARAMETERS) VALUES (@g, @r, @t, @at, @c, @p)`,
sql.Named("g", guid), sql.Named("r", nullStr("")), sql.Named("t", "BJ_ORDER_SENT"), sql.Named("at", at.In(time.FixedZone("МСК", 3*3600)).Format("2006-01-02T15:04:05.000")),
sql.Named("c", nullStr("")), sql.Named("p", nullStr("20608")))
if err != nil {
t.Fatal(err)
}
var st, dt, par, cmt sql.NullString
err = tx.QueryRowContext(ctx, `SELECT STATE, CONVERT(varchar(23), EVENT_DATE, 121), CAST(PARAMETERS AS nvarchar(100)), COMMENT FROM dbo.FTV_M2M_EVENTS WHERE GUID=@g`, sql.Named("g", guid)).Scan(&st, &dt, &par, &cmt)
t.Logf("STATE=%q EVENT_DATE=%q PARAMETERS=%q COMMENT=%v (err %v) — откат", st.String, dt.String, par.String, cmt.Valid, err)
}
+1 -1
View File
@@ -269,7 +269,7 @@ func compareNote(va, vb ClaimView, identical bool) []byte {
b.WriteString("response.xml/decision.xml (ответ/решение НРД), а также отчёт otchet.html.\n")
b.WriteString("Все XML — в исходной кодировке windows-1251, дословно.\n\n")
b.WriteString("Просьба: пояснить причину изменения результата и порядок устранения\n")
b.WriteString("(в т.ч. если это связано с учётом тестовых ценных бумаг на стороне НРД).\n")
b.WriteString("(в т.ч. если это связано с особенностями учёта ценных бумаг на стороне НРД).\n")
return []byte(b.String())
}
+130
View File
@@ -0,0 +1,130 @@
package lkgateway
// Первичная настройка после установки. Пока в bj нет ни одного пользователя, установщик
// (cmd/bj-installer) оставляет файл с хешем одноразового кода, который показан на экране сервера.
// Все страницы, кроме этой, перенаправляются сюда: по коду создаётся первый администратор, файл
// удаляется, дальше обычный вход и раздел «Настройка». Установки без файла работают как раньше
// (открытый режим до создания первого пользователя).
import (
"encoding/json"
"html/template"
"log"
"net/http"
"os"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
const firstRunFile = "/var/lib/bj/.bj/first-run.json"
type firstRunState struct {
CodeHash string `json:"code_hash"`
CreatedAt time.Time `json:"created_at"`
}
// firstRunPending — ждём первичной настройки: пользователей нет, установщик оставил код.
func firstRunPending() bool {
if globalUsers.Count() > 0 {
return false
}
_, err := os.Stat(firstRunFile)
return err == nil
}
type firstRunData struct {
Error, Login, Name string
}
var firstRunTpl = template.Must(template.New("first-run").Parse(firstRunHTML))
func registerFirstRun(mux *http.ServeMux) {
mux.HandleFunc("/first-run", func(w http.ResponseWriter, r *http.Request) {
if !firstRunPending() {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
var d firstRunData
w.Header().Set("Cache-Control", "no-store")
if r.Method == http.MethodPost {
ip := clientIP(r)
key := "first-run|" + ip
d.Login, d.Name = strings.TrimSpace(r.FormValue("login")), strings.TrimSpace(r.FormValue("name"))
pw, pw2 := r.FormValue("password"), r.FormValue("password2")
code := strings.NewReplacer("-", "", " ", "").Replace(strings.TrimSpace(r.FormValue("code")))
var st firstRunState
raw, readErr := os.ReadFile(firstRunFile)
if readErr == nil {
_ = json.Unmarshal(raw, &st)
}
switch {
case loginFailCount(key) >= 8:
d.Error = "Слишком много неудачных попыток. Подождите 15 минут."
case readErr != nil || st.CodeHash == "" || bcrypt.CompareHashAndPassword([]byte(st.CodeHash), []byte(code)) != nil:
loginFailAdd(key)
time.Sleep(700 * time.Millisecond)
log.Printf("auth: первичная настройка: неверный код с %s", ip)
d.Error = "Неверный одноразовый код"
case d.Login == "" || strings.ContainsAny(d.Login, " /\\@\"'<>"):
d.Error = "Логин пустой или содержит недопустимые символы"
case len(pw) < 10 || pw != pw2:
d.Error = "Пароль не короче 10 символов и должен совпадать с повтором"
default:
hash, err := hashPassword(pw)
if err != nil {
d.Error = err.Error()
break
}
now := time.Now()
u := &User{Login: d.Login, Name: d.Name, Role: "admin", Source: "local", PassHash: hash, Active: true, CreatedAt: now, LastLogin: now}
if err := globalUsers.Put(u); err != nil {
d.Error = "Не удалось сохранить пользователя: " + err.Error()
break
}
_ = os.Remove(firstRunFile)
loginFailReset(key)
s := globalSessions.New(u.Login, ip, r.UserAgent())
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: s.Token, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: r.TLS != nil, MaxAge: int(sessionMaxLife.Seconds())})
log.Printf("auth: первичная настройка: создан администратор %s с %s", u.Login, ip)
http.Redirect(w, r, "/admin/setup", http.StatusSeeOther)
return
}
w.WriteHeader(http.StatusUnauthorized)
}
_ = firstRunTpl.Execute(w, d)
})
}
const firstRunHTML = `<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Первичная настройка · Bridge&amp;Join</title>
<style>
:root{--bg:#f4f5f9;--card:#fff;--text:#1a1d26;--muted:#6b7280;--accent:#3b4fd8;--err:#c62828;--border:#e3e5ee}
@media (prefers-color-scheme:dark){:root{--bg:#141721;--card:#1d2130;--text:#e6e8f0;--muted:#9aa0b3;--accent:#7c8cff;--border:#2b3044}}
body{margin:0;background:var(--bg);color:var(--text);font:14px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:16px}
.box{background:var(--card);border:1px solid var(--border);border-radius:14px;padding:28px 30px;width:100%;max-width:420px;box-shadow:0 12px 40px rgba(0,0,0,.08)}
.brand{display:flex;align-items:center;gap:10px;font-size:20px;font-weight:700;margin-bottom:4px}.brand em{font-style:normal;color:var(--accent)}
.sub{color:var(--muted);font-size:12.5px;margin-bottom:14px}
label{display:block;font-size:12px;color:var(--muted);margin:10px 0 4px}
input{width:100%;box-sizing:border-box;padding:9px 11px;border:1px solid var(--border);border-radius:8px;background:transparent;color:var(--text);font-size:14px}
input:focus{outline:2px solid var(--accent);outline-offset:1px;border-color:transparent}
input.code{font-size:20px;letter-spacing:.12em;text-align:center}
button{margin-top:16px;width:100%;padding:10px;border:0;border-radius:8px;background:var(--accent);color:#fff;font-weight:600;font-size:14px;cursor:pointer}
.err{background:rgba(198,40,40,.1);color:var(--err);padding:8px 10px;border-radius:8px;font-size:13px;margin-top:12px}
.hint{color:var(--muted);font-size:12px;margin-top:12px}
code{font-size:12px}
</style></head><body><div class="box">
<div class="brand"><svg width="26" height="26" viewBox="0 0 26 26" fill="none"><path d="M5.5 16 C 9 8.5, 17 8.5, 20.5 16" stroke="#3b4fd8" stroke-width="1.9" stroke-linecap="round"/><circle cx="5.5" cy="16" r="3.1" fill="#3b4fd8"/><circle cx="20.5" cy="16" r="3.1" fill="#e8562a"/></svg><span>Bridge<em>&amp;Join</em></span></div>
<div class="sub">Первичная настройка</div>
<p class="hint" style="margin-top:0">Введите одноразовый код, который установщик показал на экране сервера, и создайте учётную запись администратора. Остальные настройки откроются сразу после этого.</p>
<form method="post" action="/first-run">
<label>Одноразовый код</label><input class="code" type="text" name="code" inputmode="numeric" autocomplete="one-time-code" placeholder="0000-0000" autofocus required>
<label>Логин администратора</label><input type="text" name="login" value="{{.Login}}" autocomplete="username" required>
<label>Имя</label><input type="text" name="name" value="{{.Name}}" autocomplete="name">
<label>Пароль, не короче 10 символов</label><input type="password" name="password" minlength="10" autocomplete="new-password" required>
<label>Повтор пароля</label><input type="password" name="password2" minlength="10" autocomplete="new-password" required>
<button type="submit">Создать администратора</button>
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
<div class="hint">Код потерян или истёк: на сервере выполните <code>sudo bj-installer first-run-code</code>.</div>
</form>
</div></body></html>`
+37 -4
View File
@@ -36,6 +36,9 @@ func answerIncoming(ctx context.Context, deps incomingDeps, id string, decisions
if !ok {
return fmt.Errorf("входящий перевод %s не найден", id)
}
if it.Status == "answered" {
return fmt.Errorf("по переводу уже отправлено решение (%s)", it.AnswerNote)
}
if deps.igwClient == nil || deps.igwChan == "" {
return fmt.Errorf("ИШ не настроен — нельзя отправить решение")
}
@@ -47,6 +50,8 @@ func answerIncoming(ctx context.Context, deps incomingDeps, id string, decisions
}
nsd := cfg.NSD
decisions = packageDecisions(decisions)
// Карта решений по ReferenceID.
byRef := make(map[string]SecurityDecision, len(decisions))
for _, d := range decisions {
@@ -146,8 +151,36 @@ func answerIncoming(ctx context.Context, deps incomingDeps, id string, decisions
return nil
}
// transferringINN — ИНН нашей организации (принимающий депозитарий в решении).
// ИНН ООО ИК «Фонтвьель» (участник M2M MC0413600000).
func transferringINN(_ Settings) string {
return "7703807489"
// transferringINN — ИНН нашей организации (передающий депозитарий в запросе, принимающий — в решении).
func transferringINN(s Settings) string { return orgINN(s) }
// packageDecisions — пакет принимается только целиком (решение 16.09.2026): если хотя бы по одной бумаге
// отказ, отказываем по всем; принятым бумагам ставится код первой отказной.
func packageDecisions(decisions []SecurityDecision) []SecurityDecision {
code := ""
for _, d := range decisions {
if !d.Confirm {
code = d.RejectCode
break
}
}
if code == "" {
for _, d := range decisions {
if !d.Confirm {
code = "M2M05"
break
}
}
}
if code == "" {
return decisions
}
out := make([]SecurityDecision, len(decisions))
for i, d := range decisions {
if d.Confirm {
d.Confirm, d.RejectCode = false, code
}
out[i] = d
}
return out
}
@@ -0,0 +1,53 @@
package lkgateway
import (
"net/http/httptest"
"strings"
"testing"
"time"
)
func renderIncoming(t *testing.T, d incomingDetailData) string {
t.Helper()
a, err := newAdmin()
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
d.page = nowPage("Входящий перевод", "incoming")
render(rec, a.incomingDetail, d)
if rec.Code != 200 {
t.Fatalf("код %d: %s", rec.Code, rec.Body.String())
}
return rec.Body.String()
}
// Отклонённый перевод: 16/1 не требуется, операция завершена, без кнопок подачи и «черновик получен».
func TestIncomingDetailRejected(t *testing.T) {
it := &IncomingTransfer{ID: "x", GUID: "9F5E120D-EFD1-46EE-A9BA-35CB2E794B81", Status: "answered", Auto: true, AnsweredAt: time.Now(),
Securities: []IncomingSecurity{{ReferenceID: "R1", SecurityCode: "SBER/03", Decision: "rejected", RejectCode: "M2M04"}}}
rejected, codes := incomingRejectedAll(it)
if !rejected || codes != "M2M04" {
t.Fatalf("incomingRejectedAll: %v %q", rejected, codes)
}
body := renderIncoming(t, incomingDetailData{It: it, Rejected: rejected, RejectCodes: codes, AutoMode: true})
for _, want := range []string{"не требуется — перевод отклонён", "Операция завершена отказом", "автоматически", "Зачисление в QUIK не требуется"} {
if !strings.Contains(body, want) {
t.Errorf("нет %q", want)
}
}
for _, bad := range []string{"Подать поручение 16/1 в НРД", "Черновик от НРД", "нужно подать"} {
if strings.Contains(body, bad) {
t.Errorf("лишнее в завершённом переводе: %q", bad)
}
}
}
// Перевод ждёт решения: шаги 16/1 не отмечены выполненными.
func TestIncomingDetailPending(t *testing.T) {
it := &IncomingTransfer{ID: "y", GUID: "G", Status: "pending", Securities: []IncomingSecurity{{ReferenceID: "R1", SecurityCode: "SBER/03"}}}
body := renderIncoming(t, incomingDetailData{It: it})
if !strings.Contains(body, "после нашего согласия") || strings.Contains(body, "Черновик от НРД") || strings.Contains(body, "Подать поручение 16/1 в НРД") {
t.Error("ожидающий перевод не должен показывать полученный черновик и кнопку подачи")
}
}
+2 -4
View File
@@ -45,10 +45,8 @@ func exportIncoming(w http.ResponseWriter, r *http.Request, deps incomingDeps, i
}
}
// Ответ НРД по поручению (отчёт MS010 + квитанции) — сырые файлы из INBOX.
for _, rf := range collectOrderResponseRaw(it.OrderContrDID) {
if f, e := zw.Create("otvet-nrd/" + rf.Name); e == nil {
_, _ = f.Write(rf.Data)
}
if docs := nsdOrderDocs(it.OrderContrDID); len(docs) > 0 {
writeNSDDocsZip(zw, "Ответ НРД/", "Документы НРД по поручению 16/1 (зачисление) № "+it.OrderContrDID, docs, true)
}
}
+44 -15
View File
@@ -176,20 +176,11 @@ func registerIncoming(mux *http.ServeMux, a *admin, deps incomingDeps) {
}
}
}
data := struct {
page
It *IncomingTransfer
Flash string
OrderResponses []orderResponse
OrderDone bool
OrderDoneAt string
DraftReceived string
Depo depoResult // сверка с DEPO — эмуляция ответа, ничего не отправляется
Quik []*QuikLink
QuikOn bool
Watch *OrderWatch
DepoL *DepoLink
}{nowPage("Входящий перевод", "incoming"), it, r.URL.Query().Get("flash"), orderResps, orderDone, orderDoneAt, draftReceivedTime(it.GUID), depoResult{}, globalQuikLinks.ForGUID(it.GUID), deps.rc.Snapshot().Quik.WritesEnabled, globalOrderWatch.Get(it.GUID), globalDepoLinks.Get(it.GUID)}
rejected, codes := incomingRejectedAll(it)
data := incomingDetailData{page: nowPage("Входящий перевод", "incoming"), It: it, Flash: r.URL.Query().Get("flash"),
OrderResponses: orderResps, OrderDone: orderDone, OrderDoneAt: orderDoneAt, DraftReceived: draftReceivedTime(it.GUID),
Quik: globalQuikLinks.ForGUID(it.GUID), QuikOn: quikActive(deps.rc.Snapshot().Quik), Watch: globalOrderWatch.Get(it.GUID), DepoL: globalDepoLinks.Get(it.GUID),
Rejected: rejected, RejectCodes: codes, AutoMode: deps.rc.Snapshot().M2MAuto.Enabled}
// Сверка выполняется при открытии карточки; итог сохраняем для бейджа в списке.
data.Depo = runDepoCheck(it)
if data.Depo.Ran {
@@ -228,9 +219,47 @@ func handleIncomingAnswer(w http.ResponseWriter, r *http.Request, deps incomingD
}
decisions = append(decisions, d)
}
msg := "Решение отправлено роботу"
msg := "Решение отправлено контрагенту через НРД"
if devMode(deps.rc) {
msg = "Решение отправлено роботу"
}
if err := answerIncoming(r.Context(), deps, id, decisions); err != nil {
msg = "Ошибка отправки решения: " + err.Error()
}
http.Redirect(w, r, "/admin/incoming/"+id+"?flash="+url.QueryEscape(msg), http.StatusSeeOther)
}
// incomingDetailData — карточка входящего перевода.
type incomingDetailData struct {
page
It *IncomingTransfer
Flash string
OrderResponses []orderResponse
OrderDone bool
OrderDoneAt string
DraftReceived string
Depo depoResult // сверка с DEPO — рекомендация по ответу, ничего не отправляется
Quik []*QuikLink
QuikOn bool
Watch *OrderWatch
DepoL *DepoLink
Rejected bool // решение отправлено и ни одна бумага не принята — перевод завершён отказом
RejectCodes string // коды отказа через запятую
AutoMode bool // автоматический режим M2M (поручение 16/1 подаст bj)
}
// incomingRejectedAll — перевод отвечен и ни одна бумага не принята (поручение 16/1 не нужно).
func incomingRejectedAll(it *IncomingTransfer) (bool, string) {
if it.Status != "answered" || incomingHasConfirmed(it) {
return false, ""
}
var codes []string
seen := map[string]bool{}
for _, s := range it.Securities {
if s.RejectCode != "" && !seen[s.RejectCode] {
seen[s.RejectCode] = true
codes = append(codes, s.RejectCode)
}
}
return true, strings.Join(codes, ", ")
}
+31 -25
View File
@@ -64,10 +64,27 @@ func submitDepoOrder(w http.ResponseWriter, r *http.Request, deps incomingDeps,
redirect := func(msg string) {
http.Redirect(w, r, "/admin/incoming/"+id+"?flash="+url.QueryEscape(msg), http.StatusSeeOther)
}
if deps.igwClient == nil || deps.igwChan == "" {
redirect("ИШ не настроен — нельзя отправить поручение")
ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second)
defer cancel()
sent, firstErr, err := submitIncomingOrderFor(ctx, deps, it)
if err != nil {
redirect(err.Error())
return
}
note := fmt.Sprintf("Поручение 16/1 (тип ЭД ORDXM) отправлено в НРД: пакет(ы) ИШ %s. "+
"Ждём отчёт по поручению депо (XMLRP) = «MS010». Ответ появится ниже на странице.", strings.Join(sent, ", "))
if firstErr != "" {
note += " ⚠ часть файлов не ушла: " + firstErr
}
redirect(note)
}
// submitIncomingOrderFor — подать поручение 16/1 (ORDXM) по черновику MF010 из INBOX ИШ (кнопка в
// карточке и автоматический режим). firstErr — если часть файлов не ушла.
func submitIncomingOrderFor(ctx context.Context, deps incomingDeps, it *IncomingTransfer) (sent []string, firstErr string, err error) {
if deps.igwClient == nil || deps.igwChan == "" {
return nil, "", fmt.Errorf("ИШ не настроен — нельзя отправить поручение")
}
// только .xml (без .SGN) — подпись НРД нам не нужна, ИШ подпишет своей ГОСТ.
var xmls []string
for _, p := range findMF010Files(it.GUID) {
@@ -76,13 +93,11 @@ func submitDepoOrder(w http.ResponseWriter, r *http.Request, deps incomingDeps,
}
}
if len(xmls) == 0 {
redirect("Черновик поручения 16/1 (MF010) по этому переводу в INBOX ИШ не найден")
return
return nil, "", fmt.Errorf("Черновик поручения 16/1 (MF010) по этому переводу в INBOX ИШ не найден")
}
ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second)
defer cancel()
var sent, sentXML []string
var firstErr, contrDID string
id := it.ID
var sentXML []string
var contrDID string
for _, p := range xmls {
raw, e := os.ReadFile(p) // windows-1251, шлём как есть
if e != nil {
@@ -116,22 +131,16 @@ func submitDepoOrder(w http.ResponseWriter, r *http.Request, deps incomingDeps,
if firstErr != "" {
msg = firstErr
}
redirect("Поручение 16/1 (ORDXM): отправка в НРД не удалась: " + msg)
return
return nil, "", fmt.Errorf("Поручение 16/1 (ORDXM): отправка в НРД не удалась: %s", msg)
}
note := fmt.Sprintf("Поручение 16/1 (тип ЭД ORDXM) отправлено в НРД: пакет(ы) ИШ %s. "+
"Ждём отчёт по поручению депо (XMLRP) = «MS010». Ответ появится ниже на странице.", strings.Join(sent, ", "))
if firstErr != "" {
note += " ⚠ часть файлов не ушла: " + firstErr
}
redirect(note)
return sent, firstErr, nil
}
// writeOrderResponseZip отдаёт на скачивание ZIP с сырыми файлами ответа НРД
// (отчёт MS010 + квитанции) по поручению с данным contr_d_id.
func writeOrderResponseZip(w http.ResponseWriter, contrDID, fname string) {
files := collectOrderResponseRaw(contrDID)
if len(files) == 0 {
docs := nsdOrderDocs(contrDID)
if len(docs) == 0 {
http.Error(w, "ответ НРД (MS010) по этому поручению в INBOX ИШ ещё не найден", http.StatusNotFound)
return
}
@@ -139,11 +148,7 @@ func writeOrderResponseZip(w http.ResponseWriter, contrDID, fname string) {
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.zip"`, fname))
zw := zip.NewWriter(w)
defer zw.Close()
for _, f := range files {
if wf, e := zw.Create(f.Name); e == nil {
_, _ = wf.Write(f.Data)
}
}
writeNSDDocsZip(zw, "", "Документы НРД по поручению № "+contrDID, docs, true)
}
// downloadSentOrder отдаёт на скачивание XML отправленного поручения 16/1
@@ -351,8 +356,9 @@ func orderRespKind(pkg, body string) (string, bool) {
u := strings.ToUpper(body)
low := strings.ToLower(body)
switch {
case strings.Contains(u, "MS010") || strings.Contains(u, "SESE.025"):
return "Отчёт по поручению депо MS010 — операция проведена, бумаги зачислены", true
case strings.Contains(u, "MS010") || strings.Contains(u, "SESE.025") || strings.Contains(u, "SESE.024") || strings.Contains(u, "GS036") || strings.Contains(u, "GS116") || strings.Contains(low, "<reg_no>"):
d := classifyNSDDoc(pkg, body)
return d.Title + " — " + d.About, d.Done
case strings.Contains(u, "ERROR") || strings.Contains(strings.ToUpper(pkg), "ERROR") || strings.Contains(low, "ошибк"):
return "Ошибка обработки пакета (CERROR)", false
case strings.Contains(u, "REORD") || strings.Contains(low, "принят"):
+16 -3
View File
@@ -65,12 +65,16 @@ func (s *keyWizardState) reset() {
}
func defaultKeySteps() []keyWizardStep {
last := "Готово — можно проверить подключения"
if devMode(globalRC) {
last = "Готово — можно отправлять тестовый документ"
}
return []keyWizardStep{
{Title: "Загрузка архива и распаковка", Status: "pending"},
{Title: "Запись ключа на выбранную флешку (с бэкапом)", Status: "pending"},
{Title: "Формирование справочника сертификатов (CRL)", Status: "pending"},
{Title: "Перезапуск и проверка ИШ", Status: "pending"},
{Title: "Готово — можно отправлять тестовый документ", Status: "pending"},
{Title: last, Status: "pending"},
}
}
@@ -356,14 +360,23 @@ func (h *setupHandlers) keyWizardInstall(w http.ResponseWriter, r *http.Request)
return
}
h.keyWiz.set(3, "ok", "ИШ перезапущен и работает: "+ishMsg)
h.keyWiz.set(4, "ok", "Теперь подпишите новым ключом — отправьте тестовый документ роботу НРД ниже")
dev := devMode(h.rc)
if dev {
h.keyWiz.set(4, "ok", "Теперь подпишите новым ключом — отправьте тестовый документ роботу НРД ниже")
} else {
h.keyWiz.set(4, "ok", "Ключ установлен — проверьте подключения на странице «Статус»")
}
h.keyWiz.Done = true
h.keyWiz.mu.Unlock()
// Ключ уже на флешке и в ИШ — распаковка staging больше не нужна.
_ = os.RemoveAll(staging)
http.Redirect(w, r, "/admin/setup/keywizard?flash="+urlQ("Готово! Ключ на флешке, справочник сформирован, ИШ перезапущен и работает. Финальная проверка — тестовым документом роботу."), http.StatusSeeOther)
final := "Готово! Ключ на флешке, справочник сформирован, ИШ перезапущен и работает."
if dev {
final += " Финальная проверка — тестовым документом роботу."
}
http.Redirect(w, r, "/admin/setup/keywizard?flash="+urlQ(final), http.StatusSeeOther)
}
// restartAndVerifyISH перезапускает Интеграционный шлюз и проверяет, что он
+22 -2
View File
@@ -10,7 +10,7 @@ import (
// DefaultLicensePublicKey — открытый ключ проверки лицензий: пара к ключу подписи сервера
// лицензий lic.zetit.ru. Зашит в bj и в настройках не меняется, поэтому ключ, подписанный
// чем-то другим, не принимается. var — только для подмены в тестах.
var DefaultLicensePublicKey = "UuvEYFxxzOFk59FjCQTdxKuzkNAJpJyWTuUdlCYfsdI="
var DefaultLicensePublicKey = license.DefaultPublicKey
// LicenseStatus — сводка состояния лицензии для UI и гейтов.
type LicenseStatus struct {
@@ -24,7 +24,8 @@ type LicenseStatus struct {
ContractUntil time.Time
DaysLeft int
AllowsUpdates bool
Features []string // признаки (модули), разрешённые ключом
Features []string // признаки (модули), разрешённые ключом
DevUntil time.Time // режим разработки до (выдаёт сервер лицензий; нулевое — выключен)
Message string
}
@@ -71,6 +72,22 @@ func modulesAllowed(rc *RuntimeConfig) map[string]bool {
return m
}
// devModeForced — режим разработки в тестах пакета.
var devModeForced bool
// devMode — режим разработки: сервер лицензий временно открыл разработчикам bj тестовые разделы и
// служебные инструменты на этой установке. Без него у клиента их нет (адреса отвечают 404).
func devMode(rc *RuntimeConfig) bool {
if devModeForced {
return true
}
if rc == nil {
return false
}
st := licenseStatus(rc)
return st.Valid && time.Now().Before(st.DevUntil)
}
// moduleAllowed — разрешён ли модуль (лицензионный признак). План enterprise — все модули.
func moduleAllowed(rc *RuntimeConfig, feature string) bool {
st := licenseStatus(rc)
@@ -127,6 +144,9 @@ func licenseStatus(rc *RuntimeConfig) LicenseStatus {
st.DaysLeft = lic.DaysLeft(now)
st.AllowsUpdates = lic.AllowsUpdates()
st.Features = lic.Features
if lic.DevUntil != nil {
st.DevUntil = *lic.DevUntil
}
st.Online = lic.InstallID != ""
if lic.Product != "" && lic.Product != "bj-server" {
st.Message = "ключ выпущен для другого продукта (" + lic.Product + ")"
+10 -5
View File
@@ -171,18 +171,22 @@ func LicenseRenew(ctx context.Context, rc *RuntimeConfig) error {
return licenseAccept(rc, r.Token, "продлена")
}
// licenseNeedsRenew — онлайн-ключ, и сутки без успешного продления.
// licenseNeedsRenew — онлайн-ключ и 15 минут без продления: так режим разработки, включённый или
// выключенный на сервере лицензий, доходит до установки быстро. После неудачи — не чаще раза в час.
func licenseNeedsRenew(rc *RuntimeConfig) bool {
s := rc.Snapshot().License
lic, err := verifyLicenseToken(s.Key)
if err != nil || lic.InstallID == "" {
return false
}
return time.Since(s.LastSync) >= 23*time.Hour
if !s.LastOK && !s.LastAttempt.IsZero() && time.Since(s.LastAttempt) < time.Hour && time.Since(s.LastSync) < 23*time.Hour {
return false
}
return time.Since(s.LastSync) >= 15*time.Minute
}
// licenseLoop — раз в час проверяет, пора ли продлить ключ (продление — раз в сутки,
// при сбое — повтор через час).
// licenseLoop — раз в 5 минут предупреждает о скором окончании лицензии и проверяет, пора ли продлить
// ключ (продление — раз в сутки, при сбое — повтор через час).
func (s *Server) licenseLoop(ctx context.Context) {
ensureInstallID(s.rc)
wait := 2 * time.Minute
@@ -192,7 +196,8 @@ func (s *Server) licenseLoop(ctx context.Context) {
return
case <-time.After(wait):
}
wait = time.Hour
wait = 5 * time.Minute
licenseExpiryWarn(s.rc, time.Now())
if !licenseNeedsRenew(s.rc) {
continue
}
+198
View File
@@ -0,0 +1,198 @@
package lkgateway
// Предупреждения лицензии.
//
// 1. Заранее об окончании: за 30, 14, 7 и 1 день до окончания договора (или ключа, если договора
// нет или сервер лицензий перестал продлевать ключ). Каждый порог — одно уведомление; если
// договор продлили (дата сменилась), отсчёт начинается заново.
// 2. При закрытых обновлениях bj — о смене версии внешней системы (МНП QUIK, ИШ, DEPO, …).
//
// Решение 16.09.2026: после окончания лицензии фоновые процессы (цикл QUIK МНП, личный кабинет,
// обмен с НРД и DEPO) не останавливаются. Последствие одно — bj перестаёт обновляться, и новые
// версии внешних систем со временем могут перестать с ним корректно работать; об этом и уведомляем.
import (
"encoding/json"
"fmt"
"log"
"math"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// licenseWatchFile — что уже отправлено и последние известные версии внешних систем.
var licenseWatchFile = "/var/lib/bj/.bj/license-watch.json"
type licenseWatch struct {
EndDate string `json:"end_date,omitempty"` // дата окончания, по которой шли предупреждения
WarnedDays int `json:"warned_days,omitempty"` // последний отправленный порог (дней)
Versions map[string]string `json:"versions,omitempty"` // ключ системы → последняя известная версия
}
var (
licenseWatchMu sync.Mutex
licenseWarnSteps = []int{30, 14, 7, 1}
)
// externalSystems — внешние системы, с которыми работает bj (свои компоненты bj обновляются вместе с ним).
var externalSystems = map[string]bool{
"quik-mnp": true, "ish": true, "depo": true, "quikexport": true,
"validata": true, "java": true, "postgres": true, "os": true,
}
func loadLicenseWatch() licenseWatch {
var w licenseWatch
if b, err := os.ReadFile(licenseWatchFile); err == nil {
_ = json.Unmarshal(b, &w)
}
if w.Versions == nil {
w.Versions = map[string]string{}
}
return w
}
func saveLicenseWatch(w licenseWatch) {
b, _ := json.MarshalIndent(w, "", " ")
tmp := licenseWatchFile + ".tmp"
if err := os.MkdirAll(filepath.Dir(licenseWatchFile), 0o700); err == nil {
if err = os.WriteFile(tmp, b, 0o600); err == nil {
err = os.Rename(tmp, licenseWatchFile)
}
if err != nil {
log.Printf("lk-gateway: лицензия: запись %s: %v", licenseWatchFile, err)
}
}
}
// ---- 1. заранее об окончании ----
// licenseEnd — когда лицензия перестанет действовать и что именно заканчивается.
func licenseEnd(st LicenseStatus, lastSync, now time.Time) (end time.Time, what string) {
if !st.Valid {
return time.Time{}, "" // уже не действует — об этом говорит статус и отказ в продлении
}
if !st.ContractUntil.IsZero() {
end, what = st.ContractUntil, "договор"
}
renewStuck := st.Online && now.Sub(lastSync) > 48*time.Hour
if (!st.Online || renewStuck) && !st.ExpiresAt.IsZero() && (end.IsZero() || st.ExpiresAt.Before(end)) {
end, what = st.ExpiresAt, "ключ"
if renewStuck {
what = "ключ (сервер лицензий не продлевает его)"
}
}
return end, what
}
// expiryStep — какой порог отправить (0 — ничего): наименьший порог, в который уже попали, если он
// ещё не отправлен для этой даты окончания.
func expiryStep(daysLeft int, w licenseWatch, endDate string) int {
warned := w.WarnedDays
if w.EndDate != endDate {
warned = 0
}
step := 0
for _, s := range licenseWarnSteps {
if daysLeft <= s {
step = s
}
}
if step == 0 || (warned != 0 && step >= warned) {
return 0
}
return step
}
func ruDays(n int) string {
switch {
case n%10 == 1 && n%100 != 11:
return fmt.Sprintf("%d день", n)
case n%10 >= 2 && n%10 <= 4 && (n%100 < 12 || n%100 > 14):
return fmt.Sprintf("%d дня", n)
}
return fmt.Sprintf("%d дней", n)
}
// licenseExpiryWarn — вызывается раз в час из licenseLoop.
func licenseExpiryWarn(rc *RuntimeConfig, now time.Time) {
st := licenseStatus(rc)
end, what := licenseEnd(st, rc.Snapshot().License.LastSync, now)
if end.IsZero() {
return
}
endDate := end.Local().Format("2006-01-02")
daysLeft := int(math.Ceil(end.Sub(now).Hours() / 24))
if daysLeft < 0 {
daysLeft = 0
}
licenseWatchMu.Lock()
defer licenseWatchMu.Unlock()
w := loadLicenseWatch()
step := expiryStep(daysLeft, w, endDate)
dateChanged := w.EndDate != endDate
if dateChanged {
w.EndDate, w.WarnedDays = endDate, 0 // договор продлили или сменился ключ — отсчёт заново
}
if step != 0 {
w.WarnedDays = step
}
if dateChanged || step != 0 {
saveLicenseWatch(w)
}
if step == 0 {
return
}
left := "осталось " + ruDays(daysLeft)
if daysLeft == 0 {
left = "сегодня последний день"
}
raiseSystemAlert(rc, "Лицензия: "+strings.SplitN(what, " ", 2)[0]+" заканчивается через "+ruDays(daysLeft),
fmt.Sprintf("Лицензия bj (%s) действует до %s — %s. После этого переводы M2M, обмен с НРД и DEPO, цикл QUIK МНП и личный кабинет продолжат работать, "+
"но отключатся модули по лицензии и обновления bj: новые версии МНП, ИШ и других систем со временем могут перестать корректно работать с bj. "+
"Для продления обратитесь к поставщику bj.", what, end.Local().Format("02.01.2006"), left))
}
// ---- 2. смена версии внешней системы при закрытых обновлениях ----
// versionChanges — сверка с последними известными версиями (запоминает новые); список изменений
// возвращается только когда обновления bj недоступны.
func versionChanges(w *licenseWatch, items []SystemVersion, updatesOK bool) []string {
var changed []string
for _, it := range items {
if !externalSystems[it.Key] || it.Version == "" {
continue
}
if prev := w.Versions[it.Key]; prev != "" && prev != it.Version && !updatesOK {
changed = append(changed, fmt.Sprintf("%s: %s → %s", it.Title, prev, it.Version))
}
w.Versions[it.Key] = it.Version
}
return changed
}
// licenseVersionWatch — вызывается после каждого опроса версий систем.
func licenseVersionWatch(rc *RuntimeConfig, v SystemVersions) {
st := licenseStatus(rc)
updatesOK := !licensingEnabled(rc) || (st.Valid && st.AllowsUpdates)
licenseWatchMu.Lock()
w := loadLicenseWatch()
before, _ := json.Marshal(w.Versions)
changed := versionChanges(&w, v.Items, updatesOK)
if after, _ := json.Marshal(w.Versions); string(after) != string(before) {
saveLicenseWatch(w)
}
licenseWatchMu.Unlock()
if len(changed) == 0 {
return
}
reason := "лицензия: " + st.Message
if st.Valid {
reason = "обновления не входят в лицензию"
}
raiseSystemAlert(rc, "Версия внешней системы изменилась, а обновления bj недоступны",
fmt.Sprintf("Изменилось: %s. Обновления bj сейчас недоступны (%s). bj продолжает работать, но совместимость с новой версией не проверена и не гарантируется — "+
"при сбоях обмена причина может быть в этом. Совместимое обновление bj придёт после продления лицензии.", strings.Join(changed, "; "), reason))
}
+92
View File
@@ -0,0 +1,92 @@
package lkgateway
import (
"strings"
"testing"
"time"
)
func TestLicenseEnd(t *testing.T) {
now := time.Date(2027, 8, 20, 12, 0, 0, 0, time.UTC)
contract := time.Date(2027, 9, 14, 0, 0, 0, 0, time.UTC)
key := now.Add(55 * 24 * time.Hour)
// Онлайн-ключ продлевается — важна дата договора.
st := LicenseStatus{Valid: true, Online: true, ContractUntil: contract, ExpiresAt: key}
if end, what := licenseEnd(st, now.Add(-time.Hour), now); !end.Equal(contract) || what != "договор" {
t.Fatalf("онлайн с договором: %v %q", end, what)
}
// Онлайн без договора и продление идёт — предупреждать не о чем.
st = LicenseStatus{Valid: true, Online: true, ExpiresAt: key}
if end, _ := licenseEnd(st, now.Add(-time.Hour), now); !end.IsZero() {
t.Fatalf("бессрочная онлайн-лицензия: %v", end)
}
// Продление не проходит 3 суток — считаем до конца ключа.
st = LicenseStatus{Valid: true, Online: true, ExpiresAt: now.Add(10 * 24 * time.Hour), ContractUntil: contract}
if end, what := licenseEnd(st, now.Add(-72*time.Hour), now); !end.Equal(st.ExpiresAt) || !strings.HasPrefix(what, "ключ") {
t.Fatalf("продление не проходит: %v %q", end, what)
}
// Офлайн-ключ — до конца ключа.
st = LicenseStatus{Valid: true, ExpiresAt: key}
if end, what := licenseEnd(st, time.Time{}, now); !end.Equal(key) || what != "ключ" {
t.Fatalf("офлайн: %v %q", end, what)
}
// Уже не действует — без предупреждения заранее.
if end, _ := licenseEnd(LicenseStatus{ContractUntil: contract}, now, now); !end.IsZero() {
t.Fatal("недействующая лицензия")
}
}
func TestExpiryStep(t *testing.T) {
end := "2027-09-14"
cases := []struct {
days int
w licenseWatch
expect int
}{
{45, licenseWatch{}, 0},
{30, licenseWatch{}, 30},
{30, licenseWatch{EndDate: end, WarnedDays: 30}, 0},
{20, licenseWatch{EndDate: end, WarnedDays: 30}, 0},
{14, licenseWatch{EndDate: end, WarnedDays: 30}, 14},
{5, licenseWatch{}, 7}, // первый запуск за 5 дней — одно уведомление, без пачки 30/14/7
{1, licenseWatch{EndDate: end, WarnedDays: 7}, 1},
{0, licenseWatch{EndDate: end, WarnedDays: 1}, 0},
{10, licenseWatch{EndDate: "2026-09-14", WarnedDays: 1}, 14}, // договор продлили — заново
}
for _, c := range cases {
if got := expiryStep(c.days, c.w, end); got != c.expect {
t.Errorf("осталось %d, %+v: порог %d, ожидался %d", c.days, c.w, got, c.expect)
}
}
if ruDays(1) != "1 день" || ruDays(3) != "3 дня" || ruDays(11) != "11 дней" || ruDays(22) != "22 дня" || ruDays(14) != "14 дней" {
t.Errorf("склонение: %s %s %s %s %s", ruDays(1), ruDays(3), ruDays(11), ruDays(22), ruDays(14))
}
}
func TestVersionChanges(t *testing.T) {
w := licenseWatch{Versions: map[string]string{}}
items := []SystemVersion{
{Key: "quik-mnp", Title: "QUIK — Модуль неторговых поручений", Version: "21.4.1"},
{Key: "bj-server", Title: "Ядро bj", Version: "0.1.0"},
{Key: "depo", Title: "Fancy DEPO", Version: ""},
}
if ch := versionChanges(&w, items, false); len(ch) != 0 {
t.Fatalf("первый опрос только запоминает: %v", ch)
}
if w.Versions["quik-mnp"] != "21.4.1" || w.Versions["bj-server"] != "" || w.Versions["depo"] != "" {
t.Fatalf("запомнены: %v", w.Versions)
}
items[0].Version = "21.5.0"
if ch := versionChanges(&w, items, true); len(ch) != 0 {
t.Fatalf("при доступных обновлениях без уведомления: %v", ch)
}
items[0].Version = "21.6.0"
ch := versionChanges(&w, items, false)
if len(ch) != 1 || ch[0] != "QUIK — Модуль неторговых поручений: 21.5.0 → 21.6.0" {
t.Fatalf("изменение при закрытых обновлениях: %v", ch)
}
if ch := versionChanges(&w, items, false); len(ch) != 0 {
t.Fatalf("повторно о той же версии не уведомляем: %v", ch)
}
}
+98 -5
View File
@@ -236,15 +236,108 @@ func registerLKAPI(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) {
}
pos = p2
}
total, priced := lkApplyPrices(r.Context(), rc, pos)
lkJSON(w, http.StatusOK, map[string]any{
"contract": u.Contract,
"full_name": u.FullName,
"positions": pos,
"source": source,
"read_only": readOnly,
"contract": u.Contract,
"full_name": u.FullName,
"positions": pos,
"source": source,
"read_only": readOnly,
"total_value": total, // оценка портфеля, 0 — цен нет
"priced": priced, // по скольким бумагам есть цена
})
})
// Карточка бумаги: реквизиты выпуска, цена, позиция клиента, график выплат.
mux.HandleFunc("/api/lk/security", func(w http.ResponseWriter, r *http.Request) {
if !auth(w, r) {
return
}
u, ok := current(w, r)
if !ok {
return
}
isin := strings.ToUpper(strings.TrimSpace(r.URL.Query().Get("isin")))
if isin == "" {
lkJSON(w, http.StatusBadRequest, map[string]string{"error": "не указана бумага"})
return
}
var pos *ClientPosition
if dp, err := depoClientPositions(r.Context(), rc.Snapshot().Fancy, u.Contract); err == nil && len(dp) > 0 {
for _, p := range depoPositionsAsClient(dp) {
if strings.EqualFold(p.ISIN, isin) {
cp := p
pos = &cp
break
}
}
} else if p2, err2 := lkClientPositions(r.Context(), rc, u.Contract); err2 == nil {
for _, p := range markTransferable(p2) {
if strings.EqualFold(p.ISIN, isin) {
cp := p
pos = &cp
break
}
}
}
if pos != nil {
one := []ClientPosition{*pos}
lkApplyPrices(r.Context(), rc, one)
pos = &one[0]
}
card, err := securityCard(r.Context(), rc, isin, pos)
if err != nil {
lkJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
lkJSON(w, http.StatusOK, map[string]any{"security": card})
})
// Движения по счетам депо за период.
mux.HandleFunc("/api/lk/operations", func(w http.ResponseWriter, r *http.Request) {
if !auth(w, r) {
return
}
u, ok := current(w, r)
if !ok {
return
}
q := r.URL.Query()
ops, err := lkOperations(r.Context(), rc.Snapshot().Fancy, u.Contract, q.Get("from"), q.Get("to"), 200)
if err != nil {
lkJSON(w, http.StatusBadGateway, map[string]string{"error": "не удалось получить операции: " + err.Error()})
return
}
lkJSON(w, http.StatusOK, map[string]any{"operations": ops, "from": q.Get("from"), "to": q.Get("to")})
})
// Выплаты дохода и удержанный налог.
mux.HandleFunc("/api/lk/income", func(w http.ResponseWriter, r *http.Request) {
if !auth(w, r) {
return
}
u, ok := current(w, r)
if !ok {
return
}
q := r.URL.Query()
rows, tax, err := lkIncome(r.Context(), rc.Snapshot().Fancy, u.Contract, q.Get("from"), q.Get("to"))
if err != nil {
lkJSON(w, http.StatusBadGateway, map[string]string{"error": "не удалось получить доходы: " + err.Error()})
return
}
var accrued, paid, taxRows float64
for _, p := range rows {
accrued += p.Accrued
paid += p.Paid
taxRows += p.Tax
}
if tax == 0 {
tax = taxRows
}
lkJSON(w, http.StatusOK, map[string]any{"income": rows, "accrued": accrued, "paid": paid, "tax": tax})
})
mux.HandleFunc("/api/lk/claims", func(w http.ResponseWriter, r *http.Request) {
if !auth(w, r) {
return
+2 -1
View File
@@ -506,7 +506,8 @@ func lkClaimToRequest(ctx context.Context, rc *RuntimeConfig, ep LKEndpoint, cl,
// MC0012500000, а серия/номер ДУЛ переопределяются на сценарий (робот
// читает сценарий из DocumentSeries). Прод-заявки этим маркером не
// затрагиваются. Позволяет прогонять все сценарии через путь ЛК.
if sc := lkDetectRobotScenario(str(data["contragent_name"])); sc != "" {
// Только в режиме разработки: у клиентов маршрута к роботу нет.
if sc := lkDetectRobotScenario(str(data["contragent_name"])); sc != "" && devMode(rc) {
doc := Document{DocumentType: "21", Series: sc, Number: robotDocumentNumbers[sc]}
req.Investor.Document = doc
req.InvestorDocumentOverride = &doc
+12 -4
View File
@@ -35,11 +35,11 @@ var lkEmuSender *mock.Sender
// lkEmulateNSD — идут ли заявки ЛК через имитатор НРД и почему.
func lkEmulateNSD(rc *RuntimeConfig) (bool, string) {
s := rc.Snapshot()
if s.LK.EmulateNSD {
if s.LK.EmulateNSD && devMode(rc) {
return true, "включена в настройках"
}
if s.LK.ActiveEnv == "prod" && strings.HasPrefix(strings.ToLower(s.NSD.Profile), "test") {
return true, "боевой ЛК при тестовом профиле ИШ " + s.NSD.Profile
return true, "боевой ЛК при тестовом контуре НРД (профиль ИШ " + s.NSD.Profile + ")"
}
return false, ""
}
@@ -106,7 +106,11 @@ func lkProcessEmulated(ctx context.Context, rc *RuntimeConfig, svc *Service, ep
lkMarkSent(id)
lkLinkGUID(guid, ep, id)
globalLKFlows.update(guid, func(f *LKFlow) { f.Stage = "sent" })
globalLKFlows.step(guid, id, "gateway", "Бумаги заблокированы, заявка передана имитатору НРД",
sentTitle := "Бумаги заблокированы, заявка обработана шлюзом bj без отправки в НРД"
if devMode(rc) {
sentTitle = "Бумаги заблокированы, заявка передана имитатору НРД"
}
globalLKFlows.step(guid, id, "gateway", sentTitle,
lkQuikSummary(guid)+fmt.Sprintf(" В НРД не отправлялась; ответ контрагента — через %s.", lkEmuDecisionDelay))
if pc, pb := lkPatch(ctx, ep, fmt.Sprintf("/claims/%d", id), []byte(`{"claim":{"status":"in_process"}}`)); pc >= 400 {
log.Printf("lk-gateway: ЛК #%d: PATCH «В работе» не прошёл: HTTP %d %s", id, pc, pb)
@@ -233,7 +237,11 @@ func (s *Server) consumeEmulatedDecisions(ctx context.Context) {
rej++
}
}
globalLKFlows.stepIfExists(guid, "response", "Решение контрагента (имитатор НРД)", fmt.Sprintf("Принято %d, отклонено %d.", conf, rej))
respTitle := "Решение по заявке (без отправки в НРД)"
if devMode(s.rc) {
respTitle = "Решение контрагента (имитатор НРД)"
}
globalLKFlows.stepIfExists(guid, "response", respTitle, fmt.Sprintf("Принято %d, отклонено %d.", conf, rej))
s.applyDecisionEffects(ctx, d, "имитатор НРД", lkEmuNote)
}
}
+6 -1
View File
@@ -45,7 +45,7 @@ func TestLKEmulateNSD(t *testing.T) {
if err := rc.UpdateNSD(s.NSD); err != nil {
t.Fatal(err)
}
if on, why := lkEmulateNSD(rc); !on || !strings.Contains(why, "тестовом профиле") {
if on, why := lkEmulateNSD(rc); !on || !strings.Contains(why, "тестовом контуре НРД") {
t.Errorf("боевой ЛК + тестовый ИШ: ожидали имитацию, получили %v %q", on, why)
}
s.LK.ActiveEnv = "test"
@@ -55,6 +55,11 @@ func TestLKEmulateNSD(t *testing.T) {
}
s.LK.EmulateNSD = true
_ = rc.UpdateLK(s.LK)
if on, _ := lkEmulateNSD(rc); on {
t.Error("флаг в настройках без режима разработки не действует")
}
devModeForced = true
defer func() { devModeForced = false }()
if on, why := lkEmulateNSD(rc); !on || why != "включена в настройках" {
t.Errorf("флаг в настройках: %v %q", on, why)
}
+274
View File
@@ -0,0 +1,274 @@
package lkgateway
// lkhistory.go — история для кабинета клиента: движения по счетам депо и выплаты дохода
// с удержанным налогом. Источник — учётная система депозитария (DEPO):
//
// операции: OD_DOCS + U_OP_INV_ZACH / U_OP_INV_SPIS / U_OP_INV_PEREVOD (связь по DEPO = OD_CLIENTS.ID);
// доходы: U_OP_INF_INCOME + U_OP_INF_INCOME_DOL (связь по DEPO или INCOME_FACE);
// налог: OD_NDFL по лицу.
//
// Кабинет ничего не считает сам: суммы и статусы показываются как в учёте.
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
)
// LKOperation — движение по счёту депо.
type LKOperation struct {
Date string `json:"date"` // дата регистрации, ДД.ММ.ГГГГ
DoneAt string `json:"done_at,omitempty"` // дата проведения
Kind string `json:"kind"` // зачисление | списание | перевод между счетами
Number string `json:"number,omitempty"` // номер документа депозитария
Account string `json:"account,omitempty"` // счёт депо
ISIN string `json:"isin,omitempty"`
Security string `json:"security,omitempty"`
Quantity float64 `json:"quantity"` // со знаком: списание отрицательное
State string `json:"state"` // исполнено | в обработке | отменено
Note string `json:"note,omitempty"` // комментарий или причина отказа
}
// LKIncome — выплата дохода клиенту.
type LKIncome struct {
Date string `json:"date"`
Kind string `json:"kind"` // купон | дивиденды | доход
ISIN string `json:"isin,omitempty"`
Security string `json:"security,omitempty"`
Quantity float64 `json:"quantity,omitempty"` // бумаг в расчёте
Accrued float64 `json:"accrued"` // начислено
Tax float64 `json:"tax"` // удержан налог
Paid float64 `json:"paid"` // к выплате
State string `json:"state,omitempty"`
Account string `json:"account,omitempty"`
}
// depoClientIDs — внутренние идентификаторы счетов депо клиента и его лицо в DEPO.
func depoClientIDs(ctx context.Context, s FancyMSSQLSettings, contract string) (ids []int64, face int64, accounts map[int64]string, err error) {
conn, err := fancyOpen(s, s.DepoDB())
if err != nil {
return nil, 0, nil, err
}
defer conn.Close()
cctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
rows, err := conn.QueryContext(cctx, `SELECT DISTINCT c.ID, ISNULL(c.NUMBER,''), ISNULL(c.FACE,0)
FROM OD_CLIENTS c JOIN OD_CLIENTS_PORTS p ON p.CLIENT = c.ID
WHERE p.PORT_NUM = @p1 OR p.PORT_NUM LIKE @p2`, contract, contract+" %")
if err != nil {
return nil, 0, nil, err
}
defer rows.Close()
accounts = map[int64]string{}
for rows.Next() {
var id, f int64
var num string
if rows.Scan(&id, &num, &f) != nil {
continue
}
ids = append(ids, id)
accounts[id] = num
if face == 0 {
face = f
}
}
return ids, face, accounts, rows.Err()
}
// docStateRu — состояние документа депозитария словами.
func docStateRu(state sql.NullInt64, proc sql.NullTime) string {
switch {
case state.Valid && state.Int64 == 2:
return "исполнено"
case state.Valid && state.Int64 == 3:
return "отменено"
case proc.Valid:
return "исполнено"
}
return "в обработке"
}
// inClause — список идентификаторов для IN (...) — значения свои, не от пользователя.
func inClause(ids []int64) string {
parts := make([]string, 0, len(ids))
for _, id := range ids {
parts = append(parts, fmt.Sprint(id))
}
return strings.Join(parts, ",")
}
// parseRange — период запроса: пустые значения — последний год по сегодня.
func parseRange(from, to string) (time.Time, time.Time) {
fromT, err := time.Parse("2006-01-02", strings.TrimSpace(from))
if err != nil {
fromT = time.Now().AddDate(-1, 0, 0)
}
toT, err := time.Parse("2006-01-02", strings.TrimSpace(to))
if err != nil {
toT = time.Now()
}
return time.Date(fromT.Year(), fromT.Month(), fromT.Day(), 0, 0, 0, 0, time.UTC),
time.Date(toT.Year(), toT.Month(), toT.Day(), 0, 0, 0, 0, time.UTC)
}
// lkOperations — движения по счетам депо клиента за период (from/to — ГГГГ-ММ-ДД, можно пустыми).
func lkOperations(ctx context.Context, s FancyMSSQLSettings, contract, from, to string, limit int) ([]LKOperation, error) {
if strings.TrimSpace(s.Server) == "" {
return nil, fmt.Errorf("учётная система депозитария не подключена")
}
ids, _, accounts, err := depoClientIDs(ctx, s, contract)
if err != nil {
return nil, err
}
if len(ids) == 0 {
return nil, nil
}
conn, err := fancyOpen(s, s.DepoDB())
if err != nil {
return nil, err
}
defer conn.Close()
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
if limit <= 0 || limit > 500 {
limit = 200
}
fromT, toT := parseRange(from, to)
// Три вида операций в одном списке: зачисление, списание, перевод между счетами.
q := fmt.Sprintf(`SELECT TOP %d * FROM (
SELECT d.R_DATE AS RD, d.PROC_DATE AS PD, ISNULL(d.NUM,'') AS NUM, d.STATE AS ST, N'зачисление' AS KIND,
z.DEPO AS ACC, ISNULL(v.ISIN,'') AS ISIN, ISNULL(v.NAME,'') AS SEC, CAST(ISNULL(z.AMOUNT,0) AS float) AS QTY,
LTRIM(RTRIM(ISNULL(z.COMMENT,'') + ' ' + ISNULL(z.REJECT_REASON,''))) AS NOTE
FROM U_OP_INV_ZACH z JOIN OD_DOCS d ON d.ID = z.DOC LEFT JOIN OD_VALUES v ON v.ID = z.SHARE
WHERE z.DEPO IN (%s)
UNION ALL
SELECT d.R_DATE, d.PROC_DATE, ISNULL(d.NUM,''), d.STATE, N'списание',
p.DEPO, ISNULL(v.ISIN,''), ISNULL(v.NAME,''), -CAST(ISNULL(p.AMOUNT,0) AS float),
LTRIM(RTRIM(ISNULL(p.COMMENT,'') + ' ' + ISNULL(p.REJECT_REASON,'')))
FROM U_OP_INV_SPIS p JOIN OD_DOCS d ON d.ID = p.DOC LEFT JOIN OD_VALUES v ON v.ID = p.SHARE
WHERE p.DEPO IN (%s)
UNION ALL
SELECT d.R_DATE, d.PROC_DATE, ISNULL(d.NUM,''), d.STATE, N'перевод: списание',
t.D_DEPO, ISNULL(v.ISIN,''), ISNULL(v.NAME,''), -CAST(ISNULL(t.AMOUNT,0) AS float),
LTRIM(RTRIM(ISNULL(t.COMMENT,'') + ' ' + ISNULL(t.REJECT_REASON,'')))
FROM U_OP_INV_PEREVOD t JOIN OD_DOCS d ON d.ID = t.DOC LEFT JOIN OD_VALUES v ON v.ID = t.SHARE
WHERE t.D_DEPO IN (%s)
UNION ALL
SELECT d.R_DATE, d.PROC_DATE, ISNULL(d.NUM,''), d.STATE, N'перевод: зачисление',
t.K_DEPO, ISNULL(v.ISIN,''), ISNULL(v.NAME,''), CAST(ISNULL(t.AMOUNT,0) AS float),
LTRIM(RTRIM(ISNULL(t.COMMENT,'') + ' ' + ISNULL(t.REJECT_REASON,'')))
FROM U_OP_INV_PEREVOD t JOIN OD_DOCS d ON d.ID = t.DOC LEFT JOIN OD_VALUES v ON v.ID = t.SHARE
WHERE t.K_DEPO IN (%s)) x
WHERE x.RD >= @p1 AND x.RD < DATEADD(day, 1, @p2)
ORDER BY x.RD DESC, x.NUM DESC`, limit, inClause(ids), inClause(ids), inClause(ids), inClause(ids))
rows, err := conn.QueryContext(cctx, q, fromT, toT)
if err != nil {
return nil, err
}
defer rows.Close()
var out []LKOperation
for rows.Next() {
var rd, pd sql.NullTime
var num, kind, isin, sec, note string
var st sql.NullInt64
var acc int64
var qty float64
if rows.Scan(&rd, &pd, &num, &st, &kind, &acc, &isin, &sec, &qty, &note) != nil {
continue
}
op := LKOperation{Kind: kind, Number: num, ISIN: isin, Security: sec, Quantity: qty,
Account: accounts[acc], State: docStateRu(st, pd), Note: strings.TrimSpace(note)}
if rd.Valid {
op.Date = rd.Time.Format("02.01.2006")
}
if pd.Valid && pd.Time.Year() < 9000 {
op.DoneAt = pd.Time.Format("02.01.2006")
}
out = append(out, op)
}
return out, rows.Err()
}
// incomeKindRu — вид дохода из учёта.
func incomeKindRu(kind sql.NullInt64) string {
switch {
case !kind.Valid:
return "доход"
case kind.Int64 == 1:
return "дивиденды"
case kind.Int64 == 2:
return "купон"
case kind.Int64 == 3:
return "погашение"
}
return "доход"
}
// lkIncome — выплаты дохода клиенту за период и удержанный налог по данным учёта.
func lkIncome(ctx context.Context, s FancyMSSQLSettings, contract, from, to string) (rowsOut []LKIncome, taxTotal float64, err error) {
if strings.TrimSpace(s.Server) == "" {
return nil, 0, fmt.Errorf("учётная система депозитария не подключена")
}
ids, face, accounts, err := depoClientIDs(ctx, s, contract)
if err != nil {
return nil, 0, err
}
if len(ids) == 0 {
return nil, 0, nil
}
conn, err := fancyOpen(s, s.DepoDB())
if err != nil {
return nil, 0, err
}
defer conn.Close()
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
fromT, toT := parseRange(from, to)
q := fmt.Sprintf(`SELECT TOP 200 h.P_DATE, h.INCOME_KIND, ISNULL(v.ISIN,''), ISNULL(v.NAME,''),
CAST(ISNULL(l.AMOUNT,0) AS float), CAST(ISNULL(l.SUMMA_TOTAL,0) AS float),
CAST(ISNULL(l.TAX_HOLDED,0) AS float), CAST(ISNULL(l.TOTAL_TO_PAY,0) AS float),
ISNULL(l.STATUS,''), ISNULL(l.DEPO,0)
FROM U_OP_INF_INCOME_DOL l
JOIN OD_DOLS o ON o.ID = l.DOL
JOIN U_OP_INF_INCOME h ON h.DOC = o.DOC
LEFT JOIN OD_VALUES v ON v.ID = h.SHARE
WHERE (l.DEPO IN (%s) OR l.INCOME_FACE = @p3)
AND h.P_DATE >= @p1 AND h.P_DATE < DATEADD(day, 1, @p2)
ORDER BY h.P_DATE DESC`, inClause(ids))
rows, err := conn.QueryContext(cctx, q, fromT, toT, face)
if err != nil {
return nil, 0, err
}
defer rows.Close()
for rows.Next() {
var pd sql.NullTime
var kind sql.NullInt64
var isin, sec, status string
var qty, accrued, tax, paid float64
var acc int64
if rows.Scan(&pd, &kind, &isin, &sec, &qty, &accrued, &tax, &paid, &status, &acc) != nil {
continue
}
in := LKIncome{Kind: incomeKindRu(kind), ISIN: isin, Security: sec, Quantity: qty,
Accrued: accrued, Tax: tax, Paid: paid, Account: accounts[acc]}
if st := strings.TrimSpace(status); st != "" && st != "0" { // «0» — служебное значение учёта
in.State = st
}
if pd.Valid {
in.Date = pd.Time.Format("02.01.2006")
}
rowsOut = append(rowsOut, in)
}
if err := rows.Err(); err != nil {
return rowsOut, 0, err
}
// Удержанный налог по лицу за период — по журналу НДФЛ депозитария.
if face > 0 {
_ = conn.QueryRowContext(cctx, `SELECT CAST(ISNULL(SUM(CAST(SUMMA AS float)),0) AS float) FROM OD_NDFL
WHERE FACE = @p1 AND R_DATE >= @p2 AND R_DATE < DATEADD(day, 1, @p3)`,
sql.Named("p1", face), sql.Named("p2", fromT), sql.Named("p3", toT)).Scan(&taxTotal)
}
return rowsOut, taxTotal, nil
}
+42
View File
@@ -0,0 +1,42 @@
//go:build live
package lkgateway
import (
"context"
"testing"
)
// Движения и доходы клиента из учётной системы.
func TestLiveHistory(t *testing.T) {
rc, err := NewRuntimeConfig("/var/lib/bj/.bj/setup.json")
if err != nil {
t.Fatal(err)
}
globalRC = rc
fs := rc.Snapshot().Fancy
for _, contract := range []string{"БР-2000", "БР-1529", "БР-859"} {
ops, err := lkOperations(context.Background(), fs, contract, "2024-01-01", "", 20)
if err != nil {
t.Fatalf("операции %s: %v", contract, err)
}
t.Logf("%s: операций %d", contract, len(ops))
for i, o := range ops {
if i > 4 {
break
}
t.Logf(" %s %s %s %s %.0f %s %s", o.Date, o.Kind, o.Account, o.Security, o.Quantity, o.State, o.Note)
}
inc, tax, err := lkIncome(context.Background(), fs, contract, "2024-01-01", "")
if err != nil {
t.Fatalf("доходы %s: %v", contract, err)
}
t.Logf("%s: выплат %d, налог по журналу %.2f", contract, len(inc), tax)
for i, p := range inc {
if i > 4 {
break
}
t.Logf(" %s %s %s начислено %.2f налог %.2f к выплате %.2f %s", p.Date, p.Kind, p.Security, p.Accrued, p.Tax, p.Paid, p.State)
}
}
}
+9 -1
View File
@@ -131,6 +131,11 @@ type ClientPosition struct {
// Transferable — бумагу можно перевести через M2M (только российские выпуски).
Transferable bool `json:"transferable"`
Note string `json:"note"`
// Оценка по данным торговой системы (пакет «Цены»); 0 — цены нет.
Price float64 `json:"price,omitempty"`
PriceAt string `json:"price_board,omitempty"` // класс торгов, откуда цена
Value float64 `json:"value,omitempty"` // стоимость позиции
ChangePct float64 `json:"change_pct,omitempty"` // изменение цены к закрытию, %
}
// lkClientPositions — позиции клиента модуля ЛК по номеру договора (тестовые клиенты;
@@ -192,7 +197,10 @@ func lkInsertRequest(ctx context.Context, rc *RuntimeConfig, req *m2m.M2MTransfe
if doc.DocumentSeries != nil {
docSeries = string(*doc.DocumentSeries)
}
ourINN := transferringINN(Settings{})
ourINN := ""
if globalRC != nil {
ourINN = transferringINN(globalRC.Snapshot())
}
opType := ""
switch ourINN {
case string(req.Data.TransferringDepository.INN):
+2 -1
View File
@@ -204,7 +204,8 @@ func (s *Service) CreateLKTransfer(ctx context.Context, rc *RuntimeConfig, u LKU
// ТЕСТОВЫЙ КЛИЕНТ: если серия ДУЛ совпадает с кодом сценария робота НРД
// (1111/2001/2002/3333), заявка адресуется роботу-тестировщику, а серия/номер
// документа задают сценарий. Так из ЛК можно прогнать полный цикл с НРД.
if _, isRobot := robotScenarios[u.DocSeries]; isRobot {
// Только в режиме разработки: у реальных паспортов такие серии тоже встречаются.
if _, isRobot := robotScenarios[u.DocSeries]; isRobot && devMode(rc) {
claim.ReceiverCodeOverride = NSDRobotCode
claim.InvestorDocumentOverride = &doc
}
+301
View File
@@ -0,0 +1,301 @@
package lkgateway
// Автоматический режим M2M (решение 16.09.2026): ручные шаги оператора выполняются без участия
// человека, чтобы соблюдать сроки и убрать человеческий фактор.
//
// - Входящий перевод ждёт решения → свежая сверка с DEPO → решение по её рекомендации: пакет
// принимается только целиком; предупреждение со Strict (бумага для квалифицированных инвесторов
// при неполной квалификации клиента) — отказ M2M08. Без сверки ничего не принимается: если DEPO
// или ИШ недоступны, bj повторяет каждые m2mAutoRetry и шлёт оповещение.
// - После нашего согласия НРД присылает черновик 16/1 (MF010) → поручение подаётся автоматически.
// - По исходящему после согласия контрагента НРД присылает черновик 16 → подаётся автоматически.
//
// Обрабатывается только то, что появилось не раньше чем за сутки до включения режима (в INBOX ИШ
// лежат старые черновики тестов — их подавать нельзя). Шаги в QUIK МНП — по настройкам цикла
// (в том числе в режиме имитации).
import (
"context"
"fmt"
"log"
"net/http"
"strings"
"sync"
"time"
)
// M2MAutoSettings — автоматический режим M2M.
type M2MAutoSettings struct {
Enabled bool `json:"enabled"`
ChangedAt time.Time `json:"changed_at,omitempty"`
ChangedBy string `json:"changed_by,omitempty"`
}
func (r *RuntimeConfig) UpdateM2MAuto(s M2MAutoSettings) error {
r.mu.Lock()
r.data.M2MAuto = s
r.data.UpdatedAt = time.Now().UTC()
r.mu.Unlock()
return r.save()
}
const (
m2mAutoTick = 30 * time.Second
m2mAutoRetry = 3 * time.Minute
m2mAutoAlertEvery = time.Hour
m2mAutoLookback = 24 * time.Hour // что обрабатывать при включении
m2mAutoDraftWait = 2 * time.Hour // черновик 16/1 не пришёл — оповещение
)
var (
globalIncomingDeps *incomingDeps
globalOutgoingDeps *outgoingDeps
)
// m2mAutoState — повторы и оповещения по ключу шага (answer:<id>, order:<id>, form16:<guid>).
type m2mAutoState struct {
mu sync.Mutex
next map[string]time.Time
alerted map[string]time.Time
}
var m2mAuto = &m2mAutoState{next: map[string]time.Time{}, alerted: map[string]time.Time{}}
func (st *m2mAutoState) due(key string, now time.Time) bool {
st.mu.Lock()
defer st.mu.Unlock()
return !now.Before(st.next[key])
}
func (st *m2mAutoState) retry(key string, now time.Time) {
st.mu.Lock()
st.next[key] = now.Add(m2mAutoRetry)
st.mu.Unlock()
}
func (st *m2mAutoState) done(key string) {
st.mu.Lock()
delete(st.next, key)
delete(st.alerted, key)
st.mu.Unlock()
}
// alert — оповещение по шагу не чаще раза в m2mAutoAlertEvery.
func (st *m2mAutoState) alert(rc *RuntimeConfig, key string, now time.Time, title, body string) {
st.mu.Lock()
last, ok := st.alerted[key]
send := !ok || now.Sub(last) >= m2mAutoAlertEvery
if send {
st.alerted[key] = now
}
st.mu.Unlock()
if send {
raiseSystemAlert(rc, title, body)
}
}
// m2mAutoLoop — фоновый исполнитель автоматического режима.
func (s *Server) m2mAutoLoop(ctx context.Context) {
ticker := time.NewTicker(m2mAutoTick)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
}
m2mAutoTickOnce(ctx, s.rc, time.Now())
healthBeat("m2m-auto")
}
}
func m2mAutoTickOnce(ctx context.Context, rc *RuntimeConfig, now time.Time) {
cfg := rc.Snapshot().M2MAuto
if !cfg.Enabled {
return
}
since := cfg.ChangedAt.Add(-m2mAutoLookback)
if d := globalIncomingDeps; d != nil {
for _, it := range d.store.List() {
switch {
case it.Status == "pending" && !it.ReceivedAt.Before(since):
m2mAutoAnswer(ctx, rc, *d, it, now)
case it.Status == "answered" && !it.OrderSubmitted && !it.AnsweredAt.Before(since) && incomingHasConfirmed(it):
m2mAutoOrder(ctx, rc, *d, it, now)
}
}
}
if d := globalOutgoingDeps; d != nil {
for _, od := range scanForm16Drafts(d.store) {
if !od.Submitted && len(od.Secs) > 0 && !od.DraftAt.Before(since) {
m2mAutoForm16(ctx, rc, *d, od, now)
}
}
}
}
func incomingHasConfirmed(it *IncomingTransfer) bool {
for _, s := range it.Securities {
if s.Decision == "confirmed" {
return true
}
}
return false
}
// autoDecisions — решение по результату сверки: рекомендация по каждой бумаге, Strict-предупреждение
// даёт отказ M2M08, пакет только целиком. why — пояснение для карточки.
func autoDecisions(it *IncomingTransfer, r depoResult) (decisions []SecurityDecision, why string) {
bySec := map[string]depoSecResult{}
strict := ""
for _, sr := range r.Secs {
bySec[sr.ReferenceID] = sr
for _, c := range sr.Checks {
if c.Strict && c.Warn && strict == "" {
strict = sr.Security
}
}
}
for _, sec := range it.Securities {
sr, ok := bySec[sec.ReferenceID]
d := SecurityDecision{ReferenceID: sec.ReferenceID}
switch {
case !ok:
d.RejectCode = "M2M05"
case strict != "":
d.RejectCode = "M2M08"
case sr.Recommend == "confirm":
d.Confirm = true
default:
d.RejectCode = sr.RejectCode
if d.RejectCode == "" {
d.RejectCode = "M2M05"
}
}
decisions = append(decisions, d)
}
decisions = packageDecisions(decisions)
why = r.Summary
if strict != "" {
why = "отказ M2M08: бумага " + strict + " для квалифицированных инвесторов, квалификация клиента неполная"
}
return decisions, why
}
func m2mAutoAnswer(ctx context.Context, rc *RuntimeConfig, d incomingDeps, it *IncomingTransfer, now time.Time) {
key := "answer:" + it.ID
if !m2mAuto.due(key, now) {
return
}
who := fmt.Sprintf("Перевод %s от %s (%s %s)", shortGUID(it.GUID), it.SenderCode, it.InvestorLast, it.InvestorFirst)
r := runDepoCheck(it)
if !r.Ran || r.Err != "" || len(r.Secs) == 0 {
m2mAuto.retry(key, now)
m2mAuto.alert(rc, key, now, "Автоматический режим M2M: ответ отложен — нет сверки с DEPO",
fmt.Sprintf("%s: %s. bj повторит через %d мин и ответит, как только сверка пройдёт; без сверки ничего не принимается.", who, pickStr(r.Err, "сверка не выполнена"), int(m2mAutoRetry.Minutes())))
return
}
d.store.SetDepo(it.ID, r.Recommend, r.Summary)
if cur, ok := d.store.Get(it.ID); !ok || cur.Status != "pending" {
m2mAuto.done(key) // оператор успел ответить сам
return
}
decisions, why := autoDecisions(it, r)
cctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
if err := answerIncoming(cctx, d, it.ID, decisions); err != nil {
if cur, ok := d.store.Get(it.ID); ok && cur.Status == "answered" {
m2mAuto.done(key)
return
}
m2mAuto.retry(key, now)
m2mAuto.alert(rc, key, now, "Автоматический режим M2M: решение не отправлено",
fmt.Sprintf("%s: %v. bj повторит через %d мин.", who, err, int(m2mAutoRetry.Minutes())))
return
}
if cur, ok := d.store.Get(it.ID); ok {
d.store.MarkAuto(it.ID, "Автоматически ("+why+"). "+cur.AnswerNote)
}
m2mAuto.done(key)
log.Printf("m2m-auto: %s — решение отправлено автоматически: %s", who, why)
}
func m2mAutoOrder(ctx context.Context, rc *RuntimeConfig, d incomingDeps, it *IncomingTransfer, now time.Time) {
key := "order:" + it.ID
if !m2mAuto.due(key, now) {
return
}
who := fmt.Sprintf("Перевод %s от %s (%s %s)", shortGUID(it.GUID), it.SenderCode, it.InvestorLast, it.InvestorFirst)
if len(findMF010Files(it.GUID)) == 0 {
if now.Sub(it.AnsweredAt) > m2mAutoDraftWait {
m2mAuto.alert(rc, key, now, "Автоматический режим M2M: нет черновика поручения 16/1",
fmt.Sprintf("%s: согласие отправлено %s, но черновик 16/1 (MF010) из НРД так и не пришёл. bj подаст поручение, как только черновик появится в INBOX ИШ.", who, it.AnsweredAt.Local().Format("02.01.2006 15:04")))
}
return // черновик ещё не пришёл — проверим на следующем проходе
}
cctx, cancel := context.WithTimeout(ctx, time.Minute)
defer cancel()
sent, partial, err := submitIncomingOrderFor(cctx, d, it)
if err != nil {
m2mAuto.retry(key, now)
m2mAuto.alert(rc, key, now, "Автоматический режим M2M: поручение 16/1 не подано",
fmt.Sprintf("%s: %v. bj повторит через %d мин.", who, err, int(m2mAutoRetry.Minutes())))
return
}
m2mAuto.done(key)
log.Printf("m2m-auto: %s — поручение 16/1 подано автоматически, пакеты ИШ %s%s", who, strings.Join(sent, ", "), pickStr(partial, ""))
if partial != "" {
raiseSystemAlert(rc, "Автоматический режим M2M: поручение 16/1 подано не полностью", who+": часть файлов не ушла: "+partial)
}
}
func m2mAutoForm16(ctx context.Context, rc *RuntimeConfig, d outgoingDeps, od *OutgoingDeal, now time.Time) {
key := "form16:" + od.GUID
if !m2mAuto.due(key, now) {
return
}
who := fmt.Sprintf("Исходящий перевод %s (контрагент %s)", shortGUID(od.GUID), od.CounterpartyCode)
cctx, cancel := context.WithTimeout(ctx, time.Minute)
defer cancel()
pkgs, _, _, err := submitOutgoingFor(cctx, d, od.GUID)
if len(pkgs) == 0 {
m2mAuto.retry(key, now)
m2mAuto.alert(rc, key, now, "Автоматический режим M2M: поручение 16 не подано",
fmt.Sprintf("%s: %v. bj повторит через %d мин.", who, err, int(m2mAutoRetry.Minutes())))
return
}
m2mAuto.done(key)
log.Printf("m2m-auto: %s — поручение 16 подано автоматически, пакеты ИШ %s", who, strings.Join(pkgs, ", "))
if err != nil {
raiseSystemAlert(rc, "Автоматический режим M2M: поручение 16 подано не полностью", who+": "+err.Error())
}
}
// saveM2MAuto — POST /admin/setup/m2m-auto.
func (h *setupHandlers) saveM2MAuto(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
cur := h.rc.Snapshot().M2MAuto
on := r.FormValue("enabled") != ""
if on == cur.Enabled {
setupFlash(w, r, "Автоматический режим M2M: без изменений")
return
}
s := M2MAutoSettings{Enabled: on, ChangedAt: time.Now()}
if u := currentUser(w); u != nil {
s.ChangedBy = u.Login
}
if err := h.rc.UpdateM2MAuto(s); err != nil {
setupFlash(w, r, "Автоматический режим M2M: ошибка сохранения: "+err.Error())
return
}
if on {
log.Printf("m2m-auto: автоматический режим ВКЛЮЧЁН (%s)", s.ChangedBy)
setupFlash(w, r, "Автоматический режим M2M включён: решения по входящим, поручения 16/1 и 16 bj отправляет сам (первый проход — в течение 30 секунд).")
return
}
log.Printf("m2m-auto: автоматический режим выключен (%s)", s.ChangedBy)
setupFlash(w, r, "Автоматический режим M2M выключен — решения и поручения снова отправляет оператор.")
}
+85
View File
@@ -0,0 +1,85 @@
package lkgateway
import (
"strings"
"testing"
"time"
)
func TestAutoDecisions(t *testing.T) {
it := &IncomingTransfer{Securities: []IncomingSecurity{{ReferenceID: "A"}, {ReferenceID: "B"}}}
// Всё прошло — согласие по всем.
r := depoResult{Summary: "принять", Secs: []depoSecResult{{ReferenceID: "A", Recommend: "confirm"}, {ReferenceID: "B", Recommend: "confirm"}}}
dec, _ := autoDecisions(it, r)
if !dec[0].Confirm || !dec[1].Confirm {
t.Fatalf("согласие: %+v", dec)
}
// Отказ по одной — отказ всем пакетом с её кодом.
r = depoResult{Summary: "отказ пакетом M2M05", Secs: []depoSecResult{{ReferenceID: "A", Recommend: "confirm"}, {ReferenceID: "B", Recommend: "reject", RejectCode: "M2M05"}}}
dec, _ = autoDecisions(it, r)
for _, d := range dec {
if d.Confirm || d.RejectCode != "M2M05" {
t.Fatalf("пакет целиком: %+v", dec)
}
}
// Бумага для квал. инвесторов при неполной квалификации — отказ M2M08 всем.
r = depoResult{Summary: "принять", Secs: []depoSecResult{
{ReferenceID: "A", Security: "RU000A10F5K6", Recommend: "confirm", Checks: []depoCheck{{Name: "Бумага только для квал. инвесторов", OK: true, Warn: true, Strict: true, Code: "M2M08"}}},
{ReferenceID: "B", Recommend: "confirm", Checks: []depoCheck{{Name: "Квал-инвестор (если бумага только для квал.)", OK: true, Warn: true, Code: "M2M08"}}},
}}
dec, why := autoDecisions(it, r)
for _, d := range dec {
if d.Confirm || d.RejectCode != "M2M08" {
t.Fatalf("строгое предупреждение: %+v", dec)
}
}
if !strings.Contains(why, "RU000A10F5K6") {
t.Fatalf("пояснение: %q", why)
}
// Обычное предупреждение (клиент не квал, бумага без ограничений) отказа не даёт.
r = depoResult{Secs: []depoSecResult{
{ReferenceID: "A", Recommend: "confirm", Checks: []depoCheck{{OK: true, Warn: true, Code: "M2M08"}}},
{ReferenceID: "B", Recommend: "confirm"},
}}
if dec, _ = autoDecisions(it, r); !dec[0].Confirm || !dec[1].Confirm {
t.Fatalf("информационное предупреждение не должно отказывать: %+v", dec)
}
// Бумага без результата сверки — не принимаем.
r = depoResult{Secs: []depoSecResult{{ReferenceID: "A", Recommend: "confirm"}}}
if dec, _ = autoDecisions(it, r); dec[0].Confirm || dec[1].Confirm {
t.Fatalf("без сверки по бумаге: %+v", dec)
}
}
func TestM2MAutoState(t *testing.T) {
st := &m2mAutoState{next: map[string]time.Time{}, alerted: map[string]time.Time{}}
now := time.Now()
if !st.due("k", now) {
t.Fatal("новый шаг должен выполняться сразу")
}
st.retry("k", now)
if st.due("k", now.Add(time.Minute)) || !st.due("k", now.Add(m2mAutoRetry)) {
t.Fatal("повтор через m2mAutoRetry")
}
st.done("k")
if !st.due("k", now) {
t.Fatal("после выполнения ключ сбрасывается")
}
}
// Выключенный режим ничего не делает (и не трогает сторы).
func TestM2MAutoDisabled(t *testing.T) {
rc, err := NewRuntimeConfig(t.TempDir() + "/setup.json")
if err != nil {
t.Fatal(err)
}
saved := globalIncomingDeps
globalIncomingDeps = nil
defer func() { globalIncomingDeps = saved }()
m2mAutoTickOnce(t.Context(), rc, time.Now())
}
+3 -3
View File
@@ -31,7 +31,7 @@ var m2mRejectCatalog = []m2mCodeInfo{
{"M2M08", "Операция нарушит законодательство РФ",
"Проведение перевода приведёт к нарушению закона: например, бумага предназначена только для квалифицированных инвесторов, а инвестор не квалифицирован."},
{"M2M09", "Отказ принимающей стороны (доп.)",
"Дополнительный код отказа зачисляющей стороны (используется в тест-сценариях)."},
"Дополнительный код отказа зачисляющей стороны."},
}
// m2mServiceCatalog — коды сервиса МОСТ (ошибки/информирование). Приходят в ответе
@@ -107,9 +107,9 @@ var m2mCodeTexts = map[string]string{
"M2M04": "Инвестор не найден",
"M2M05": "Зачисляющий депозитарий не оказывает услуги по учёту прав на ценную бумагу",
"M2M06": "Приостановление операций по счёту депо зачисления",
"M2M07": "Отказ принимающей стороны (тест 07)",
"M2M07": "Отказ принимающей стороны",
"M2M08": "Проведение операции приведёт к нарушению законодательства РФ",
"M2M09": "Отказ принимающей стороны (тест 09)",
"M2M09": "Отказ принимающей стороны (дополнительный код)",
"M2M10": "Получено невалидное сообщение",
"M2M14": "Отправитель не зарегистрирован в справочнике участников M2M",
"M2M15": "Получатель (зачисляющий депозитарий) не зарегистрирован в справочнике участников M2M", // предположительно (пара к M2M14); уточнить по Спецификации ЭД
+2 -2
View File
@@ -23,7 +23,7 @@ import (
type m2mClientCard struct{ Birth, BirthPlace, Issued, INN string }
var m2mClientCards = map[string]m2mClientCard{
"К1": {Birth: "14.03.1999", BirthPlace: "г. Москва", Issued: "ГУ МВД России по г. Москве, 28.03.2019, код подразделения 770-093", INN: "772941830554"},
"К1": {}, // заводится у обеих сторон; дополнительные анкетные данные у участников свои
}
type m2mDocSec struct{ ISIN, Code, Name, Qty string }
@@ -344,7 +344,7 @@ select.btn{padding:4px 8px}
<div class="tw"><table>
<thead><tr><th>Клиент</th><th>ФИО</th><th>Документ</th><th>Анкетные данные</th><th>Что сделать</th></tr></thead><tbody>
{{range .Clients}}<tr><td><b>{{.Key}}</b></td><td>{{.FIO}}</td><td>{{.Doc}}{{if .Card.Issued}}<div class="note">выдан {{.Card.Issued}}</div>{{end}}</td>
<td>{{if .Register}}дата рождения {{.Card.Birth}}<br>место рождения {{.Card.BirthPlace}}<br>гражданство РФ<br>ИНН <span class="mono">{{.Card.INN}}</span>{{else}}<span class="note">не требуются</span>{{end}}</td>
<td>{{if and .Register .Card.Birth}}дата рождения {{.Card.Birth}}<br>место рождения {{.Card.BirthPlace}}<br>гражданство РФ<br>ИНН <span class="mono">{{.Card.INN}}</span>{{else if .Register}}<span class="note">по учётным данным участника</span>{{else}}<span class="note">не требуются</span>{{end}}</td>
<td>{{if .Register}}Завести у обеих сторон: открытый торговый счёт депо, без признака ИИС, не квалифицированный инвестор. ФИО и паспорт — строго как в таблице.{{else}}<b>Не заводить</b> у стороны, которая зачисляет в кейсах с M2M04. Для отправки запроса по К2 достаточно данных из таблицы.{{end}}</td></tr>
{{end}}</tbody></table></div>
</div>
+26
View File
@@ -0,0 +1,26 @@
package lkgateway
import (
"net/http/httptest"
"net/url"
"strings"
"testing"
)
func TestM2MQtyOverrides(t *testing.T) {
c := m2mCase{ID: "2", Secs: []m2mSec{sSBER("10"), {ISIN: "RU000A0JUR61", Code: "PAI", Qty: "5", Frac: true}}}
form := url.Values{"qty_RU0009029540": {"1"}, "qty_RU000A0JUR61": {"2,5"}}
r := httptest.NewRequest("POST", "/admin/m2m-testing/initiate", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
got := m2mQtyOverrides(r, c)
if got["RU0009029540"] != "1" || got["RU000A0JUR61"] != "2.5" {
t.Fatalf("количество: %v", got)
}
if note := m2mQtyNote(c, got); !strings.Contains(note, "SBER/03 × 1 (по плану 10)") {
t.Fatalf("отметка: %q", note)
}
bad := httptest.NewRequest("GET", "/?qty_RU0009029540=1.5&qty_RU000A0JUR61=-1", nil)
if got := m2mQtyOverrides(bad, c); len(got) != 0 {
t.Fatalf("дробное для акций и отрицательное — игнорировать: %v", got)
}
}
+46 -9
View File
@@ -18,6 +18,7 @@ import (
"net/http"
"net/url"
"os"
"strconv"
"strings"
"sync"
"time"
@@ -36,7 +37,7 @@ type m2mSec struct {
type m2mClient struct{ Last, First, Middle, DocSeries, DocNumber, SeedID string }
var m2mTestClients = map[string]m2mClient{
"К1": {"Мостовиков", "Арсений", "Тимурович", "4519", "736284", seedClientK1},
"К1": {"Бубликов", "Павел", "Иванович", "3453", "289090", seedClientK1},
"К2": {"Баранкин", "Сергей", "Сергеевич", "3763", "290089", seedClientK2},
}
@@ -373,10 +374,11 @@ func registerM2MTest(mux *http.ServeMux, a *admin, h *setupHandlers) {
ISIN, Code, Name, Qty string
Bad bool
}
qtys := m2mQtyOverrides(r, *c)
var secs []pvSec
for _, s := range c.Secs {
secs = append(secs, pvSec{
ISIN: s.ISIN, Code: s.Code, Name: m2mSecName[s.ISIN], Qty: s.Qty,
ISIN: s.ISIN, Code: s.Code, Name: m2mSecName[s.ISIN], Qty: pickStr(qtys[s.ISIN], s.Qty),
Bad: m2mSecBad(*c, s),
})
}
@@ -420,7 +422,7 @@ func registerM2MTest(mux *http.ServeMux, a *admin, h *setupHandlers) {
}
var ss []depoOutSec
for _, s := range c.Secs {
ss = append(ss, depoOutSec{ReferenceID: s.Code, ISIN: s.ISIN, Code: s.Code, Qty: s.Qty})
ss = append(ss, depoOutSec{ReferenceID: s.Code, ISIN: s.ISIN, Code: s.Code, Qty: pickStr(qtys[s.ISIN], s.Qty)})
}
return runDepoCheckOut(cl.Last, cl.First, cl.Middle, cl.DocSeries, cl.DocNumber, ss, nsd.AccountID, nsd.SectionID)
}(),
@@ -562,7 +564,8 @@ func registerM2MTest(mux *http.ServeMux, a *admin, h *setupHandlers) {
back("Сначала укажите код участника контрагента (кнопка «Контрагент»)")
return
}
guid, err := h.m2mInitiate(*cs, plan)
qtys := m2mQtyOverrides(r, *cs)
guid, err := h.m2mInitiate(*cs, plan, qtys)
m2mMu.Lock()
plan = m2mLoadPlan()
st := plan.States[id]
@@ -574,7 +577,7 @@ func registerM2MTest(mux *http.ServeMux, a *admin, h *setupHandlers) {
} else {
st.Status = "in_progress"
st.GUID = guid
st.Note = "Отправлено контрагенту " + plan.CounterpartyCode + " (GUID " + guid + "). Ждём ответ."
st.Note = "Отправлено контрагенту " + plan.CounterpartyCode + " (GUID " + guid + ")" + m2mQtyNote(*cs, qtys) + ". Ждём ответ."
msg = "Кейс " + id + ": исходящий M2M отправлен (GUID " + guid + "). Ответ придёт во «Входящие»."
}
st.Updated = time.Now()
@@ -585,8 +588,41 @@ func registerM2MTest(mux *http.ServeMux, a *admin, h *setupHandlers) {
})
}
// m2mInitiate строит исходящий M2M по кейсу и отправляет через НРД контрагенту.
func (h *setupHandlers) m2mInitiate(c m2mCase, plan m2mPlan) (string, error) {
// m2mQtyOverrides — количество по бумагам, выбранное в предпросмотре (qty_<ISIN>); неверное значение
// игнорируется (остаётся количество из плана).
func m2mQtyOverrides(r *http.Request, c m2mCase) map[string]string {
out := map[string]string{}
for _, s := range c.Secs {
v := strings.ReplaceAll(strings.TrimSpace(r.FormValue("qty_"+s.ISIN)), ",", ".")
if v == "" {
continue
}
f, err := strconv.ParseFloat(v, 64)
if err != nil || f <= 0 || (!s.Frac && f != float64(int64(f))) {
continue
}
out[s.ISIN] = v
}
return out
}
// m2mQtyNote — «, количество: SBER/03 × 1» для отметки кейса, если количество меняли.
func m2mQtyNote(c m2mCase, qtys map[string]string) string {
var parts []string
for _, s := range c.Secs {
if q, ok := qtys[s.ISIN]; ok && q != s.Qty {
parts = append(parts, s.Code+" × "+q+" (по плану "+s.Qty+")")
}
}
if len(parts) == 0 {
return ""
}
return ", количество: " + strings.Join(parts, "; ")
}
// m2mInitiate строит исходящий M2M по кейсу и отправляет через НРД контрагенту. qtys — количество,
// выбранное в предпросмотре (ISIN → количество), иначе — из плана.
func (h *setupHandlers) m2mInitiate(c m2mCase, plan m2mPlan, qtys map[string]string) (string, error) {
if len(c.Secs) == 0 {
return "", fmt.Errorf("в кейсе %s нет бумаг для отправки (это кейс сценария 2 — формируется вручную)", c.ID)
}
@@ -596,9 +632,10 @@ func (h *setupHandlers) m2mInitiate(c m2mCase, plan m2mPlan) (string, error) {
secs := make([]ClaimSec, 0, len(c.Secs))
for _, s := range c.Secs {
qty := Quantity{Whole: uint64(atoiSafe(s.Qty))}
q := pickStr(qtys[s.ISIN], s.Qty)
qty := Quantity{Whole: uint64(atoiSafe(q))}
if s.Frac {
qty = Quantity{Fractional: s.Qty}
qty = Quantity{Fractional: q}
}
secs = append(secs, ClaimSec{
SecurityCode: s.Code,
+2 -4
View File
@@ -115,10 +115,8 @@ func writeIncomingBundleZip(zw *zip.Writer, it *IncomingTransfer, prefix string)
_, _ = f.Write(faithfulXMLBytes(it.OrderXML))
}
}
for _, rf := range collectOrderResponseRaw(it.OrderContrDID) {
if f, e := zw.Create(prefix + "otvet-nrd/" + rf.Name); e == nil {
_, _ = f.Write(rf.Data)
}
if docs := nsdOrderDocs(it.OrderContrDID); len(docs) > 0 {
writeNSDDocsZip(zw, prefix+"Ответ НРД/", "Документы НРД по поручению 16/1 № "+it.OrderContrDID, docs, false)
}
}
+701
View File
@@ -0,0 +1,701 @@
package lkgateway
// manual.go — ручной ввод ИСХОДЯЩЕГО перевода M2M по бумажному поручению клиента.
//
// Клиент приносит поручение на бумаге, сотрудник депозитария вводит его в bj так, как клиент
// заполнил бы заявку в личном кабинете. Дальше перевод идёт тем же путём, что и заявка ЛК:
// пакет M2MTransferRequest в НРД → запись в DEPO (sentHook) → блокировка в QUIK (quikSendHook,
// если сотрудник не заблокировал вручную) → ответ контрагента → черновик формы 16 → поручение 16
// (вручную кнопкой или автоматическим режимом) → отчёт НРД MS010.
//
// Отправить нельзя, пока не заполнены реквизиты, не выполнена сверка с DEPO (если учётная система
// подключена) и не проставлены обязательные отметки сверки с учётной и торговой системами. Каждая
// отметка хранит, кто и когда её поставил. Поручения — в manual-orders.json.
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
)
const manualFile = "/var/lib/bj/.bj/manual-orders.json"
// ManualSec — бумага в поручении.
type ManualSec struct {
ISIN string `json:"isin"`
Code string `json:"code,omitempty"` // код НРД (необязательно; пусто — ISIN)
Name string `json:"name,omitempty"`
Qty string `json:"qty"`
}
// ManualMark — отметка сотрудника (кто и когда).
type ManualMark struct {
Done bool `json:"done"`
By string `json:"by,omitempty"`
At time.Time `json:"at,omitempty"`
}
// ManualDepoCheck — результат сверки с DEPO на момент Hash (данные поручения).
type ManualDepoCheck struct {
At time.Time `json:"at"`
By string `json:"by,omitempty"`
Hash string `json:"hash"`
Ran bool `json:"ran"`
Err string `json:"err,omitempty"`
Recommend string `json:"recommend,omitempty"`
Summary string `json:"summary,omitempty"`
Investor string `json:"investor,omitempty"`
Account string `json:"account,omitempty"`
Secs []depoSecResult `json:"secs,omitempty"`
}
// ManualOrder — поручение, введённое вручную.
type ManualOrder struct {
ID int `json:"id"`
CreatedAt time.Time `json:"created_at"`
CreatedBy string `json:"created_by,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
// Основание
PaperNo string `json:"paper_no"`
PaperDate string `json:"paper_date"` // ГГГГ-ММ-ДД
ReceivedVia string `json:"received_via"`
// Клиент
LastName string `json:"last_name"`
FirstName string `json:"first_name"`
MiddleName string `json:"middle_name,omitempty"`
BirthDate string `json:"birth_date"` // ГГГГ-ММ-ДД
DocType string `json:"doc_type"`
DocSeries string `json:"doc_series,omitempty"`
DocNumber string `json:"doc_number"`
DocIssueDate string `json:"doc_issue_date,omitempty"`
DocIssuer string `json:"doc_issuer,omitempty"`
Contract string `json:"contract,omitempty"`
Account string `json:"account"`
QuikCode string `json:"quik_code,omitempty"`
Phone string `json:"phone,omitempty"`
// Принимающая сторона
RecvINN string `json:"recv_inn"`
RecvCode string `json:"recv_code,omitempty"`
RecvName string `json:"recv_name,omitempty"`
RecvAccount string `json:"recv_account,omitempty"` // счёт клиента у принимающего (для сведения)
SettlePlace string `json:"settle_place"` // nrd (СПБ Банк пока не используется)
CostInfo bool `json:"cost_info"`
Securities []ManualSec `json:"securities"`
Comment string `json:"comment,omitempty"`
// Сверка и отметки до отправки
Depo *ManualDepoCheck `json:"depo,omitempty"`
Checks map[string]ManualMark `json:"checks,omitempty"`
QuikMode string `json:"quik_mode,omitempty"` // auto | manual | none
QuikInstr string `json:"quik_instr,omitempty"`
// Отправка
Status string `json:"status"` // draft | sent | declined
GUID string `json:"guid,omitempty"`
DealID string `json:"deal_id,omitempty"`
SentAt time.Time `json:"sent_at,omitempty"`
SentBy string `json:"sent_by,omitempty"`
SendErr string `json:"send_err,omitempty"`
DeclineReason string `json:"decline_reason,omitempty"`
DeclinedBy string `json:"declined_by,omitempty"`
DeclinedAt time.Time `json:"declined_at,omitempty"`
// Отметки после ответа
Post map[string]ManualMark `json:"post,omitempty"`
}
// FullName — ФИО клиента.
func (o *ManualOrder) FullName() string {
return strings.TrimSpace(strings.Join([]string{o.LastName, o.FirstName, o.MiddleName}, " "))
}
// Number — номер поручения в bj.
func (o *ManualOrder) Number() string { return fmt.Sprintf("Р-%05d", o.ID) }
// dataHash — отпечаток данных, от которых зависят сверка и отметки: изменили данные — сверку и
// отметки нужно пройти заново.
func (o *ManualOrder) dataHash() string {
b, _ := json.Marshal([]any{o.LastName, o.FirstName, o.MiddleName, o.BirthDate, o.DocType, o.DocSeries, o.DocNumber,
o.Account, o.RecvINN, o.SettlePlace, o.CostInfo, o.Securities})
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:8])
}
// manualCheckDef — обязательная отметка.
type manualCheckDef struct{ Key, Group, Title, Hint string }
// manualPreChecks — отметки перед отправкой (все обязательны).
var manualPreChecks = []manualCheckDef{
{"paper", "Поручение клиента", "Поручение оформлено правильно",
"подпись клиента или представителя, дата, реквизиты бумаг и принимающего депозитария; полномочия представителя (доверенность) проверены"},
{"client", "Учётная система депозитария", "Клиент идентифицирован",
"ФИО, документ, удостоверяющий личность, и дата рождения совпадают с анкетой депонента"},
{"account", "Учётная система депозитария", "Счёт депо открыт, принадлежит клиенту и не является ИИС",
"перевод с индивидуального инвестиционного счёта через M2M не выполняется"},
{"balance", "Учётная система депозитария", "Остаток достаточен, бумаги свободны",
"на торговом разделе не меньше количества в поручении; нет обременений, ареста и блокировок"},
{"security", "Учётная система депозитария", "Бумаги допускаются к переводу M2M",
"российские выпуски (ISIN начинается с RU), приняты на обслуживание"},
{"qual", "Учётная система депозитария", "Ограничения для квалифицированных инвесторов проверены",
"если бумага только для квалифицированных инвесторов — статус клиента проверен, клиент предупреждён о возможном отказе"},
{"fees", "Учётная система депозитария", "Задолженности по оплате услуг нет",
"или задолженность урегулирована по правилам депозитария"},
{"counterparty", "Принимающая сторона", "Принимающий депозитарий — участник M2M",
"ИНН и наименование сверены со справочником участников НРД"},
}
// manualPostDef — отметки после ответа: when — done (исполнено) | rejected (отказ); quik — только при
// ручной блокировке в торговой системе.
type manualPostDef struct {
Key, When, Title string
Quik bool
}
var manualPostChecks = []manualPostDef{
{"quik_debit", "done", "Списание в торговой системе проведено (лимит уменьшен окончательно)", true},
{"depo_done", "done", "Операция списания отражена в учётной системе депозитария", false},
{"client_done", "done", "Клиент уведомлён об исполнении", false},
{"paper_done", "done", "На поручении проставлена отметка об исполнении", false},
{"quik_unblock", "rejected", "Блокировка в торговой системе снята (лимит восстановлен)", true},
{"client_rejected", "rejected", "Клиент уведомлён об отказе и его причине", false},
{"paper_rejected", "rejected", "На поручении проставлена отметка об отказе", false},
}
// manualStore — поручения ручного ввода (переживают перезапуск).
type manualStore struct {
mu sync.Mutex
path string
items map[int]*ManualOrder
next int
}
var globalManual = loadManualStore(manualFile)
func loadManualStore(path string) *manualStore {
s := &manualStore{path: path, items: map[int]*ManualOrder{}, next: 1}
if b, err := os.ReadFile(path); err == nil {
var list []*ManualOrder
if json.Unmarshal(b, &list) == nil {
for _, o := range list {
if o.Status == "sending" { // отправка прервана перезапуском
o.Status, o.SendErr = "draft", "отправка прервана перезапуском сервиса — перед повтором проверьте журнал исходящих переводов"
}
s.items[o.ID] = o
if o.ID >= s.next {
s.next = o.ID + 1
}
}
}
}
return s
}
func (s *manualStore) saveLocked() error {
list := make([]*ManualOrder, 0, len(s.items))
for _, o := range s.items {
list = append(list, o)
}
sort.Slice(list, func(i, j int) bool { return list[i].ID < list[j].ID })
b, err := json.MarshalIndent(list, "", " ")
if err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, b, 0o600); err != nil {
return err
}
return os.Rename(tmp, s.path)
}
func (s *manualStore) Get(id int) (*ManualOrder, bool) {
s.mu.Lock()
defer s.mu.Unlock()
o, ok := s.items[id]
if !ok {
return nil, false
}
b, _ := json.Marshal(o) // глубокая копия
var cp ManualOrder
_ = json.Unmarshal(b, &cp)
return &cp, true
}
func (s *manualStore) List() []*ManualOrder {
s.mu.Lock()
ids := make([]int, 0, len(s.items))
for id := range s.items {
ids = append(ids, id)
}
s.mu.Unlock()
sort.Sort(sort.Reverse(sort.IntSlice(ids)))
out := make([]*ManualOrder, 0, len(ids))
for _, id := range ids {
if o, ok := s.Get(id); ok {
out = append(out, o)
}
}
return out
}
// Create — новое поручение (ID присваивается).
func (s *manualStore) Create(o *ManualOrder) error {
s.mu.Lock()
defer s.mu.Unlock()
o.ID = s.next
s.next++
o.CreatedAt, o.UpdatedAt = time.Now(), time.Now()
s.items[o.ID] = o
return s.saveLocked()
}
// Update — изменение под замком: fn получает текущую запись.
func (s *manualStore) Update(id int, fn func(o *ManualOrder) error) error {
s.mu.Lock()
defer s.mu.Unlock()
o, ok := s.items[id]
if !ok {
return errors.New("поручение не найдено")
}
b, _ := json.Marshal(o)
var cp ManualOrder
_ = json.Unmarshal(b, &cp)
if err := fn(&cp); err != nil {
return err
}
cp.UpdatedAt = time.Now()
s.items[id] = &cp
return s.saveLocked()
}
var (
manualINNRe = regexp.MustCompile(`^\d{10}$`)
manualISINRe = regexp.MustCompile(`^[A-Z]{2}[A-Z0-9]{9}\d$`)
manualDateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
)
// manualQty — количество: целое или дробное (паи), > 0.
func manualQty(s string) (Quantity, bool) {
s = strings.ReplaceAll(strings.TrimSpace(s), ",", ".")
f, err := strconv.ParseFloat(s, 64)
if err != nil || f <= 0 {
return Quantity{}, false
}
if f == float64(uint64(f)) {
return Quantity{Whole: uint64(f)}, true
}
return Quantity{Fractional: strconv.FormatFloat(f, 'f', -1, 64)}, true
}
// manualDataProblems — что не заполнено или заполнено неверно.
func manualDataProblems(o *ManualOrder) []string {
var p []string
need := func(v, what string) {
if strings.TrimSpace(v) == "" {
p = append(p, "не заполнено: "+what)
}
}
need(o.PaperNo, "номер поручения клиента")
if !manualDateRe.MatchString(o.PaperDate) {
p = append(p, "не заполнена дата поручения клиента")
}
need(o.LastName, "фамилия клиента")
need(o.FirstName, "имя клиента")
if !manualDateRe.MatchString(o.BirthDate) {
p = append(p, "не заполнена дата рождения клиента")
}
need(o.DocType, "вид документа")
need(o.DocNumber, "номер документа")
if o.DocType == "21" && len(strings.ReplaceAll(o.DocSeries, " ", "")) != 4 {
p = append(p, "серия паспорта РФ — 4 цифры")
}
need(o.Account, "счёт депо клиента")
if !manualINNRe.MatchString(strings.TrimSpace(o.RecvINN)) {
p = append(p, "ИНН принимающего депозитария — 10 цифр")
}
switch {
case len(o.Securities) == 0:
p = append(p, "не указана бумага")
case len(o.Securities) > 1:
p = append(p, "в одном поручении — одна бумага: на каждую бумагу оформите отдельное поручение")
}
for i, s := range o.Securities {
isin := strings.ToUpper(strings.TrimSpace(s.ISIN))
switch {
case !manualISINRe.MatchString(isin):
p = append(p, fmt.Sprintf("бумага %d: неверный ISIN «%s»", i+1, s.ISIN))
case !isRussianISIN(isin):
p = append(p, fmt.Sprintf("бумага %d: %s — иностранный выпуск, через M2M не переводится", i+1, isin))
}
if _, ok := manualQty(s.Qty); !ok {
p = append(p, fmt.Sprintf("бумага %d: количество должно быть больше нуля", i+1))
}
}
return p
}
// manualDepoConfigured — подключена ли учётная система (сверка обязательна).
func manualDepoConfigured(rc *RuntimeConfig) bool {
return rc != nil && strings.TrimSpace(rc.Snapshot().Fancy.Server) != ""
}
// manualQuikAutoAvailable — может ли bj сам заблокировать бумаги в торговой системе при отправке.
func manualQuikAutoAvailable(rc *RuntimeConfig) bool {
if rc == nil {
return false
}
q := rc.Snapshot().Quik
return quikActive(q) && q.AutoOut
}
// manualSendBlockers — почему поручение пока нельзя отправить (пусто — можно).
func manualSendBlockers(rc *RuntimeConfig, o *ManualOrder) []string {
if o.Status != "draft" {
return []string{"поручение уже " + manualStatusRu(o.Status)}
}
b := manualDataProblems(o)
if manualDepoConfigured(rc) {
switch {
case o.Depo == nil:
b = append(b, "не выполнена сверка с учётной системой (DEPO)")
case o.Depo.Hash != o.dataHash():
b = append(b, "данные изменены после сверки с DEPO — выполните сверку заново")
case !o.Depo.Ran:
b = append(b, "сверка с DEPO не выполнена: "+o.Depo.Err)
case o.Depo.Recommend != "confirm":
b = append(b, "сверка с DEPO не пройдена: "+o.Depo.Summary)
}
}
for _, c := range manualPreChecks {
if !o.Checks[c.Key].Done {
b = append(b, "нет отметки: "+c.Title)
}
}
switch o.QuikMode {
case "auto":
if !manualQuikAutoAvailable(rc) {
b = append(b, "автоматическая блокировка в торговой системе не включена — заблокируйте бумаги вручную")
}
case "manual":
if strings.TrimSpace(o.QuikInstr) == "" {
b = append(b, "укажите номер поручения на блокировку в торговой системе")
}
case "none":
default:
b = append(b, "не выбрано, как заблокированы бумаги в торговой системе")
}
return b
}
func manualStatusRu(s string) string {
switch s {
case "draft":
return "черновик"
case "sent":
return "отправлено в НРД"
case "sending":
return "отправляется"
case "declined":
return "отклонено депозитарием"
}
return s
}
// manualClaim — заявка в том же виде, что из личного кабинета.
func manualClaim(rc *RuntimeConfig, o *ManualOrder) (CreateClaimRequest, error) {
nsd := rc.Snapshot().NSD
var secs []ClaimSec
for _, s := range o.Securities {
isin := strings.ToUpper(strings.TrimSpace(s.ISIN))
q, ok := manualQty(s.Qty)
if !ok {
return CreateClaimRequest{}, fmt.Errorf("количество по %s", isin)
}
secs = append(secs, ClaimSec{
SecurityCode: pick(strings.TrimSpace(s.Code), isin),
SecurityDetails: SecurityDetails{ISIN: isin},
Quantity: q,
SettlementAccounts: []SettlementAccount{{
SettlementRequisitesINN: transferringINN(rc.Snapshot()),
SettlementLocation: SettlementLocation{DeponentCode: nsd.DeponentCode, AccountID: nsd.AccountID, SectionID: nsd.SectionID},
}},
})
}
cost := CostInfo{No: &struct{}{}}
if o.CostInfo {
cost = CostInfo{Yes: &CostInfoYes{Code: nsd.DeponentCode}}
}
ser := strings.ReplaceAll(strings.TrimSpace(o.DocSeries), " ", "")
return CreateClaimRequest{
Investor: Investor{
ID: lkInvestorUUID("manual:" + ser + strings.TrimSpace(o.DocNumber)),
LastName: strings.TrimSpace(o.LastName), FirstName: strings.TrimSpace(o.FirstName), MiddleName: strings.TrimSpace(o.MiddleName),
Document: Document{DocumentType: o.DocType, Series: ser, Number: strings.TrimSpace(o.DocNumber)},
BirthDate: o.BirthDate,
},
TransferringDepositoryINN: transferringINN(rc.Snapshot()),
ReceivingDepositoryINN: strings.TrimSpace(o.RecvINN),
CostInfo: cost,
Securities: secs,
SignatureFormat: "PAPER",
ClientAccount: strings.ToUpper(strings.TrimSpace(o.Account)),
}, nil
}
// manualDepoRun — сверка поручения с DEPO теми же проверками, что и заявка ЛК.
func manualDepoRun(rc *RuntimeConfig, o *ManualOrder) *ManualDepoCheck {
nsd := rc.Snapshot().NSD
var secs []depoOutSec
for i, s := range o.Securities {
secs = append(secs, depoOutSec{ReferenceID: strconv.Itoa(i + 1), ISIN: strings.ToUpper(strings.TrimSpace(s.ISIN)), Code: s.Code, Qty: s.Qty})
}
res := runDepoCheckOutAccount(o.LastName, o.FirstName, o.MiddleName, strings.ReplaceAll(o.DocSeries, " ", ""), strings.TrimSpace(o.DocNumber), o.BirthDate,
secs, nsd.AccountID, nsd.SectionID, strings.ToUpper(strings.TrimSpace(o.Account)))
return &ManualDepoCheck{At: time.Now(), Hash: o.dataHash(), Ran: res.Ran, Err: res.Err, Recommend: res.Recommend,
Summary: res.Summary, Investor: res.Investor, Account: res.Account, Secs: res.Secs}
}
// manualDepoClient — клиент по документу из DEPO: ФИО, дата рождения, открытый торговый счёт.
type manualDepoClient struct {
Found bool
Name, Birth, Account string
Err string
}
func manualDepoLookup(rc *RuntimeConfig, ser, num string) manualDepoClient {
var out manualDepoClient
if !manualDepoConfigured(rc) {
out.Err = "учётная система (DEPO) не подключена"
return out
}
fs := rc.Snapshot().Fancy
db, ctx, cancel, errS := depoOpen(fs)
if errS != "" {
out.Err = errS
return out
}
defer cancel()
defer db.Close()
p := depoResolveParty(ctx, db, fs, strings.ReplaceAll(ser, " ", ""), strings.TrimSpace(num), "")
if !p.faceOK {
out.Err = "клиент с таким документом в DEPO не найден"
return out
}
out.Found, out.Name, out.Account = true, p.faceName, p.accOpen
if p.birth.Valid {
out.Birth = p.birth.Time.Format("2006-01-02")
}
return out
}
// manualDepoSecNames — наименования бумаг из DEPO по ISIN (не найденные — без записи).
func manualDepoSecNames(rc *RuntimeConfig, isins []string) map[string]string {
out := map[string]string{}
if !manualDepoConfigured(rc) || len(isins) == 0 {
return out
}
fs := rc.Snapshot().Fancy
db, ctx, cancel, errS := depoOpen(fs)
if errS != "" {
return out
}
defer cancel()
defer db.Close()
for _, isin := range isins {
if _, name, found, _ := depoSecurity(ctx, db, isin); found {
out[isin] = name
}
}
return out
}
// manualQuikSkipKey — в контексте отправки: бумаги в торговой системе заблокированы вручную
// (или торговая система не используется) — хук QUIK не вызывается.
type manualQuikSkipKey struct{}
func manualQuikSkipped(ctx context.Context) bool {
v, _ := ctx.Value(manualQuikSkipKey{}).(bool)
return v
}
// manualSend — отправка поручения в НРД тем же путём, что и заявка из личного кабинета.
func manualSend(ctx context.Context, rc *RuntimeConfig, svc *Service, o *ManualOrder) (guid, dealID string, err error) {
claim, err := manualClaim(rc, o)
if err != nil {
return "", "", err
}
if ss, ok := svc.store.(*SeedStore); ok {
nsd := rc.Snapshot().NSD
ss.EnsureLKClient(LKUser{
Login: "manual:" + o.Number(), Contract: claim.Investor.ID,
LastName: claim.Investor.LastName, FirstName: claim.Investor.FirstName, MiddleName: claim.Investor.MiddleName,
DocType: claim.Investor.Document.DocumentType, DocSeries: claim.Investor.Document.Series, DocNumber: claim.Investor.Document.Number,
Active: true,
}, nil, m2mcore.DepoAccount{
DeponentCode: nsd.DeponentCode,
AccountID: m2m.AccountID(nsd.AccountID),
SectionID: nsd.SectionID,
DepositoryINN: m2m.OrganizationINN(settlementPlaceINN(o.SettlePlace)),
})
}
req, err := svc.BuildRequest(ctx, claim)
if err != nil {
return "", "", fmt.Errorf("пакет не прошёл проверку формата НРД: %w", err)
}
sender := svc.sender
if emu, _ := lkEmulateNSD(rc); emu && lkEmuSender != nil {
sender = lkEmuSender
}
if o.QuikMode != "auto" {
ctx = context.WithValue(ctx, manualQuikSkipKey{}, true)
}
resp, err := svc.SendBuiltRequestVia(ctx, sender, req, claim.Investor.ID, nil)
if err != nil {
return string(req.Header.GUID), "", err
}
return string(req.Header.GUID), resp.ID, nil
}
// ---- ход исполнения (для карточки) ----
// manualStep — шаг исполнения: State — done | wait | todo | fail | skip.
type manualStep struct {
Title, Detail, State string
At time.Time
}
// manualProgress — ход исполнения отправленного поручения и итог: outcome — "" (в работе) |
// done | rejected.
type manualProgress struct {
Steps []manualStep
Outcome string
Claim *ClaimView
Depo *OutgoingDeal
Quik []*QuikLink
}
func manualProgressFor(ctx context.Context, svc *Service, o *ManualOrder) manualProgress {
var pr manualProgress
add := func(title, detail, state string, at time.Time) {
pr.Steps = append(pr.Steps, manualStep{Title: title, Detail: detail, State: state, At: at})
}
add("Поручение введено", o.CreatedBy, "done", o.CreatedAt)
if o.Depo != nil {
st := "done"
if !o.Depo.Ran || o.Depo.Recommend != "confirm" {
st = "fail"
}
add("Сверка с учётной системой", pick(o.Depo.Summary, o.Depo.Err), st, o.Depo.At)
}
if o.Status == "declined" {
add("Отклонено депозитарием", o.DeclineReason, "fail", o.DeclinedAt)
pr.Outcome = "rejected"
return pr
}
if o.Status != "sent" {
add("Отправка в НРД", "после сверки и отметок", "todo", time.Time{})
return pr
}
add("Пакет отправлен в НРД", "GUID "+o.GUID, "done", o.SentAt)
if svc != nil && o.DealID != "" {
if v, err := svc.GetClaim(ctx, o.DealID); err == nil {
pr.Claim = &v
}
}
if o.GUID != "" {
pr.Quik = globalQuikLinks.ForGUID(o.GUID)
if globalOutgoingStore != nil {
if d, ok := scanForm16Deal(globalOutgoingStore, o.GUID); ok {
pr.Depo = d
}
}
}
c := pr.Claim
switch {
case c == nil:
add("Ответ сервиса MOEX МОСТ", "нет данных о переводе", "wait", time.Time{})
case c.M2MResponse != nil && c.M2MResponse.StatusCode == "ERROR":
var codes []string
for _, r := range c.M2MResponse.Responses {
codes = append(codes, strings.TrimSpace(r.Code+" "+r.Text))
}
add("Ответ сервиса MOEX МОСТ: ошибка", strings.Join(codes, "; "), "fail", c.UpdatedAt)
pr.Outcome = "rejected"
return pr
case c.M2MResponse != nil:
add("Пакет принят сервисом MOEX МОСТ", c.M2MResponse.StatusCode, "done", time.Time{})
default:
add("Ответ сервиса MOEX МОСТ", "ждём квитанцию", "wait", time.Time{})
}
if c != nil && c.M2MDecision != nil {
rejected := false
var codes []string
for _, s := range c.M2MDecision.Securities {
if s.Outcome == "rejected" {
rejected = true
codes = append(codes, s.RejectCodes...)
}
}
if rejected {
add("Принимающий депозитарий отказал", rejectCodesLabel(codes), "fail", time.Time{})
pr.Outcome = "rejected"
return pr
}
add("Принимающий депозитарий согласился", "", "done", time.Time{})
} else if c != nil && (c.Status == "rejected" || c.Status == "timed_out") {
add("Перевод не состоялся", russianState(c.Status), "fail", c.UpdatedAt)
pr.Outcome = "rejected"
return pr
} else if pr.Depo == nil {
add("Решение принимающего депозитария", "ждём ответ контрагента", "wait", time.Time{})
}
d := pr.Depo
switch {
case d == nil:
add("Черновик поручения 16 от НРД", "придёт после согласия контрагента", "todo", time.Time{})
add("Поручение 16 подано", "", "todo", time.Time{})
add("Отчёт НРД об исполнении (MS010)", "", "todo", time.Time{})
return pr
case !d.Submitted:
add("Черновик поручения 16 получен", "", "done", d.DraftAt)
add("Поручение 16 подано", "подайте поручение кнопкой ниже (в автоматическом режиме bj подаёт сам)", "wait", time.Time{})
add("Отчёт НРД об исполнении (MS010)", "", "todo", time.Time{})
return pr
}
add("Черновик поручения 16 получен", "", "done", d.DraftAt)
add("Поручение 16 подано", "№ "+strings.Join(d.ContrDIDs, ", "), "done", d.OrderAt)
if d.Done {
add("Отчёт НРД об исполнении (MS010)", "бумаги списаны", "done", time.Time{})
pr.Outcome = "done"
} else {
add("Отчёт НРД об исполнении (MS010)", "ждём встречное поручение контрагента и отчёт НРД", "wait", time.Time{})
}
return pr
}
// manualDocTypes — виды документов (справочник НРД).
var manualDocTypes = []struct{ Code, Title string }{
{"21", "Паспорт гражданина РФ"},
{"03", "Свидетельство о рождении"},
{"10", "Паспорт иностранного гражданина"},
{"12", "Вид на жительство в РФ"},
{"22", "Заграничный паспорт гражданина РФ"},
{"07", "Военный билет"},
{"91", "Иной документ"},
}
+197
View File
@@ -0,0 +1,197 @@
package lkgateway
// manual_depo.go — подбор клиента и бумаг из DEPO для ручного ввода: поиск по ФИО или номеру
// документа, открытые торговые счета, бумаги на торговом разделе с остатком.
import (
"database/sql"
"encoding/json"
"net/http"
"strconv"
"strings"
)
// manualDepoAcc — открытый торговый счёт клиента.
type manualDepoAcc struct {
ClientID int64 `json:"client_id"`
Number string `json:"number"`
Contract string `json:"contract,omitempty"`
IIS bool `json:"iis,omitempty"`
}
// manualDepoFace — клиент из картотеки DEPO.
type manualDepoFace struct {
LastName string `json:"last_name"`
FirstName string `json:"first_name"`
MiddleName string `json:"middle_name,omitempty"`
Name string `json:"name"`
Birth string `json:"birth,omitempty"` // ГГГГ-ММ-ДД
DocType string `json:"doc_type,omitempty"`
DocSer string `json:"doc_ser,omitempty"`
DocNum string `json:"doc_num"`
DocDate string `json:"doc_date,omitempty"`
DocIssuer string `json:"doc_issuer,omitempty"`
Phone string `json:"phone,omitempty"`
Accounts []manualDepoAcc `json:"accounts"`
}
// manualDepoPos — бумага на торговом разделе клиента.
type manualDepoPos struct {
ISIN string `json:"isin"`
Name string `json:"name"`
Qty float64 `json:"qty"`
Russian bool `json:"russian"`
}
// depoDocTypeNSD — вид документа DEPO → код справочника НРД (паспорт РФ в DEPO — 20).
func depoDocTypeNSD(t int64) string {
if t == 20 {
return "21"
}
return ""
}
// manualDepoSearch — клиенты с открытым торговым счётом по ФИО (часть, без учёта регистра) или номеру документа.
func manualDepoSearch(rc *RuntimeConfig, q string) ([]manualDepoFace, string) {
q = strings.Join(strings.Fields(q), " ")
if len([]rune(q)) < 3 {
return nil, "введите не меньше 3 символов фамилии или номер документа"
}
if !manualDepoConfigured(rc) {
return nil, "учётная система (DEPO) не подключена"
}
fs := rc.Snapshot().Fancy
db, ctx, cancel, errS := depoOpen(fs)
if errS != "" {
return nil, errS
}
defer cancel()
defer db.Close()
digits := strings.ReplaceAll(q, " ", "")
// Действующая версия карточки (LAST_FLAG=1); счета привязаны к исходной записи (SELF_ID).
rows, err := db.QueryContext(ctx, `SELECT TOP 20 ISNULL(NULLIF(f.SELF_ID,0),f.ID), ISNULL(f.NAME,''), f.DATE_BIRTH, ISNULL(f.DOC_TYPE,0), ISNULL(f.DOC_SER,''), ISNULL(f.DOC_NUM,''),
f.DOC_DAT, ISNULL(f.DOC_PLACE,''), ISNULL(f.DOC_CODE,''), ISNULL(f.PHONE,'')
FROM OD_FACES f
WHERE ISNULL(f.LAST_FLAG,1)=1 AND (UPPER(f.NAME) LIKE UPPER(@q) OR f.DOC_NUM=@d OR REPLACE(ISNULL(f.DOC_SER,''),' ','')+f.DOC_NUM=@d)
AND EXISTS (SELECT 1 FROM OD_CLIENTS c WHERE c.FACE=ISNULL(NULLIF(f.SELF_ID,0),f.ID) AND c.NUMBER LIKE N'ВТНКЦ%' AND c.STATE=0 AND c.D_CLOSE IS NULL)
ORDER BY f.NAME`, sql.Named("q", q+"%"), sql.Named("d", digits))
if err != nil {
return nil, "поиск в DEPO: " + err.Error()
}
type faceRow struct {
id int64
f manualDepoFace
}
var found []faceRow
for rows.Next() {
var r faceRow
var birth, docDat sql.NullTime
var docType int64
var place, code string
if rows.Scan(&r.id, &r.f.Name, &birth, &docType, &r.f.DocSer, &r.f.DocNum, &docDat, &place, &code, &r.f.Phone) != nil {
continue
}
p := strings.Fields(r.f.Name)
if len(p) > 0 {
r.f.LastName = p[0]
}
if len(p) > 1 {
r.f.FirstName = p[1]
}
if len(p) > 2 {
r.f.MiddleName = strings.Join(p[2:], " ")
}
if birth.Valid {
r.f.Birth = birth.Time.Format("2006-01-02")
}
if docDat.Valid {
r.f.DocDate = docDat.Time.Format("2006-01-02")
}
r.f.DocType = depoDocTypeNSD(docType)
r.f.DocSer = strings.ReplaceAll(r.f.DocSer, " ", "")
r.f.DocIssuer = strings.TrimSpace(place)
if code = strings.TrimSpace(code); code != "" {
r.f.DocIssuer += ", код подразделения " + code
}
found = append(found, r)
}
rows.Close()
out := make([]manualDepoFace, 0, len(found))
for _, r := range found {
ar, err := db.QueryContext(ctx, `SELECT c.ID, ISNULL(c.NUMBER,''),
ISNULL((SELECT TOP 1 p.PORT_NUM FROM OD_CLIENTS_PORTS p WHERE p.CLIENT=c.ID ORDER BY p.PORT_DATE, p.PORT_NUM),'')
FROM OD_CLIENTS c WHERE c.FACE=@f AND c.NUMBER LIKE N'ВТНКЦ%' AND c.STATE=0 AND c.D_CLOSE IS NULL ORDER BY c.ID`, sql.Named("f", r.id))
if err == nil {
for ar.Next() {
var a manualDepoAcc
if ar.Scan(&a.ClientID, &a.Number, &a.Contract) == nil {
r.f.Accounts = append(r.f.Accounts, a)
}
}
ar.Close()
}
for i := range r.f.Accounts {
ok, _ := depoAccountIIS(ctx, db, fs, r.f.Accounts[i].ClientID)
r.f.Accounts[i].IIS = !ok
}
out = append(out, r.f)
}
if len(out) == 0 {
return nil, "клиентов с открытым торговым счётом не найдено"
}
return out, ""
}
// manualDepoPositions — бумаги с положительным остатком на торговом разделе счёта (98070.04).
func manualDepoPositions(rc *RuntimeConfig, clientID int64) ([]manualDepoPos, string) {
if !manualDepoConfigured(rc) {
return nil, "учётная система (DEPO) не подключена"
}
db, ctx, cancel, errS := depoOpen(rc.Snapshot().Fancy)
if errS != "" {
return nil, errS
}
defer cancel()
defer db.Close()
rows, err := db.QueryContext(ctx, `SELECT ISNULL(v.ISIN,''), ISNULL(v.NAME,''), SUM(CAST(r.K AS float))
FROM OD_RESTS r JOIN OD_VALUES v ON v.ID=r.VALUE_ID
WHERE r.REG_1=@c AND r.BAL_ACC=@b
GROUP BY v.ISIN, v.NAME HAVING SUM(CAST(r.K AS float)) > 0 ORDER BY v.NAME`,
sql.Named("c", clientID), sql.Named("b", depoTradeBalAcc))
if err != nil {
return nil, "остатки в DEPO: " + err.Error()
}
defer rows.Close()
var out []manualDepoPos
for rows.Next() {
var p manualDepoPos
if rows.Scan(&p.ISIN, &p.Name, &p.Qty) == nil {
p.ISIN = strings.ToUpper(strings.TrimSpace(p.ISIN))
p.Russian = isRussianISIN(p.ISIN)
out = append(out, p)
}
}
return out, ""
}
// registerManualDepo — GET /admin/manual/depo/search?q= и /admin/manual/depo/positions?client=ID (JSON).
func registerManualDepo(mux *http.ServeMux, rc *RuntimeConfig) {
reply := func(w http.ResponseWriter, v any, errS string) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]any{"items": v, "error": errS})
}
mux.HandleFunc("/admin/manual/depo/search", func(w http.ResponseWriter, r *http.Request) {
items, errS := manualDepoSearch(rc, r.URL.Query().Get("q"))
reply(w, items, errS)
})
mux.HandleFunc("/admin/manual/depo/positions", func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.URL.Query().Get("client"), 10, 64)
if err != nil || id <= 0 {
reply(w, nil, "не указан счёт")
return
}
items, errS := manualDepoPositions(rc, id)
reply(w, items, errS)
})
}
+453
View File
@@ -0,0 +1,453 @@
package lkgateway
// manual_handlers.go — страницы раздела «Ручной ввод M2M» (/admin/manual).
import (
"context"
"fmt"
"html/template"
"log"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
type manualPageData struct {
page
Mode string // list | edit | card | print
Flash string
List []manualListRow
O *ManualOrder
Handbook []HandbookEntry
DocTypes []struct{ Code, Title string }
PreChecks []manualCheckDef
PostChecks []manualPostDef
Problems []string // незаполненные реквизиты (для формы)
Blockers []string // почему нельзя отправить
DepoOn bool // учётная система подключена — сверка обязательна
DepoStale bool // данные изменены после сверки
QuikAuto bool // bj может сам заблокировать бумаги в торговой системе
Progress manualProgress
NSD NSDSettings
Auto bool // автоматический режим M2M включён (поручение 16 подаётся само)
}
// manualListRow — строка списка.
type manualListRow struct {
O *ManualOrder
State string // draft | sent | declined | done | rejected
StateRu string
}
func manualActor(w http.ResponseWriter) string {
if u := currentUser(w); u != nil {
if u.Name != "" {
return u.Name + " (" + u.Login + ")"
}
return u.Login
}
return "без входа"
}
func registerManual(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) {
tpl, err := template.New("layout.html").Funcs(templateFuncs).ParseFS(templatesFS,
"web/templates/layout.html", "web/templates/admin_manual.html")
if err != nil {
log.Fatalf("lk-gateway: шаблон admin_manual: %v", err)
}
help, err := template.New("layout.html").Funcs(templateFuncs).ParseFS(templatesFS,
"web/templates/layout.html", "web/templates/admin_help_manual.html")
if err != nil {
log.Fatalf("lk-gateway: шаблон admin_help_manual: %v", err)
}
mux.HandleFunc("/admin/help/manual", func(w http.ResponseWriter, r *http.Request) {
render(w, help, nowPage("Ручной ввод M2M: инструкция", "help"))
})
base := func(title string) manualPageData {
return manualPageData{
page: nowPage(title, "manual"), DocTypes: manualDocTypes, PreChecks: manualPreChecks, PostChecks: manualPostChecks,
DepoOn: manualDepoConfigured(rc), QuikAuto: manualQuikAutoAvailable(rc), NSD: rc.Snapshot().NSD,
}
}
back := func(w http.ResponseWriter, r *http.Request, to, msg, anchor string) {
if msg != "" {
to += "?flash=" + url.QueryEscape(msg)
}
http.Redirect(w, r, to+anchor, http.StatusSeeOther)
}
mux.HandleFunc("/admin/manual", func(w http.ResponseWriter, r *http.Request) {
d := base("Ручной ввод M2M")
d.Mode, d.Flash = "list", r.URL.Query().Get("flash")
var drafts map[string]*OutgoingDeal
for _, o := range globalManual.List() {
row := manualListRow{O: o, State: o.Status, StateRu: manualStatusRu(o.Status)}
if o.Status == "sent" {
if drafts == nil { // INBOX читаем один раз на список
drafts = map[string]*OutgoingDeal{}
for _, dd := range scanForm16Drafts(globalOutgoingStore) {
drafts[dd.GUID] = dd
}
}
row.StateRu = "в работе"
if dd := drafts[o.GUID]; dd != nil && dd.Done {
row.State, row.StateRu = "done", "исполнено"
} else if o.DealID != "" && svc != nil {
if v, err := svc.GetClaim(r.Context(), o.DealID); err == nil && (v.Status == "rejected" || v.Status == "timed_out") {
row.State, row.StateRu = "rejected", "отказ"
}
}
}
d.List = append(d.List, row)
}
render(w, tpl, d)
})
registerManualDepo(mux, rc)
mux.HandleFunc("/admin/manual/new", func(w http.ResponseWriter, r *http.Request) {
d := base("Новое поручение")
d.Mode = "edit"
d.O = &ManualOrder{Status: "draft", DocType: "21", SettlePlace: "nrd", PaperDate: time.Now().Format("2006-01-02"),
ReceivedVia: "лично в офисе", Securities: []ManualSec{{}}}
d.Handbook = handbookParticipants()
render(w, tpl, d)
})
// Сохранение формы (новое или черновик). do: save | lookup (заполнить из DEPO) | depo (сверка).
mux.HandleFunc("/admin/manual/save", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
_ = r.ParseForm()
in := manualFromForm(r)
id, _ := strconv.Atoi(r.FormValue("id"))
actor := manualActor(w)
msg := "Поручение сохранено"
if id == 0 {
in.Status, in.CreatedBy = "draft", actor
if err := globalManual.Create(in); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
id = in.ID
} else {
reset := false
err := globalManual.Update(id, func(o *ManualOrder) error {
if o.Status != "draft" {
return fmt.Errorf("поручение уже %s — изменить нельзя", manualStatusRu(o.Status))
}
old := o.dataHash()
manualCopyData(o, in)
if o.dataHash() != old && (o.Depo != nil || len(o.Checks) > 0) {
o.Depo, o.Checks, reset = nil, nil, true
}
return nil
})
if err != nil {
back(w, r, fmt.Sprintf("/admin/manual/%d", id), err.Error(), "")
return
}
if reset {
msg = "Данные изменены — сверка и отметки сброшены, пройдите их заново"
}
}
switch r.FormValue("do") {
case "lookup":
msg = manualDoLookup(rc, id)
case "depo":
msg = manualDoDepo(rc, id, actor)
}
anchor := ""
if r.FormValue("do") == "depo" {
anchor = "#depo"
}
back(w, r, fmt.Sprintf("/admin/manual/%d", id), msg, anchor)
})
mux.HandleFunc("/admin/manual/", func(w http.ResponseWriter, r *http.Request) {
rest := strings.TrimPrefix(r.URL.Path, "/admin/manual/")
parts := strings.SplitN(rest, "/", 2)
id, err := strconv.Atoi(parts[0])
if err != nil {
http.NotFound(w, r)
return
}
action := ""
if len(parts) == 2 {
action = parts[1]
}
o, ok := globalManual.Get(id)
if !ok {
http.NotFound(w, r)
return
}
card := fmt.Sprintf("/admin/manual/%d", id)
if action != "" && action != "print" && r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
actor := manualActor(w)
switch action {
case "", "print":
d := base("Поручение " + o.Number())
d.O, d.Flash = o, r.URL.Query().Get("flash")
d.Mode = "card"
if action == "print" {
d.Mode = "print"
}
if o.Status == "draft" {
d.Handbook = handbookParticipants()
d.Problems = manualDataProblems(o)
d.Blockers = manualSendBlockers(rc, o)
d.DepoStale = o.Depo != nil && o.Depo.Hash != o.dataHash()
if len(o.Securities) == 0 { // форма показывает строку бумаги всегда
o.Securities = []ManualSec{{}}
}
}
d.Progress = manualProgressFor(r.Context(), svc, o)
d.Auto = rc.Snapshot().M2MAuto.Enabled
render(w, tpl, d)
case "checks":
_ = r.ParseForm()
err := globalManual.Update(id, func(o *ManualOrder) error {
if o.Status != "draft" {
return fmt.Errorf("поручение уже %s", manualStatusRu(o.Status))
}
if o.Checks == nil {
o.Checks = map[string]ManualMark{}
}
for _, c := range manualPreChecks {
manualMark(o.Checks, c.Key, r.FormValue("check_"+c.Key) != "", actor)
}
switch m := r.FormValue("quik_mode"); m {
case "auto", "manual", "none":
o.QuikMode = m
}
o.QuikInstr = strings.TrimSpace(r.FormValue("quik_instr"))
return nil
})
msg := ""
if err != nil {
msg = err.Error()
}
back(w, r, card, msg, "#checks")
case "send":
var blockers []string
err := globalManual.Update(id, func(o *ManualOrder) error {
if blockers = manualSendBlockers(rc, o); len(blockers) > 0 {
return fmt.Errorf("отправить нельзя: %s", strings.Join(blockers, "; "))
}
o.Status, o.SendErr = "sending", ""
return nil
})
if err != nil {
back(w, r, card, err.Error(), "")
return
}
o, _ = globalManual.Get(id)
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
guid, dealID, serr := manualSend(ctx, rc, svc, o)
cancel()
msg := ""
_ = globalManual.Update(id, func(o *ManualOrder) error {
if serr != nil {
o.Status, o.SendErr = "draft", serr.Error()
msg = "Поручение не отправлено: " + serr.Error()
return nil
}
o.Status, o.GUID, o.DealID, o.SentAt, o.SentBy = "sent", guid, dealID, time.Now(), actor
msg = "Поручение отправлено в НРД. Ход исполнения — ниже на этой странице."
return nil
})
log.Printf("lk-gateway: ручной ввод %s (%s): отправка — %s", o.Number(), o.FullName(), msg)
back(w, r, card, msg, "")
case "decline":
reason := strings.TrimSpace(r.FormValue("reason"))
err := globalManual.Update(id, func(o *ManualOrder) error {
if o.Status != "draft" {
return fmt.Errorf("поручение уже %s", manualStatusRu(o.Status))
}
if reason == "" {
return fmt.Errorf("укажите причину отказа")
}
o.Status, o.DeclineReason, o.DeclinedBy, o.DeclinedAt = "declined", reason, actor, time.Now()
return nil
})
msg := "Поручение отклонено депозитарием"
if err != nil {
msg = err.Error()
}
back(w, r, card, msg, "")
case "post":
_ = r.ParseForm()
err := globalManual.Update(id, func(o *ManualOrder) error {
if o.Post == nil {
o.Post = map[string]ManualMark{}
}
for _, c := range manualPostChecks {
if _, shown := r.Form["shown_"+c.Key]; shown {
manualMark(o.Post, c.Key, r.FormValue("post_"+c.Key) != "", actor)
}
}
return nil
})
msg := ""
if err != nil {
msg = err.Error()
}
back(w, r, card, msg, "#post")
default:
http.NotFound(w, r)
}
})
}
// manualMark — поставить или снять отметку (время и автор — при постановке).
func manualMark(m map[string]ManualMark, key string, on bool, actor string) {
cur := m[key]
switch {
case on && !cur.Done:
m[key] = ManualMark{Done: true, By: actor, At: time.Now()}
case !on && cur.Done:
delete(m, key)
}
}
// manualFromForm — данные поручения из формы.
func manualFromForm(r *http.Request) *ManualOrder {
f := func(k string) string { return strings.TrimSpace(r.FormValue(k)) }
o := &ManualOrder{
PaperNo: f("paper_no"), PaperDate: f("paper_date"), ReceivedVia: f("received_via"),
LastName: f("last_name"), FirstName: f("first_name"), MiddleName: f("middle_name"), BirthDate: f("birth_date"),
DocType: f("doc_type"), DocSeries: strings.ReplaceAll(f("doc_series"), " ", ""), DocNumber: strings.ReplaceAll(f("doc_number"), " ", ""),
DocIssueDate: f("doc_issue_date"), DocIssuer: f("doc_issuer"),
Contract: f("contract"), Account: strings.ToUpper(f("account")), QuikCode: f("quik_code"), Phone: f("phone"),
RecvINN: f("recv_inn"), RecvName: f("recv_name"), RecvAccount: f("recv_account"), SettlePlace: f("settle_place"),
CostInfo: r.FormValue("cost_info") != "", Comment: f("comment"),
}
o.SettlePlace = "nrd" // СПБ Банк пока не используется
isins, codes, names, qtys := r.Form["sec_isin"], r.Form["sec_code"], r.Form["sec_name"], r.Form["sec_qty"]
at := func(a []string, i int) string {
if i < len(a) {
return strings.TrimSpace(a[i])
}
return ""
}
for i := range isins {
s := ManualSec{ISIN: strings.ToUpper(at(isins, i)), Code: at(codes, i), Name: at(names, i), Qty: at(qtys, i)}
if s.ISIN == "" && s.Qty == "" && s.Code == "" {
continue
}
o.Securities = append(o.Securities, s)
}
if o.RecvINN != "" {
o.RecvCode = handbookCodeByINN(o.RecvINN)
if o.RecvName == "" {
for _, p := range handbookParticipants() {
if p.INN == o.RecvINN && (o.RecvCode == "" || p.Code == o.RecvCode) {
o.RecvName = p.Name
break
}
}
}
}
return o
}
// manualCopyData — переносит данные формы в сохранённое поручение (служебные поля не трогает).
func manualCopyData(dst, src *ManualOrder) {
keep := *dst
*dst = *src
dst.ID, dst.CreatedAt, dst.CreatedBy, dst.UpdatedAt = keep.ID, keep.CreatedAt, keep.CreatedBy, keep.UpdatedAt
dst.Depo, dst.Checks, dst.QuikMode, dst.QuikInstr = keep.Depo, keep.Checks, keep.QuikMode, keep.QuikInstr
dst.Status, dst.GUID, dst.DealID, dst.SentAt, dst.SentBy, dst.SendErr = keep.Status, keep.GUID, keep.DealID, keep.SentAt, keep.SentBy, keep.SendErr
dst.DeclineReason, dst.DeclinedBy, dst.DeclinedAt, dst.Post = keep.DeclineReason, keep.DeclinedBy, keep.DeclinedAt, keep.Post
}
// manualDoLookup — заполнить из DEPO пустые ФИО, дату рождения, счёт и наименования бумаг.
func manualDoLookup(rc *RuntimeConfig, id int) string {
o, ok := globalManual.Get(id)
if !ok {
return "поручение не найдено"
}
if strings.TrimSpace(o.DocNumber) == "" {
return "Для поиска в DEPO укажите серию и номер документа"
}
c := manualDepoLookup(rc, o.DocSeries, o.DocNumber)
if !c.Found {
return "Поиск в DEPO: " + c.Err
}
var isins []string
for _, s := range o.Securities {
if s.Name == "" && s.ISIN != "" {
isins = append(isins, s.ISIN)
}
}
names := manualDepoSecNames(rc, isins)
var filled []string
_ = globalManual.Update(id, func(o *ManualOrder) error {
old := o.dataHash()
if o.LastName == "" && o.FirstName == "" {
if p := strings.Fields(c.Name); len(p) >= 2 {
o.LastName, o.FirstName = p[0], p[1]
if len(p) > 2 {
o.MiddleName = strings.Join(p[2:], " ")
}
filled = append(filled, "ФИО")
}
}
if o.BirthDate == "" && c.Birth != "" {
o.BirthDate = c.Birth
filled = append(filled, "дата рождения")
}
if o.Account == "" && c.Account != "" {
o.Account = c.Account
filled = append(filled, "счёт "+c.Account)
}
for i := range o.Securities {
if n := names[o.Securities[i].ISIN]; n != "" && o.Securities[i].Name == "" {
o.Securities[i].Name = n
filled = append(filled, "наименование "+o.Securities[i].ISIN)
}
}
if o.dataHash() != old {
o.Depo, o.Checks = nil, nil
}
return nil
})
msg := "Клиент в DEPO: " + c.Name
if c.Account == "" {
msg += " (открытого торгового счёта нет)"
}
if len(filled) > 0 {
msg += ". Заполнено: " + strings.Join(filled, ", ") + " — проверьте с поручением клиента"
} else {
msg += ". Пустых полей для заполнения нет"
}
return msg
}
// manualDoDepo — сверка с DEPO.
func manualDoDepo(rc *RuntimeConfig, id int, actor string) string {
o, ok := globalManual.Get(id)
if !ok {
return "поручение не найдено"
}
if p := manualDataProblems(o); len(p) > 0 {
return "Сверка не выполнена — сначала заполните реквизиты: " + strings.Join(p, "; ")
}
chk := manualDepoRun(rc, o)
chk.By = actor
_ = globalManual.Update(id, func(o *ManualOrder) error {
o.Depo = chk
return nil
})
if !chk.Ran {
return "Сверка с DEPO не выполнена: " + chk.Err
}
return "Сверка с DEPO: " + chk.Summary
}
+160
View File
@@ -0,0 +1,160 @@
package lkgateway
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
func manualTestOrder() *ManualOrder {
return &ManualOrder{
Status: "draft", PaperNo: "15", PaperDate: "2026-09-17", ReceivedVia: "лично в офисе",
LastName: "Иванов", FirstName: "Иван", MiddleName: "Иванович", BirthDate: "1980-01-02",
DocType: "21", DocSeries: "0000", DocNumber: "000000", Account: "СЧЁТ-1",
RecvINN: "7702165310", RecvName: "Депозитарий", SettlePlace: "nrd",
Securities: []ManualSec{{ISIN: "RU0009029540", Qty: "10"}},
}
}
func TestManualSendBlockers(t *testing.T) {
rc, err := NewRuntimeConfig(t.TempDir() + "/setup.json")
if err != nil {
t.Fatal(err)
}
o := manualTestOrder()
b := manualSendBlockers(rc, o)
if len(b) != len(manualPreChecks)+1 { // все отметки + способ блокировки в QUIK
t.Fatalf("без отметок: %v", b)
}
o.Checks = map[string]ManualMark{}
for _, c := range manualPreChecks {
o.Checks[c.Key] = ManualMark{Done: true}
}
o.QuikMode = "manual"
if b = manualSendBlockers(rc, o); len(b) != 1 || !strings.Contains(b[0], "номер поручения") {
t.Fatalf("ручная блокировка без номера: %v", b)
}
o.QuikInstr = "123"
if b = manualSendBlockers(rc, o); len(b) != 0 {
t.Fatalf("всё заполнено: %v", b)
}
o.QuikMode = "auto" // цикл QUIK не включён
if b = manualSendBlockers(rc, o); len(b) != 1 {
t.Fatalf("автоблокировка недоступна: %v", b)
}
o.QuikMode = "none"
o.Securities = []ManualSec{{ISIN: "US0378331005", Qty: "0"}}
if b = manualSendBlockers(rc, o); len(b) != 2 {
t.Fatalf("иностранная бумага и нулевое количество: %v", b)
}
o.Securities = []ManualSec{{ISIN: "RU0009029540", Qty: "1"}, {ISIN: "RU000A0JUR61", Qty: "1"}}
if b = manualSendBlockers(rc, o); len(b) != 1 || !strings.Contains(b[0], "одна бумага") {
t.Fatalf("одно поручение — одна бумага: %v", b)
}
}
func TestManualQtyAndClaim(t *testing.T) {
if q, ok := manualQty("1,5"); !ok || q.Fractional != "1.5" {
t.Fatalf("дробное: %+v", q)
}
if q, ok := manualQty("7"); !ok || q.Whole != 7 {
t.Fatalf("целое: %+v", q)
}
if _, ok := manualQty("-1"); ok {
t.Fatal("отрицательное")
}
rc, _ := NewRuntimeConfig(t.TempDir() + "/setup.json")
c, err := manualClaim(rc, manualTestOrder())
if err != nil || c.ClientAccount != "СЧЁТ-1" || c.Investor.BirthDate != "1980-01-02" || c.Securities[0].SecurityCode != "RU0009029540" || c.CostInfo.No == nil {
t.Fatalf("заявка: %+v %v", c, err)
}
}
// Страницы раздела: список, форма, карточка черновика, отметки, отказ, печать.
func TestManualPages(t *testing.T) {
saved := globalManual
globalManual = loadManualStore(t.TempDir() + "/manual.json")
defer func() { globalManual = saved }()
rc, err := NewRuntimeConfig(t.TempDir() + "/setup.json")
if err != nil {
t.Fatal(err)
}
mux := http.NewServeMux()
registerManual(mux, rc, nil)
do := func(method, path string, form url.Values) *httptest.ResponseRecorder {
var req *http.Request
if form != nil {
req = httptest.NewRequest(method, path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
} else {
req = httptest.NewRequest(method, path, nil)
}
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
return rec
}
if rec := do("GET", "/admin/help/manual", nil); rec.Code != 200 || !strings.Contains(rec.Body.String(), "Порядок работы") {
t.Fatalf("инструкция: %d", rec.Code)
}
if rec := do("GET", "/admin/manual/new", nil); rec.Code != 200 || !strings.Contains(rec.Body.String(), "Новое поручение") {
t.Fatalf("форма: %d %s", rec.Code, rec.Body.String())
}
form := url.Values{"paper_no": {"15"}, "paper_date": {"2026-09-17"}, "last_name": {"Иванов"}, "first_name": {"Иван"},
"birth_date": {"1980-01-02"}, "doc_type": {"21"}, "doc_series": {"00 00"}, "doc_number": {"000000"}, "account": {"счёт-1"},
"recv_inn": {"7702165310"}, "sec_isin": {"ru0009029540", ""}, "sec_qty": {"10", ""}, "sec_code": {"", ""}, "sec_name": {"", ""}, "do": {"save"}}
rec := do("POST", "/admin/manual/save", form)
if rec.Code != http.StatusSeeOther || !strings.HasPrefix(rec.Header().Get("Location"), "/admin/manual/1") {
t.Fatalf("сохранение: %d %s", rec.Code, rec.Header().Get("Location"))
}
o, _ := globalManual.Get(1)
if o.DocSeries != "0000" || o.Account != "СЧЁТ-1" || len(o.Securities) != 1 || o.Securities[0].ISIN != "RU0009029540" {
t.Fatalf("данные: %+v", o)
}
if rec = do("GET", "/admin/manual/1", nil); rec.Code != 200 || !strings.Contains(rec.Body.String(), "Отправить пока нельзя") {
t.Fatalf("карточка: %d %s", rec.Code, rec.Body.String())
}
chk := url.Values{"quik_mode": {"none"}}
for _, c := range manualPreChecks {
chk.Set("check_"+c.Key, "1")
}
do("POST", "/admin/manual/1/checks", chk)
o, _ = globalManual.Get(1)
if len(manualSendBlockers(rc, o)) != 0 || o.Checks["paper"].At.IsZero() {
t.Fatalf("отметки: %+v %v", o.Checks, manualSendBlockers(rc, o))
}
if rec = do("GET", "/admin/manual/1", nil); !strings.Contains(rec.Body.String(), "Все проверки и отметки пройдены") {
t.Fatalf("готово к отправке: %s", rec.Body.String())
}
// Изменение данных сбрасывает отметки.
form.Set("id", "1")
form.Set("sec_qty", "11")
do("POST", "/admin/manual/save", form)
if o, _ = globalManual.Get(1); len(o.Checks) != 0 {
t.Fatalf("отметки должны сброситься: %+v", o.Checks)
}
do("POST", "/admin/manual/1/decline", url.Values{"reason": {"нет подписи клиента"}})
o, _ = globalManual.Get(1)
if o.Status != "declined" || o.DeclinedAt.Before(time.Now().Add(-time.Minute)) {
t.Fatalf("отказ: %+v", o)
}
for _, p := range []string{"/admin/manual", "/admin/manual/1", "/admin/manual/1/print"} {
if rec = do("GET", p, nil); rec.Code != 200 || !strings.Contains(rec.Body.String(), "нет подписи клиента") && p != "/admin/manual" {
t.Fatalf("%s: %d %s", p, rec.Code, rec.Body.String())
}
}
}
func TestOrgIdentity(t *testing.T) {
var s Settings
s.NSD.DeponentCode = "MC0000000001"
if orgCode(s) != "MC0000000001" {
t.Fatalf("код по депозитарному коду: %q", orgCode(s))
}
s.NSD.OrgCode, s.NSD.OrgINN, s.NSD.OrgName = "mc0000000002", "7700000000", "Депозитарий"
if orgCode(s) != "MC0000000002" || orgINN(s) != "7700000000" || transferringINN(s) != "7700000000" || orgName(s) != "Депозитарий" {
t.Fatalf("организация из настроек: %q %q %q", orgCode(s), orgINN(s), orgName(s))
}
}
+58
View File
@@ -0,0 +1,58 @@
package lkgateway
// Службы модулей (bj-npr, bj-quikexport, …) — отдельные программы на локальных портах, которые
// обновляются независимо от ядра. bj-server проверяет вход, права и лицензию, передаёт службе
// служебный ключ (X-BJ-Module-Key) и встраивает её страницы в свой макет. Сами службы — modules/.
import (
"crypto/rand"
"encoding/hex"
"html/template"
"net/http"
"os"
"strings"
"sync"
)
const moduleKeyFile = "/var/lib/bj/.bj/module.key"
var moduleKeyMu sync.Mutex
// moduleKey — служебный ключ для служб модулей (создаётся один раз, читается службами от того же пользователя).
func moduleKey() (string, error) {
moduleKeyMu.Lock()
defer moduleKeyMu.Unlock()
if b, err := os.ReadFile(moduleKeyFile); err == nil && len(strings.TrimSpace(string(b))) >= 32 {
return strings.TrimSpace(string(b)), nil
}
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
key := hex.EncodeToString(buf)
if err := os.WriteFile(moduleKeyFile, []byte(key+"\n"), 0o600); err != nil {
return "", err
}
return key, nil
}
// noRedirect — ответы служб (в т. ч. редиректы) передаются браузеру как есть.
func noRedirect(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
// modulePageData — страница модуля в макете bj.
type modulePageData struct {
page
Content template.HTML
}
func moduleMessage(title, text, hint string) template.HTML {
return template.HTML(`<div class="card"><h2>` + template.HTMLEscapeString(title) + `</h2><p>` + template.HTMLEscapeString(text) + `</p>` +
`<p class="muted">` + template.HTMLEscapeString(hint) + `</p></div>`)
}
func pickStr(v, def string) string {
if strings.TrimSpace(v) == "" {
return def
}
return v
}
+25
View File
@@ -402,10 +402,35 @@ func (h *setupHandlers) checkDocsNow(w http.ResponseWriter, r *http.Request) {
setupFlash(w, r, "Проверка обновлений документации завершена. "+strings.TrimSpace(summary))
}
// devNewsIDs — стартовые новости для разработчиков (TEST3, робот НРД): у клиента не показываем.
var devNewsIDs = map[string]bool{
"test3-maintenance-2026-05": true,
"robot-autotest-2026-05-12": true,
}
// visibleNews — новости, которые видны на этой установке: вне режима разработки
// без служебных новостей про тестирование.
func visibleNews(rc *RuntimeConfig, items []NewsItem) []NewsItem {
if devMode(rc) {
return items
}
out := make([]NewsItem, 0, len(items))
for _, it := range items {
if !devNewsIDs[it.ID] {
out = append(out, it)
}
}
return out
}
// SeedDefaultNews добавляет в ленту известные на момент запуска события
// (окно техработ TEST3 в мае 2026 и появление робота-автотестирования).
// Вызывается из server.go при старте — дедуп по ID гарантирован AddNews.
// Только в режиме разработки: клиентам эти новости не нужны.
func SeedDefaultNews(rc *RuntimeConfig) {
if !devMode(rc) {
return
}
defaults := []NewsItem{
{
ID: "test3-maintenance-2026-05",
+94 -440
View File
@@ -1,32 +1,26 @@
package lkgateway
// Модуль «НПР / УДС» — риск-монитор клиентов маржинальной торговли поверх базы
// экспорта QUIK (quik_export, схема npr). Перенесён «как есть» с 10.10.10.23
// (FastAPI ui/app.py) 14.09.2026; читает базу логином только для чтения.
// Включается настройкой NPR.Enabled и лицензионным признаком "npr".
// Модуль «НПР / УДС» — риск-монитор клиентов маржинальной торговли по базе экспорта QUIK.
// Сам модуль работает отдельной службой bj-npr (modules/npr, обновляется независимо). Здесь —
// его настройки и проверка подключения, а раздел /admin/npr bj-server проксирует службе: проверяет
// включение и лицензию (права — authMiddleware), передаёт служебный ключ и встраивает полные
// страницы модуля в свой макет. Если служба не отвечает, показывает понятную страницу.
import (
"context"
"embed"
"encoding/csv"
"encoding/json"
"fmt"
"io/fs"
"log"
"math"
"html/template"
"io"
"net/http"
"strconv"
"net/url"
"os"
"strings"
"sync"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
//go:embed web/static/npr/*
var nprStaticFS embed.FS
// NPRSettings — модуль НПР/УДС.
type NPRSettings struct {
Enabled bool `json:"enabled"`
@@ -50,6 +44,84 @@ func nprAvailable(rc *RuntimeConfig) bool {
return s.Enabled && strings.TrimSpace(s.DSN) != "" && moduleAllowed(rc, "npr")
}
// ---- служба модуля ----
var nprHTTP = &http.Client{Timeout: 60 * time.Second, CheckRedirect: noRedirect}
func nprURL() string {
if u := strings.TrimSpace(os.Getenv("BJ_NPR_URL")); u != "" {
return strings.TrimRight(u, "/")
}
return "http://127.0.0.1:8083"
}
// registerNPR — прокси раздела /admin/npr к службе bj-npr.
func registerNPR(mux *http.ServeMux, a *admin, rc *RuntimeConfig) {
unavailable := func(w http.ResponseWriter, r *http.Request, status int, title, text, hint string) {
if isAjax(r) || !strings.HasPrefix(r.Header.Get("Accept"), "text/html") && r.URL.Path != "/admin/npr" && !strings.HasPrefix(r.URL.Path, "/admin/npr/c/") {
http.Error(w, text, status)
return
}
w.WriteHeader(status)
render(w, a.modulePage, modulePageData{page: nowPage("НПР / УДС", "npr"), Content: moduleMessage(title, text, hint)})
}
proxy := func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/admin/npr/static/") && !nprAvailable(rc) {
unavailable(w, r, http.StatusForbidden, "Модуль «НПР / УДС» недоступен",
"Модуль не включён в настройках или не входит в лицензию.", "Включение и подключение к базе: Настройка → НПР / УДС. Состав лицензии: Настройка → Лицензия.")
return
}
key, err := moduleKey()
if err != nil {
http.Error(w, "служебный ключ модулей: "+err.Error(), http.StatusInternalServerError)
return
}
req, err := http.NewRequestWithContext(r.Context(), r.Method, nprURL()+r.URL.RequestURI(), r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
for _, h := range []string{"Accept", "Content-Type", "HX-Request", "HX-Target", "HX-Trigger", "HX-Current-URL", "If-Modified-Since", "If-None-Match", "Range"} {
if v := r.Header.Get(h); v != "" {
req.Header.Set(h, v)
}
}
req.Header.Set("X-BJ-Module-Key", key)
if u := currentUser(w); u != nil {
req.Header.Set("X-BJ-User", url.QueryEscape(u.Login))
}
resp, err := nprHTTP.Do(req)
if err != nil {
unavailable(w, r, http.StatusServiceUnavailable, "Служба модуля не отвечает",
"Служба bj-npr не запущена или ещё не установлена на этом сервере.",
"Установка и обновление модуля: Настройка → Обновления. Состояние службы: systemctl status bj-npr.")
return
}
defer resp.Body.Close()
if resp.Header.Get("X-BJ-Layout") == "page" {
body, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
title, _ := url.PathUnescape(resp.Header.Get("X-BJ-Title"))
render(w, a.modulePage, modulePageData{page: nowPage(pickStr(title, "НПР / УДС"), "npr"), Content: template.HTML(body)})
return
}
for _, h := range []string{"Content-Type", "Content-Disposition", "Content-Length", "Cache-Control", "Last-Modified", "ETag", "Accept-Ranges", "Content-Range", "Location"} {
if v := resp.Header.Get(h); v != "" {
w.Header().Set(h, v)
}
}
w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body)
}
mux.HandleFunc("/admin/npr", proxy)
mux.HandleFunc("/admin/npr/", proxy)
}
// ---- настройки: проверка подключения ----
var (
nprPoolMu sync.Mutex
nprPool *pgxpool.Pool
@@ -74,7 +146,7 @@ func nprDB(rc *RuntimeConfig) (*pgxpool.Pool, error) {
if err != nil {
return nil, err
}
cfg.MaxConns = 5
cfg.MaxConns = 2
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
p, err := pgxpool.NewWithConfig(ctx, cfg)
@@ -85,429 +157,6 @@ func nprDB(rc *RuntimeConfig) (*pgxpool.Pool, error) {
return p, nil
}
var nprMSK = time.FixedZone("MSK", 3*3600)
var nprSort = map[string]string{
"uds_asc": "uds ASC NULLS LAST, client_code ASC",
"uds_desc": "uds DESC NULLS LAST, client_code ASC",
"client_asc": "client_code ASC",
"client_desc": "client_code DESC",
"ts_desc": "ts DESC",
}
var nprTabs = map[string]string{
// Маржинальный риск: клиенты с реально открытыми позициями.
"margin": "COALESCE(init_margin,0) > 0 OR COALESCE(min_margin,0) > 0",
// Дебиторская задолженность: позиций нет, но портфель в минусе.
"debt": "COALESCE(init_margin,0) = 0 AND COALESCE(min_margin,0) = 0 AND COALESCE(portfolio_value,0) < 0",
}
type nprRow struct {
ClientCode, MarginType, UDSFlag string
UDS, RCV1, RCV2, MarginReq *float64
PortfolioValue *float64
InitMargin, MinMargin, CorrMargin *float64
Status, RiskLevel *int
TS time.Time
Zone string
}
func (r nprRow) Class() string {
switch {
case r.UDSFlag == "CALL" || (r.UDS != nil && *r.UDS < 1):
return "row-call"
case r.UDS != nil && *r.UDS < 1.5:
return "row-warn"
case r.UDSFlag == "INF":
return "row-inf"
}
return ""
}
func (r nprRow) Badge() (cls, text string) {
switch {
case r.UDSFlag == "CALL":
return "badge-call", "МАРЖИН-КОЛЛ"
case r.UDSFlag == "INF":
return "badge-inf", "∞ избыточно"
case r.UDS != nil && *r.UDS < 1:
return "badge-call", "маржин-колл"
case r.UDS != nil && *r.UDS < 1.5:
return "badge-warn", "внимание"
case r.UDS != nil:
return "badge-ok", "норма"
}
return "", "—"
}
func (r nprRow) BadgeClass() string { c, _ := r.Badge(); return c }
func (r nprRow) BadgeText() string { _, t := r.Badge(); return t }
func nprF(v *float64) string {
if v == nil || math.IsNaN(*v) {
return "—"
}
return strings.ReplaceAll(strconv.FormatFloat(*v, 'f', 2, 64), ".", ",")
}
func nprI(v *int) string {
if v == nil {
return ""
}
return strconv.Itoa(*v)
}
func nprParseF(s string) *float64 {
s = strings.TrimSpace(strings.ReplaceAll(s, ",", "."))
if s == "" {
return nil
}
f, err := strconv.ParseFloat(s, 64)
if err != nil {
return nil
}
return &f
}
func nprWhere(q string, mt []string, umn, umx *float64, onlyCalls bool, tab string) (string, []any) {
var cl []string
var params []any
cl = append(cl, "("+nprTabs[tab]+")")
if q != "" {
params = append(params, "%"+q+"%")
cl = append(cl, fmt.Sprintf("client_code ILIKE $%d", len(params)))
}
if len(mt) > 0 {
params = append(params, mt)
cl = append(cl, fmt.Sprintf("margin_type = ANY($%d)", len(params)))
}
if umn != nil {
params = append(params, *umn)
cl = append(cl, fmt.Sprintf("uds >= $%d", len(params)))
}
if umx != nil {
params = append(params, *umx)
cl = append(cl, fmt.Sprintf("uds <= $%d", len(params)))
}
if onlyCalls {
cl = append(cl, "uds IS NOT NULL AND uds < 1")
}
return strings.Join(cl, " AND "), params
}
func scanNPR(rows pgx.Rows) ([]nprRow, int, error) {
var out []nprRow
total := 0
for rows.Next() {
var r nprRow
var mt, fl *string
var zone *string
if err := rows.Scan(&r.ClientCode, &mt, &r.UDS, &fl, &r.RCV1, &r.RCV2, &r.MarginReq, &r.PortfolioValue, &r.InitMargin, &r.MinMargin, &r.CorrMargin, &r.Status, &r.RiskLevel, &r.TS, &zone, &total); err != nil {
return nil, 0, err
}
if mt != nil {
r.MarginType = *mt
}
if fl != nil {
r.UDSFlag = *fl
}
if zone != nil {
r.Zone = *zone
}
out = append(out, r)
}
return out, total, rows.Err()
}
const nprCols = `client_code, margin_type, uds::float8, uds_flag, rcv1::float8, rcv2::float8, margin_requirement::float8, portfolio_value::float8, init_margin::float8, min_margin::float8, corrected_margin::float8, status, risk_level, ts, zone`
func nprParsePeriod(hours int, from, to string) (time.Time, time.Time, bool) {
parse := func(s string) (time.Time, bool) {
for _, f := range []string{"2006-01-02T15:04", "2006-01-02T15:04:05", "2006-01-02 15:04", "2006-01-02"} {
if t, err := time.ParseInLocation(f, s, nprMSK); err == nil {
return t, true
}
}
return time.Time{}, false
}
now := time.Now()
tf, okf := parse(from)
tt, okt := parse(to)
if okf || okt {
if !okf {
tf = now.Add(-2 * 365 * 24 * time.Hour)
}
if !okt {
tt = now
}
return tf, tt, true
}
if hours <= 0 {
hours = 24
}
return now.Add(-time.Duration(hours) * time.Hour), now, false
}
// registerNPR — маршруты модуля.
func registerNPR(mux *http.ServeMux, a *admin, rc *RuntimeConfig) {
sub, _ := fs.Sub(nprStaticFS, "web/static/npr")
mux.Handle("/admin/npr/static/", http.StripPrefix("/admin/npr/static/", http.FileServer(http.FS(sub))))
guard := func(w http.ResponseWriter, r *http.Request) (*pgxpool.Pool, bool) {
if !nprAvailable(rc) {
http.Error(w, "модуль «НПР / УДС» не включён или не входит в лицензию", http.StatusForbidden)
return nil, false
}
p, err := nprDB(rc)
if err != nil {
http.Error(w, "модуль НПР: "+err.Error(), http.StatusServiceUnavailable)
return nil, false
}
return p, true
}
qp := func(r *http.Request) (q string, mt []string, umn, umx *float64, onlyCalls bool, sort, tab string, page int) {
v := r.URL.Query()
q = strings.TrimSpace(v.Get("q"))
for _, m := range v["mt"] {
if m = strings.TrimSpace(m); m != "" {
mt = append(mt, m)
}
}
umn, umx = nprParseF(v.Get("umn")), nprParseF(v.Get("umx"))
onlyCalls = v.Get("only_calls") == "true" || v.Get("only_calls") == "on"
sort = v.Get("sort")
if _, ok := nprSort[sort]; !ok {
sort = "uds_asc"
}
tab = v.Get("tab")
if _, ok := nprTabs[tab]; !ok {
tab = "margin"
}
page, _ = strconv.Atoi(v.Get("page"))
if page < 1 {
page = 1
}
return
}
mux.HandleFunc("/admin/npr", func(w http.ResponseWriter, r *http.Request) {
if _, ok := guard(w, r); !ok {
return
}
q, mt, _, _, onlyCalls, sort, tab, pg := qp(r)
data := struct {
page
Q, Sort, Tab string
MT []string
UMN, UMX string
OnlyCalls bool
Page int
}{nowPage("НПР / УДС", "npr"), q, sort, tab, mt, r.URL.Query().Get("umn"), r.URL.Query().Get("umx"), onlyCalls, pg}
render(w, a.npr, data)
})
mux.HandleFunc("/admin/npr/rows", func(w http.ResponseWriter, r *http.Request) {
p, ok := guard(w, r)
if !ok {
return
}
q, mt, umn, umx, onlyCalls, sort, tab, page := qp(r)
limit := 30
where, params := nprWhere(q, mt, umn, umx, onlyCalls, tab)
params = append(params, limit, (page-1)*limit)
sql := fmt.Sprintf(`WITH per_client AS (
SELECT DISTINCT ON (client_code) %s FROM npr.v_current
ORDER BY client_code, (COALESCE(abs(rcv1),0) + COALESCE(abs(rcv2),0)) DESC)
SELECT %s, count(*) OVER () AS total FROM per_client WHERE %s ORDER BY %s LIMIT $%d OFFSET $%d`,
nprCols, nprCols, where, nprSort[sort], len(params)-1, len(params))
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
rows, err := p.Query(ctx, sql, params...)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
list, total, err := scanNPR(rows)
rows.Close()
if err != nil {
http.Error(w, err.Error(), 500)
return
}
pages := (total + limit - 1) / limit
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_ = a.nprRows.ExecuteTemplate(w, "npr_rows", map[string]any{"Rows": list, "Total": total, "Page": page, "Pages": pages, "HasNext": total > page*limit})
})
mux.HandleFunc("/admin/npr/api/summary", func(w http.ResponseWriter, r *http.Request) {
p, ok := guard(w, r)
if !ok {
return
}
tab := r.URL.Query().Get("tab")
if _, ok := nprTabs[tab]; !ok {
tab = "margin"
}
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
var total, below1, below0, calls, inf, warn, okc, tabM, tabD int64
err := p.QueryRow(ctx, `WITH per_client AS (SELECT DISTINCT ON (client_code) client_code, uds, uds_flag, zone, init_margin, min_margin, portfolio_value FROM npr.v_current WHERE `+nprTabs[tab]+` ORDER BY client_code, (COALESCE(abs(rcv1),0) + COALESCE(abs(rcv2),0)) DESC)
SELECT count(*), count(*) FILTER (WHERE uds IS NOT NULL AND uds < 1), count(*) FILTER (WHERE uds IS NOT NULL AND uds < 0), count(*) FILTER (WHERE zone='CALL'), count(*) FILTER (WHERE zone='INF'), count(*) FILTER (WHERE zone='WARN'), count(*) FILTER (WHERE zone='OK') FROM per_client`).
Scan(&total, &below1, &below0, &calls, &inf, &warn, &okc)
if err == nil {
err = p.QueryRow(ctx, `SELECT count(DISTINCT client_code) FILTER (WHERE `+nprTabs["margin"]+`), count(DISTINCT client_code) FILTER (WHERE `+nprTabs["debt"]+`) FROM npr.v_current`).Scan(&tabM, &tabD)
}
if err != nil {
http.Error(w, err.Error(), 500)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"total": total, "below_one": below1, "below_zero": below0, "calls": calls, "inf": inf, "warn": warn, "ok": okc, "tab_margin": tabM, "tab_debt": tabD, "tab": tab})
})
mux.HandleFunc("/admin/npr/api/freshness", func(w http.ResponseWriter, r *http.Request) {
p, ok := guard(w, r)
if !ok {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
var lastTick *time.Time
var ticks int64
_ = p.QueryRow(ctx, "SELECT last_tick, tick_count FROM npr.heartbeat WHERE id=1").Scan(&lastTick, &ticks)
var lastTS *time.Time
var total, hour, day int64
_ = p.QueryRow(ctx, `SELECT max(ts), count(*), count(*) FILTER (WHERE ts >= NOW() - INTERVAL '1 hour'), count(*) FILTER (WHERE ts >= NOW() - INTERVAL '24 hours') FROM npr.history`).Scan(&lastTS, &total, &hour, &day)
out := map[string]any{"last_tick": nil, "tick_count": ticks, "last_event_ts": nil, "total_rows": total, "events_last_hour": hour, "events_last_24h": day, "server_now": time.Now().Format(time.RFC3339)}
if lastTick != nil {
out["last_tick"] = lastTick.Format(time.RFC3339)
}
if lastTS != nil {
out["last_event_ts"] = lastTS.Format(time.RFC3339)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(out)
})
mux.HandleFunc("/admin/npr/c/", func(w http.ResponseWriter, r *http.Request) {
p, ok := guard(w, r)
if !ok {
return
}
rest := strings.TrimPrefix(r.URL.Path, "/admin/npr/c/")
code := rest
sub := ""
if i := strings.Index(rest, "/"); i >= 0 {
code, sub = rest[:i], rest[i+1:]
}
if code == "" {
http.NotFound(w, r)
return
}
hours, _ := strconv.Atoi(r.URL.Query().Get("hours"))
tf, tt, custom := nprParsePeriod(hours, r.URL.Query().Get("dt_from"), r.URL.Query().Get("dt_to"))
if hours <= 0 {
hours = 24
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
switch sub {
case "series":
rows, err := p.Query(ctx, "SELECT ts, rcv1::float8, rcv2::float8, uds::float8 FROM npr.v_npr WHERE client_code=$1 AND ts BETWEEN $2 AND $3 ORDER BY ts", code, tf, tt)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
defer rows.Close()
labels, r1, r2, u := []string{}, []*float64{}, []*float64{}, []*float64{}
for rows.Next() {
var ts time.Time
var a, b, c *float64
if rows.Scan(&ts, &a, &b, &c) == nil {
labels = append(labels, ts.Format(time.RFC3339))
r1, r2, u = append(r1, a), append(r2, b), append(u, c)
}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"labels": labels, "rcv1": r1, "rcv2": r2, "uds": u})
return
case "export.csv":
rows, err := p.Query(ctx, `SELECT h.ts, h.firm_id, h.client_code, COALESCE(h.margin_type,''), h.rcv1::float8, h.rcv2::float8,
CASE WHEN h.funds_level >= 9.99 THEN NULL ELSE h.funds_level::float8 END, CASE WHEN h.funds_level >= 9.99 THEN 'INF' WHEN h.funds_level <= -9.99 THEN 'CALL' ELSE '' END,
h.portfolio_value::float8, h.init_margin::float8, h.min_margin::float8, h.corrected_margin::float8, GREATEST(-h.rcv1, 0)::float8, h.status, h.risk_level
FROM npr.history h WHERE h.client_code=$1 AND h.ts BETWEEN $2 AND $3 ORDER BY h.ts`, code, tf, tt)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
defer rows.Close()
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="npr_%s_%s-%s.csv"`, code, tf.Format("20060102"), tt.Format("20060102")))
w.Write([]byte{0xEF, 0xBB, 0xBF})
cw := csv.NewWriter(w)
cw.Comma = ';'
cw.Write([]string{"Время (UTC)", "Время (МСК)", "Фирма", "Код клиента", "Тип клиента", "НПР1", "НПР2", "УДС", "Флаг УДС", "Стоимость портфеля", "Начальная маржа", "Минимальная маржа", "Скорректированная маржа", "Маржинальное требование", "Статус", "Уровень риска"})
f := func(v *float64) string {
if v == nil {
return ""
}
return strings.ReplaceAll(strconv.FormatFloat(*v, 'f', 4, 64), ".", ",")
}
for rows.Next() {
var ts time.Time
var firm, cc, mt, flag string
var a, b, c, pv, im, mm, cm, mr *float64
var st, rl *int
if rows.Scan(&ts, &firm, &cc, &mt, &a, &b, &c, &flag, &pv, &im, &mm, &cm, &mr, &st, &rl) != nil {
continue
}
cw.Write([]string{ts.UTC().Format("2006-01-02 15:04:05"), ts.In(nprMSK).Format("2006-01-02 15:04:05"), firm, cc, mt, f(a), f(b), f(c), flag, f(pv), f(im), f(mm), f(cm), f(mr), nprI(st), nprI(rl)})
}
cw.Flush()
return
case "":
default:
http.NotFound(w, r)
return
}
rows, err := p.Query(ctx, `SELECT `+nprCols+`, 0 FROM npr.v_current WHERE client_code=$1 ORDER BY (COALESCE(abs(rcv1),0) + COALESCE(abs(rcv2),0)) DESC LIMIT 1`, code)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
cur, _, err := scanNPR(rows)
rows.Close()
if err != nil || len(cur) == 0 {
http.Error(w, "Клиент "+code+" не найден в текущем состоянии QUIK", http.StatusNotFound)
return
}
hrows, err := p.Query(ctx, `SELECT client_code, margin_type, uds::float8, uds_flag, rcv1::float8, rcv2::float8, margin_requirement::float8, portfolio_value::float8, init_margin::float8, min_margin::float8, NULL::float8, NULL::int, NULL::int, ts, NULL::text, 0 FROM npr.v_npr WHERE client_code=$1 AND ts BETWEEN $2 AND $3 ORDER BY ts DESC LIMIT 200`, code, tf, tt)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
hist, _, _ := scanNPR(hrows)
hrows.Close()
data := struct {
page
C nprRow
History []nprRow
Hours int
DtFrom, DtTo string
TF, TT time.Time
Custom bool
Periods []struct {
H int
T string
}
}{nowPage("Клиент "+code+" — НПР/УДС", "npr"), cur[0], hist, hours, r.URL.Query().Get("dt_from"), r.URL.Query().Get("dt_to"), tf, tt, custom,
[]struct {
H int
T string
}{{1, "за 1 час"}, {6, "за 6 часов"}, {24, "за сутки"}, {168, "за 7 дней"}, {720, "за 30 дней"}}}
render(w, a.nprClient, data)
})
log.Printf("npr: модуль зарегистрирован (включён: %v)", nprAvailable(rc))
}
// nprTest — проверка подключения для настроек.
func nprTest(rc *RuntimeConfig) (string, error) {
p, err := nprDB(rc)
@@ -527,7 +176,12 @@ func nprTest(rc *RuntimeConfig) (string, error) {
if tick != nil {
age = fmt.Sprintf("пульс экспортёра %s назад", time.Since(*tick).Round(time.Second))
}
return fmt.Sprintf("клиентов в текущем состоянии %d, записей истории %d, %s", clients, hist, age), nil
service := "служба bj-npr не отвечает"
if resp, err := (&http.Client{Timeout: 3 * time.Second}).Get(nprURL() + "/healthz"); err == nil {
resp.Body.Close()
service = "служба bj-npr работает"
}
return fmt.Sprintf("клиентов в текущем состоянии %d, записей истории %d, %s; %s", clients, hist, age, service), nil
}
func (h *setupHandlers) saveNPR(w http.ResponseWriter, r *http.Request) {
+78
View File
@@ -0,0 +1,78 @@
package lkgateway
import (
"html/template"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// Страница службы модуля встраивается в макет bj: меню и заголовок bj, содержимое — от модуля.
func TestModulePageWrap(t *testing.T) {
a, err := newAdmin()
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
frag := `<div id="npr-frag"><script src="/admin/npr/static/htmx.min.js"></script>фрагмент</div>`
render(rec, a.modulePage, modulePageData{page: nowPage("Клиент V3BR061 · НПР / УДС", "npr"), Content: template.HTML(frag)})
if rec.Code != http.StatusOK {
t.Fatalf("код %d: %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
for _, want := range []string{frag, "Клиент V3BR061 · НПР / УДС", "</html>"} {
if !strings.Contains(body, want) {
t.Errorf("в странице нет %q", want)
}
}
if strings.Count(body, "<html") != 1 {
t.Errorf("ожидался один документ, <html встречается %d раз", strings.Count(body, "<html"))
}
}
// Раздел «Экспорт QUIK» от службы встраивается в страницу настроек, индикатор меню — от службы.
func TestSetupPageEmbedsQuikExport(t *testing.T) {
a, err := newAdmin()
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
frag := `<h1>QUIK — Экспорт биржевой информации</h1><div id="qx-frag">раздел службы</div>`
render(rec, a.setup, SetupData{page: nowPage("Настройка", "setup"), AdvancedUnlocked: true, Modules: map[string]bool{"quik-export": true, "quik-mnp": true, "npr": true},
QuikExport: QuikExportView{HTML: template.HTML(frag), Ind: "warn"}})
if rec.Code != http.StatusOK {
t.Fatalf("код %d: %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, frag) {
t.Error("в странице настроек нет раздела от службы")
}
if !strings.Contains(body, `Экспорт QUIK<span class="ind warn">`) {
t.Error("индикатор меню не взят у службы")
}
}
// Версии: общий список в «Обзоре» и строка в разделе модуля.
func TestSetupPageShowsVersions(t *testing.T) {
a, err := newAdmin()
if err != nil {
t.Fatal(err)
}
vers := SystemVersions{At: time.Now(), Items: []SystemVersion{
{Key: "quik-mnp", Title: "QUIK — Модуль неторговых поручений", Version: "21.3.0", Detail: "обновлена 05.06.2026", Section: "quik"},
{Key: "depo", Title: "Fancy DEPO (база DEPO)", Section: "fancy", Err: "нет связи"},
}}
rec := httptest.NewRecorder()
render(rec, a.setup, SetupData{page: nowPage("Настройка", "setup"), AdvancedUnlocked: true, Modules: map[string]bool{"quik-mnp": true}, Versions: vers})
if rec.Code != http.StatusOK {
t.Fatalf("код %d: %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
for _, want := range []string{"Версии систем", "<strong>21.3.0</strong>", "QUIK — Модуль неторговых поручений: <strong>21.3.0</strong> · обновлена 05.06.2026", "не определена", "нет связи"} {
if !strings.Contains(body, want) {
t.Errorf("в странице нет %q", want)
}
}
}
+363
View File
@@ -0,0 +1,363 @@
package lkgateway
// nsddocs.go — документы НРД по депо-поручению в понятном виде. Пакеты в INBOX ИШ называются
// номерами (K1699092, 50818781 …); по содержимому определяем, что это за документ (квитанция о
// регистрации, уведомление GS036, отчёт о статусе GS116, отчёт об исполнении MS010, ошибка) и
// выгружаем папками с названием, описью и PDF для депозитария (как печатные формы из канала НРД).
import (
"archive/zip"
"bytes"
"context"
"fmt"
"html"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
)
// nsdDoc — один документ (пакет) НРД по поручению.
type nsdDoc struct {
Pkg string
Time time.Time
Code string // REG | REG_REJECT | GS036 | GS116 | MS010 | ERROR | ""
Title string
About string
Fields [][2]string
Done bool // отчёт об исполнении (MS010)
Rejected bool
Files []respFile // имена без папки пакета
}
var nsdTagCache sync.Map // имя элемента → *regexp.Regexp
// xmlTag — первое значение элемента name (без учёта префикса пространства имён).
func xmlTag(body, name string) string {
v, ok := nsdTagCache.Load(name)
if !ok {
v, _ = nsdTagCache.LoadOrStore(name, regexp.MustCompile(`<(?:\w+:)?`+name+`(?:\s[^>]*)?>\s*([^<]*?)\s*</(?:\w+:)?`+name+`>`))
}
if m := v.(*regexp.Regexp).FindStringSubmatch(body); m != nil {
return html.UnescapeString(m[1])
}
return ""
}
func addField(d *nsdDoc, k, v string) {
if strings.TrimSpace(v) != "" {
d.Fields = append(d.Fields, [2]string{k, strings.TrimSpace(v)})
}
}
var (
othrIDRe = regexp.MustCompile(`(?s)<OthrId>\s*<Id>([^<]+)</Id>`)
sfkpgAcctRe = regexp.MustCompile(`(?s)<SfkpgAcct>\s*<Id>([^<]+)</Id>`)
gs036PairRe = regexp.MustCompile(`(?s)<Ntfctns>\s*(?:<NtfctnHdr>([^<]*)</NtfctnHdr>)?\s*(?:<NtfctnTxt>([^<]*)</NtfctnTxt>)?\s*</Ntfctns>`)
prtryIDRe = regexp.MustCompile(`(?s)<(Rcvg|Dlvrg)SttlmPties>.*?<PrtryId>\s*<Id>([^<]+)</Id>`)
qtyUnitRe = regexp.MustCompile(`(?s)<(?:SttldQty|SttlmQty)>\s*<Qty>\s*<(?:Unit|FaceAmt|AmtsdVal)>([^<]+)<`)
dateInRe = regexp.MustCompile(`(?s)<(FctvSttlmDt|SttlmDt)>\s*<Dt>\s*<Dt>([^<]+)</Dt>`)
)
func securityLine(body string) string {
code, isin, desc := "", xmlTag(body, "ISIN"), xmlTag(body, "Desc")
if m := othrIDRe.FindStringSubmatch(body); m != nil {
code = m[1]
}
parts := []string{}
for _, p := range []string{code, isin, desc} {
if p != "" {
parts = append(parts, p)
}
}
return strings.Join(parts, " · ")
}
// classifyNSDDoc — что за документ НРД в пакете pkg (текст всех файлов пакета — body).
func classifyNSDDoc(pkg, body string) nsdDoc {
d := nsdDoc{Pkg: pkg}
biz := xmlTag(body, "BizSvc")
up := strings.ToUpper(body)
switch {
case biz == "MS010" || strings.Contains(body, "sese.025"):
d.Code, d.Done = "MS010", true
op := map[string]string{"DELI": "списание", "RECE": "зачисление"}[xmlTag(body, "SctiesMvmntTp")]
d.Title = "Отчёт об исполнении поручения (MS010)"
d.About = "Итоговый документ: НРД исполнил депо-поручение" + pick3(op != "", " — "+op+" бумаг проведено", "") + "."
addField(&d, "№ поручения", xmlTag(body, "AcctOwnrTxId"))
addField(&d, "Референс сделки M2M", xmlTag(body, "TradId"))
addField(&d, "Операция", op)
addField(&d, "Ценная бумага", securityLine(body))
if m := qtyUnitRe.FindStringSubmatch(body); m != nil {
addField(&d, "Количество", m[1])
}
if m := sfkpgAcctRe.FindStringSubmatch(body); m != nil {
addField(&d, "Счёт депо", m[1])
}
if m := dateInRe.FindStringSubmatch(body); m != nil {
addField(&d, "Дата расчётов", m[2])
}
case biz == "GS116" || strings.Contains(body, "sese.024"):
d.Code = "GS116"
d.Title = "Отчёт о статусе поручения (GS116)"
switch {
case strings.Contains(body, "<Mtchd>"):
d.About = "Поручение сквитовано со встречным поручением контрагента (MACH) — НРД переходит к исполнению."
addField(&d, "Статус сверки", "сквитовано (MACH)")
case strings.Contains(body, "<NMtchd>"):
d.About = "Поручение не сквитовано: встречное поручение контрагента не найдено или не совпадает по реквизитам."
addField(&d, "Статус сверки", "не сквитовано")
default:
d.About = "Промежуточный отчёт НРД о статусе поручения."
}
addField(&d, "№ поручения", xmlTag(body, "AcctOwnrTxId"))
addField(&d, "Рег. № в НРД", xmlTag(body, "AcctSvcrTxId"))
addField(&d, "Ценная бумага", securityLine(body))
if m := qtyUnitRe.FindStringSubmatch(body); m != nil {
addField(&d, "Количество", m[1])
}
if m := prtryIDRe.FindStringSubmatch(body); m != nil {
addField(&d, pick3(m[1] == "Rcvg", "Получатель", "Отправитель"), m[2])
}
if m := dateInRe.FindStringSubmatch(body); m != nil {
addField(&d, "Дата расчётов", m[2])
}
case biz == "GS036" || strings.Contains(body, "gs036"):
d.Code = "GS036"
subject := ""
for _, m := range gs036PairRe.FindAllStringSubmatch(body, -1) {
hdr, txt := strings.TrimSpace(html.UnescapeString(m[1])), strings.TrimSpace(html.UnescapeString(m[2]))
switch {
case hdr == "" && txt != "" && subject == "":
subject = txt
case hdr != "":
addField(&d, hdr, txt)
}
}
d.Title = "Уведомление НРД (GS036)"
if subject = strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(subject, "об "), "о ")); subject != "" {
prep := "о "
if r := []rune(subject); strings.ContainsRune("аоуэиыАОУЭИЫ", r[0]) {
prep = "об "
}
d.Title = "Уведомление " + prep + strings.TrimSuffix(subject, " к поручению депо") + " (GS036)"
}
d.About = "Промежуточное уведомление по поручению; итог — в отчёте о статусе (GS116) и об исполнении (MS010)."
case strings.Contains(body, "<reg_no>") || strings.Contains(body, "<contragent_doc_id>"):
abort, cause := xmlTag(body, "abort_code"), xmlTag(body, "abort_cause")
if abort != "" || cause != "" {
d.Code, d.Rejected = "REG_REJECT", true
d.Title = "Отказ в регистрации поручения"
d.About = "НРД не принял поручение к исполнению: " + strings.TrimSpace(abort+" "+cause) + "."
} else {
d.Code = "REG"
d.Title = "Квитанция о регистрации поручения"
d.About = "НРД принял поручение к обработке и присвоил регистрационный номер."
}
addField(&d, "Рег. № в НРД", xmlTag(body, "reg_no"))
addField(&d, "Дата регистрации", xmlTag(body, "reg_date"))
addField(&d, "№ поручения", xmlTag(body, "contragent_doc_id"))
addField(&d, "Код отказа", abort)
addField(&d, "Причина отказа", cause)
case strings.Contains(up, "ERROR") || strings.Contains(strings.ToUpper(pkg), "ERROR") || strings.Contains(strings.ToLower(body), "ошибк"):
d.Code, d.Rejected = "ERROR", true
d.Title = "Ошибка обработки пакета"
d.About = "НРД не смог обработать пакет: " + orderRespSummary(body)
case biz != "":
d.Title = "Отчёт НРД (" + biz + ")"
d.About = "Документ НРД по поручению."
default:
d.Title = "Документ НРД"
d.About = "Документ НРД по поручению (тип не распознан — см. файлы в папке)."
}
return d
}
// pkgFiles — файлы пакета (xml/rpt/htm/dbf/sgn, содержимое zip) без имени папки.
func pkgFiles(dir string) []respFile {
var out []respFile
files, _ := os.ReadDir(dir)
for _, f := range files {
if f.IsDir() {
continue
}
p := filepath.Join(dir, f.Name())
low := strings.ToLower(f.Name())
switch {
case strings.HasSuffix(low, ".xml"), strings.HasSuffix(low, ".rpt"), strings.HasSuffix(low, ".htm"), strings.HasSuffix(low, ".dbf"), strings.HasSuffix(low, ".sgn"):
if b, e := os.ReadFile(p); e == nil {
out = append(out, respFile{Name: f.Name(), Data: b})
}
case strings.HasSuffix(low, ".zip"):
if b, e := os.ReadFile(p); e == nil {
if zr, e2 := zip.NewReader(bytes.NewReader(b), int64(len(b))); e2 == nil {
for _, zf := range zr.File {
if rc, e3 := zf.Open(); e3 == nil {
data, _ := io.ReadAll(rc)
rc.Close()
out = append(out, respFile{Name: zf.Name, Data: data})
}
}
}
}
}
}
return out
}
// nsdOrderDocs — документы НРД по поручениям (contr_d_id), по времени.
func nsdOrderDocs(contrDIDs ...string) []nsdDoc {
entries, err := os.ReadDir(igwInboxDir)
if err != nil {
return nil
}
seen := map[string]bool{}
var out []nsdDoc
for _, cd := range contrDIDs {
cd = strings.TrimSpace(cd)
if cd == "" {
continue
}
for _, e := range entries {
dir := e.Name()
if !e.IsDir() || strings.HasPrefix(dir, "#") || dir == "INBOX" || seen[dir] {
continue
}
full := filepath.Join(igwInboxDir, dir)
body, mt := readPkgText(full)
if body == "" || !strings.Contains(body, cd) {
continue
}
seen[dir] = true
d := classifyNSDDoc(dir, body)
d.Time, d.Files = mt, pkgFiles(full)
out = append(out, d)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Time.Before(out[j].Time) })
return out
}
var folderBadChars = strings.NewReplacer("/", "-", `\`, "-", ":", " ", "*", "", "?", "", `"`, "", "<", "", ">", "", "|", "")
// nsdDocFolder — «02 Уведомление об отсутствии встречного поручения (GS036) — 50818777».
func nsdDocFolder(i int, d nsdDoc) string {
return folderBadChars.Replace(fmt.Sprintf("%02d %s — %s", i+1, d.Title, d.Pkg))
}
func mskTime(t time.Time) string {
if t.IsZero() {
return "—"
}
return t.In(time.FixedZone("МСК", 3*3600)).Format("02.01.2006 15:04:05") + " МСК"
}
// writeNSDDocsZip — документы НРД папками с понятными названиями + «Опись.txt» + PDF (если withPDF).
func writeNSDDocsZip(zw *zip.Writer, prefix, title string, docs []nsdDoc, withPDF bool) {
var opis strings.Builder
opis.WriteString(title + "\r\n")
opis.WriteString("Сформировано bj " + mskTime(time.Now()) + ". Папки — документы НРД в порядке поступления; внутри — подлинные файлы из INBOX ИШ (XML, печатная форма .rpt, таблицы .dbf, подпись .SGN).\r\n\r\n")
for i, d := range docs {
folder := nsdDocFolder(i, d)
for _, f := range d.Files {
if w, e := zw.Create(prefix + folder + "/" + f.Name); e == nil {
_, _ = w.Write(f.Data)
}
}
fmt.Fprintf(&opis, "%s\r\n Получен: %s\r\n Что это: %s\r\n", folder, mskTime(d.Time), d.About)
for _, kv := range d.Fields {
fmt.Fprintf(&opis, " %s: %s\r\n", kv[0], kv[1])
}
opis.WriteString("\r\n")
}
if w, e := zw.Create(prefix + "Опись.txt"); e == nil {
_, _ = w.Write([]byte("\ufeff" + opis.String()))
}
if !withPDF {
return
}
page := nsdDocsReportHTML(title, docs)
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
if pdf, err := htmlToPDF(ctx, page); err == nil {
if w, e := zw.Create(prefix + "Документы НРД.pdf"); e == nil {
_, _ = w.Write(pdf)
}
return
} else {
log.Printf("nsd-docs: PDF не собран (%v) — в архиве HTML", err)
}
if w, e := zw.Create(prefix + "Документы НРД.html"); e == nil {
_, _ = w.Write(page)
}
}
// nsdDocsReportHTML — читаемая сводка документов НРД (для PDF).
func nsdDocsReportHTML(title string, docs []nsdDoc) []byte {
esc := html.EscapeString
var b strings.Builder
b.WriteString(`<!doctype html><html lang="ru"><head><meta charset="utf-8"><title>` + esc(title) + `</title><style>`)
b.WriteString(`@page{size:A4;margin:16mm 14mm}body{font-family:"PT Sans","DejaVu Sans",Arial,sans-serif;color:#1a1a1a;font-size:12px;line-height:1.45}`)
b.WriteString(`h1{font-size:18px;margin:0 0 4px}.sub{color:#666;margin:0 0 14px}.doc{border:1px solid #cfd4dc;border-radius:8px;padding:10px 12px;margin:0 0 12px;break-inside:avoid}`)
b.WriteString(`.doc h2{font-size:14px;margin:0 0 2px}.meta{color:#666;font-size:11px;margin-bottom:6px}.about{margin:4px 0 8px}`)
b.WriteString(`table{border-collapse:collapse;width:100%}td{border-top:1px solid #e4e7ec;padding:4px 6px;vertical-align:top}td.k{color:#555;width:38%}`)
b.WriteString(`.ok{color:#0f7a37}.bad{color:#b3261e}.brand{border-bottom:2px solid #ff0508;padding-bottom:8px;margin-bottom:12px}</style></head><body>`)
b.WriteString(`<div class="brand"><h1>` + esc(title) + `</h1><p class="sub">Документы НРД по депо-поручению в порядке поступления · сформировано bj ` + esc(mskTime(time.Now())) + `</p></div>`)
for i, d := range docs {
cls := ""
switch {
case d.Done:
cls = "ok"
case d.Rejected:
cls = "bad"
}
fmt.Fprintf(&b, `<div class="doc"><h2 class="%s">%d. %s</h2><div class="meta">пакет НРД %s · получен %s</div><div class="about">%s</div>`,
cls, i+1, esc(d.Title), esc(d.Pkg), esc(mskTime(d.Time)), esc(d.About))
if len(d.Fields) > 0 {
b.WriteString("<table>")
for _, kv := range d.Fields {
fmt.Fprintf(&b, `<tr><td class="k">%s</td><td>%s</td></tr>`, esc(kv[0]), esc(kv[1]))
}
b.WriteString("</table>")
}
b.WriteString("</div>")
}
b.WriteString(`<p class="sub">Подлинные документы — файлы в папках архива (XML с подписью НРД .SGN). Этот PDF — их читаемое представление.</p></body></html>`)
return []byte(b.String())
}
// htmlToPDF — печать HTML в PDF headless-браузером (Chromium есть на серверах bj с Astra).
func htmlToPDF(ctx context.Context, page []byte) ([]byte, error) {
bin := ""
for _, name := range []string{"chromium", "chromium-browser", "google-chrome"} {
if p, err := exec.LookPath(name); err == nil {
bin = p
break
}
}
if bin == "" {
return nil, fmt.Errorf("браузер для печати в PDF не найден")
}
dir, err := os.MkdirTemp("", "bj-pdf-")
if err != nil {
return nil, err
}
defer os.RemoveAll(dir)
in, out := filepath.Join(dir, "page.html"), filepath.Join(dir, "page.pdf")
if err := os.WriteFile(in, page, 0o600); err != nil {
return nil, err
}
cmd := exec.CommandContext(ctx, bin, "--headless=new", "--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage",
"--no-pdf-header-footer", "--user-data-dir="+filepath.Join(dir, "profile"), "--print-to-pdf="+out, "file://"+in)
cmd.Env = append(os.Environ(), "HOME="+dir)
if msg, err := cmd.CombinedOutput(); err != nil {
return nil, fmt.Errorf("%v: %s", err, strings.TrimSpace(string(msg)))
}
return os.ReadFile(out)
}
+30
View File
@@ -0,0 +1,30 @@
//go:build live
package lkgateway
import (
"archive/zip"
"bytes"
"os"
"testing"
)
// Живой прогон: документы НРД по поручению 20608 (исходящий 16.09) → ZIP с папками, описью и PDF.
// go test -c -tags live ./internal/lkgateway/ ; запуск от bj: -test.run TestNSDDocsLive -test.v
func TestNSDDocsLive(t *testing.T) {
docs := nsdOrderDocs("20608")
for i, d := range docs {
t.Logf("%s | %v", nsdDocFolder(i, d), d.Fields)
}
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
writeNSDDocsZip(zw, "", "Документы НРД по поручению 16 (списание) № 20608", docs, true)
zw.Close()
zr, _ := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
for _, f := range zr.File {
if f.Name == "Опись.txt" || f.Name == "Документы НРД.pdf" || f.Name == "Документы НРД.html" {
t.Logf("в архиве: %s (%d байт)", f.Name, f.UncompressedSize64)
}
}
_ = os.WriteFile(os.TempDir()+"/nsd-docs-20608.zip", buf.Bytes(), 0o644)
}
+43
View File
@@ -0,0 +1,43 @@
package lkgateway
import (
"strings"
"testing"
)
func TestClassifyNSDDoc(t *testing.T) {
cases := []struct{ pkg, body, code, title string }{
{"K1699092", `<Batch><Document><reg_no>14462249</reg_no><reg_date>2026-09-16 16:03:53</reg_date><contragent_doc_id>20608</contragent_doc_id><abort_code /><abort_cause /></Document></Batch>`,
"REG", "Квитанция о регистрации поручения"},
{"K1", `<Batch><Document><reg_no>1</reg_no><contragent_doc_id>20608</contragent_doc_id><abort_code>12</abort_code><abort_cause>Нет встречного</abort_cause></Document></Batch>`,
"REG_REJECT", "Отказ в регистрации поручения"},
{"50818777", `<BizSvc>GS036</BizSvc><GS036><Ntfctns><NtfctnTxt> отсутствии встречного поручения к поручению депо</NtfctnTxt></Ntfctns><Ntfctns><NtfctnHdr>Код операции</NtfctnHdr><NtfctnTxt>16</NtfctnTxt></Ntfctns></GS036>`,
"GS036", "Уведомление об отсутствии встречного поручения (GS036)"},
{"50818779", `<BizSvc>GS116</BizSvc><AcctOwnrTxId>20608</AcctOwnrTxId><AcctSvcrTxId>14462249</AcctSvcrTxId><MtchgSts><Mtchd><Rsn><Id>MACH</Id></Rsn></Mtchd></MtchgSts>`,
"GS116", "Отчёт о статусе поручения (GS116)"},
{"50818781", `<BizSvc>MS010</BizSvc><AcctOwnrTxId>20608</AcctOwnrTxId><SctiesMvmntTp>DELI</SctiesMvmntTp><SttldQty><Qty><Unit>10</Unit></Qty></SttldQty><SfkpgAcct><Id>HL171004001C</Id></SfkpgAcct>`,
"MS010", "Отчёт об исполнении поручения (MS010)"},
}
for _, c := range cases {
d := classifyNSDDoc(c.pkg, c.body)
if d.Code != c.code || d.Title != c.title {
t.Errorf("%s: %s «%s», ожидали %s «%s»", c.pkg, d.Code, d.Title, c.code, c.title)
}
}
ms := classifyNSDDoc("50818781", cases[4].body)
if !ms.Done || !strings.Contains(ms.About, "списание") {
t.Errorf("MS010: %+v", ms)
}
var acc string
for _, kv := range ms.Fields {
if kv[0] == "Счёт депо" {
acc = kv[1]
}
}
if acc != "HL171004001C" {
t.Errorf("счёт депо: %q (%v)", acc, ms.Fields)
}
if f := nsdDocFolder(1, classifyNSDDoc("50818777", cases[2].body)); f != "02 Уведомление об отсутствии встречного поручения (GS036) — 50818777" {
t.Errorf("папка: %q", f)
}
}

Some files were not shown because too many files have changed in this diff Show More