Compare commits

..

31 Commits

Author SHA1 Message Date
zuevav 586ffb3a31 fix(poller): постоянная память обработанных пакетов ИШ
При перезапуске bj-server поллер заново вычитывал старые ответы НРД из ИШ и
повторно применял их к заявкам. Для ответов с нулевым GUID (M2Mxx) это давало
ложные «отклонения» по FIFO — все заявки выглядели rejected.

Теперь множество применённых id входящих пакетов хранится в
/var/lib/bj/.bj/poller-processed.json и переживает перезапуск. На самом первом
старте все уже лежащие во входящих пакеты помечаются обработанными, чтобы не
применять исторический backlog к текущим заявкам.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 10:37:04 +03:00
zuevav a694f475a8 fix(robot-test): корректные реквизиты для бизнес-проверок НРД
- TransferringDepositoryINN = наш ИНН 7703807489 (ООО ИК «Фонтвьель»,
  участник MC0413600000) — иначе M2M17 «неверный ИНН списывающего депозитария»
- место расчётов тест-инвестора = наш реальный тестовый счёт НРД
  (MC0413600000 / HL171004001C / 36MC0413600000F00, расч. деп. ИНН 7702165310)
  — иначе M2M19 «недопустимое место расчетов»

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 00:12:01 +03:00
zuevav 9737c787f9 feat: живой цикл M2M с НРД + мастер установки ключа на флешку
Инфраструктура M2M (живой обмен с НРД через ИШ):
- обработка M2MTransferResponse: ERROR(M2Mxx) → заявка Отклонена, сохранение
  ответа; INFO → ждём Decision; идемпотентность поллера
- fallback-корреляция ответов с нулевым GUID (M2M14/M2M17) по FIFO
- сырой XML ответа НРД в карточке заявки (для пересылки в ТП)
- тестовый пакет роботу приведён к эталону m2m_robot_samples (CostInfo=Yes,
  4 бумаги, IsolationStatus, DocumentSeries=сценарий); override паспорта
- редирект из теста сразу в карточку заявки

Мастер установки ключа Валидаты на флешку (admin/setup/keywizard):
- пошаговый: загрузка .7z+пароль → выбор флешки → запись → справочник
  сертификатов (CRL) → перезапуск+проверка ИШ → готово
- привилегированный воркер (bj-keymedia) в host-namespace через файл-обмен,
  bj-server остаётся в песочнице
- сохранение структуры профиля архива (spr<N>), перечисление съёмных USB

Прочее:
- пакет-доказательство для ТП НРД + форма регистрации участника M2M
- эталонные образцы робота (DOC/m2m_robot_samples)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 00:03:21 +03:00
fontvielle 6e503433d4 refactor(deploy): «одна команда — всё работает» для нового стенда
Переосмысление установщика по фидбеку: установщик должен делать чистую
установку «с нуля», а не миграцию. От пользователя требуется одна
команда и больше ничего:

    curl -sSL https://.../install.sh | sudo bash

deploy/astra/install.sh переписан:
- 9 чётких этапов с прогресс-индикатором (секунды от старта)
- Авто-определение ОС: Astra SE → официально, Astra CE/Debian → warn,
  Ubuntu → ask, остальные → fail
- Авто-скачивание дистрибутива ИШ (~120 МБ) с old.nsd.ru
- Авто-установка ИШ через dpkg (но если упадёт — это OK, продолжаем)
- НЕинтерактивный режим (--yes) для CI/автоматизации
- Финальная сводка с URL'ами + список ручных шагов
- Дефолтный setup.json создаётся сразу, чтоб bj-server понимал DSN
- ProtectSystem=strict, ReadWritePaths только нужные, PrivateTmp в
  systemd unit'е

deploy/astra/migrate-from-redos.sh → переименован в import-data.sh
и стал ОПЦИОНАЛЬНЫМ (для тех кто переезжает с действующего стенда).
Главный путь — чистая установка через install.sh.

deploy/astra/README.md перетряхнут:
- TL;DR с одной командой в самом верху
- Объяснение Astra SE vs Astra CE vs Debian (платное/бесплатное)
- 5 скриптов вместо 6
- Раздел «что произойдёт после установки» — режим эмуляции, что
  осталось руками
- 10-этапный путь от чистой ВМ до прохождения теста с роботом MOEX МОСТ

REPORT.md обновлён: установщик описан как «одна команда» вместо
«миграция», % готовности не изменился (но качество улучшилось).
2026-05-14 17:42:35 +03:00
fontvielle bac55cbdfd feat(deploy): полный набор скриптов миграции на Astra Linux
Поскольку Интеграционный шлюз НРД (ИШ) официально работает только на
Astra Linux SE 1.6/1.7 (RPM-версии нет, дистрибутив только .deb для
Debian-based), мигрируем инфраструктуру с РЕД ОС на Astra. Это
заодно проверит «чистую» установку с нуля.

deploy/astra/install.sh — установщик bj-server для свежей Astra ВМ:
- проверка ОС (Astra Linux SE)
- apt-зависимости (curl, git, podman, podman-compose, postgresql-client)
- скачивание Go 1.24+ с go.dev, если нет
- системный пользователь bj + каталоги /opt/bj /var/lib/bj /var/log/bj
- git clone репо в /opt/bj/src
- go build бинарника /opt/bj/bj-server
- podman-compose up -d postgres + накат всех миграций
- systemd unit с безопасными ограничениями (NoNewPrivileges,
  ProtectSystem=strict, ReadWritePaths только нужные, PrivateTmp)
- автозапуск + проверка systemctl is-active
- финальная подсказка с URL'ами

deploy/astra/install-validata.sh — установщик Валидата CSP:
- ищет .deb пакеты в dist/validata/ или принимает путь аргументом
- если пакетов нет — печатает текст письма для запроса дистрибутива
  у НРД (soed@nsd.ru) или МБ (pki@moex.com)
- dpkg -i + apt-get install -f для зависимостей
- проверка установки в /opt/Validata*

deploy/astra/install-ish.sh — установщик ИШ:
- ищет igate_*_amd64.deb в стандартных местах (dist/ish/, ~/Downloads и т.п.)
- проверяет наличие Валидаты (предупреждает если нет)
- dpkg -i igate_*.deb
- подсказывает следующие шаги (запуск GUI, настройка БД и канала WSL)

deploy/astra/migrate-from-redos.sh — экспорт со старой РЕД ОС ВМ:
- pg_dump БД bj (через podman exec или напрямую)
- копия ~/.bj/setup.json (ищется в нескольких стандартных путях)
- последние 7 дней логов из /var/log/bj и journalctl
- meta.txt с версиями ОС/пакетов
- README.md с инструкциями восстановления на новой ВМ
- итоговый тарбол /tmp/bj-migration-YYYY-MM-DD-HHMM.tar.gz

deploy/astra/healthcheck.sh — проверка после установки:
- 8 разделов: ОС, пользователь/каталоги, systemd, HTTP, PostgreSQL,
  Валидата, ИШ, сетевые порты
- цветной вывод OK/WARN/FAIL для каждого пункта

deploy/astra/README.md — пошаговая инструкция миграции из 8 этапов:
- подготовка (Astra ВМ, доступ root, запрос Валидаты у НРД)
- экспорт со старой ВМ
- install.sh на новой ВМ
- restore БД и настроек
- healthcheck
- install-validata.sh + импорт сертификатов УЦ МБ
- install-ish.sh + настройка канала WSL
- первый тест с роботом MOEX МОСТ
- раздел «что НЕ переносится автоматически»
- раздел «откат» (старая ВМ работает до подтверждения миграции)

REPORT.md обновлён:
- общая готовность 72% → 75%
- готовность к роботу 85% → 88%
- новая строка «Установщик/мигратор на Astra Linux: 100% »
- «Установка ИШ на наш стенд» поднялась с 0% до 30%
2026-05-14 17:37:10 +03:00
fontvielle 7a7aa0cf6c docs(ish): полный комплект документации ИШ НРД + help-страница «Архитектура обмена»
С официальной страницы НРД (https://www.nsd.ru/workflow/system/programs/web-service/)
скачано всё необходимое для подключения ИШ:

DOC/:
- ruk_install_ish_2025_11_10.pdf (4.7 МБ) — Руководство по установке ИШ
  от 10.11.2025, с разделами «6. Технические требования» и «7.3.2 Установка
  под Linux»
- ruk_pol_ish.pdf (3.5 МБ) — Руководство пользователя ИШ
- QA_ish.pdf (2.5 МБ) — Часто задаваемые вопросы
- test-case_ish.pdf (1.3 МБ) — Тест-кейсы для проверки работоспособности
- instr_int_sh_01072025.pdf (0.4 МБ) — Инструкция по заявке на тестирование
- web_service_nrd_standard_soap_rest.pdf (2.2 МБ) — техрекомендации
  Web-сервиса ONYX

dist/ish/:
- igate_100.0-765_amd64.deb (117 МБ) — дистрибутив ИШ для Astra Linux,
  не в git (в .gitignore), но всегда есть для установки
- igate_95.0-716_amd64.SGN — ЭП к дистрибутиву
- README.md — индекс файлов + ссылки на повторное скачивание

Ключевые факты из руководства (попали в REPORT.md и help-страницу):
- ИШ работает на Astra Linux SE 1.6/1.7 (РЕД ОС не упомянута) или Windows
- СКЗИ — Валидата CSP + АПК Валидата Клиент L (НЕ КриптоПро!)
  Дистрибутив Валидаты — по запросу soed@nsd.ru / pki@moex.com
- БД — PostgreSQL (обязательна для REST API ИШ) или SQLite
- Сертификат — только от УЦ Московской Биржи (ca.moex.com)

Добавлена help-страница /admin/help/architecture с:
- ASCII-диаграммой полной схемы (bj-server → ИШ → ONYX → робот)
- Таблицей «кто на чьей стороне, какая ОС, какая СКЗИ»
- 8 FAQ-вопросов (включая «ИШ — это сервер НРД?», «можно ли в одну ВМ?»,
  «зачем Валидата если есть КриптоПро» и др.)
- Чек-лист «что у нас уже готово»

REPORT.md обновлён:
- общая готовность 70% → 72%
- 7 внешних блокеров вместо 6 (Astra Linux ВМ + Валидата CSP стали явными)
- раздел «Дистрибутив ИШ и полная документация» с описанием каждого файла

Cleanup: .gitignore теперь исключает /dist/ish/*.deb но пропускает
README.md внутри той же папки.
2026-05-14 17:28:59 +03:00
fontvielle de41aea00c feat(igw): REST-клиент ИШ НРД по DOC/instr-ish-rest-api.pdf + упаковщик ZIP
Полный клиент Интеграционного шлюза НРД в internal/nsdadapter/igw/:

client.go — REST endpoint'ы по свежей спецификации НРД:
- POST /api/package/{channel}/file — отправка ZIP (Type=archive, File=base64)
  возвращает id пакета (поддерживаются варианты id|package_id|ID)
- GET /api/package/status/{id} — статус NEW|SENT|ERROR (с error-полем)
- GET /api/package?channel=&type=M2MTD|M2MER&date=&id=&count=&excludeErrors=
  — список входящих от НРД, с files[] и signs[] (ИШ сам проверяет ЭП и
  выдаёт VALID|INVALID)
- GET /api/package/{id} — скачать ZIP (raw или base64-в-JSON, авто-детект
  по сигнатуре PK\x03\x04)
- Ретраи только на 5xx/сетевые ошибки (4xx — сразу ошибка)
- HTTP-клиент через options, кастомный таймаут, ретраи

pack.go — упаковщик/распаковщик ZIP по разделу 2.3 инструкции:
- PackRequest(req, docName) — M2MTransferRequest→ZIP с config.xml
- PackXML(xml, docName, packageType) — для эталонных сообщений
- UnpackPackage(zip) → {DocXML, WinfXML, Signature, Filenames}
- ParseDecision / ParseResponse через nsdxml.Unmarshal

Покрыто тестами (10/10 PASS):
- send happy path с проверкой формата JSON-body
- retry на 5xx, без ретраев на 4xx
- GetStatus с числовым id
- ListIncoming как массив (новый формат) и как {items:[]} (старый)
- GetPackage raw ZIP + GetPackage с base64-в-JSON
- упаковка/распаковка: 2 файла в ZIP, имена, содержимое config.xml
- распаковка с .sgn и winf.xml

cmd/bj-server/main.go — NSD-poller адаптирован под новый API
(client.ListIncoming(ctx, ListFilter{}) вместо позиционных параметров;
поля Package.ID/Name/Type/State вместо PackageID/PackageType).

Скачана и положена в DOC/ свежая спецификация (798 KB, 15 стр):
DOC/instr-ish-rest-api.pdf — это исходный документ для нашей реализации.

REPORT.md обновлён:
- общая готовность 65% → 70%
- готовность к роботу 80% → 85%
- добавлен раздел про REST-клиент ИШ
- блокер #6 — отсутствие «Руководства по установке ИШ»
2026-05-14 17:10:17 +03:00
fontvielle 5fa6ea6ab1 feat(robot): эмулятор робота-автотеста НРД + help-страница + REPORT.md
Реализован внутренний робот-эмулятор в internal/nsdadapter/mock/robot.go.
Источник правил: DOC/instruktsiya-po-testirovaniyu-s-robotom.pdf (от
12.05.2026). Когда mock.Sender видит Header.ReceiverCode == MC0012500000
и DocumentSeries в {1111, 2001, 2002, 3333} — формирует Decision по
выбранному сценарию вместо default-логики.

Сценарии:
- 1111 «Ответ с отказом»: все бумаги Rejection, код ошибки берётся из
  последних 2 символов DocumentNumber (01..09 → M2M01..M2M09)
- 2001 «Принять все бумаги»: все Confirmation; i-й символ DocumentNumber
  = номер депозитария-получателя для i-й секции (1/2)
- 2002 «Принять частично»: 0 = отклонить с M2M05, иначе номер депозитария
- 3333 «Выступить принимающей стороной»: пока только первое сообщение
  (отказ M2M05). Встречный M2MTransferRequest от робота — TODO
  (требует приёмной стороны bj-server)

Тестовые наборы депозитариев (ИНН 7702165310, depcode MC0012500000,
счёт HL2603250011, разделы 31MC0012500000F00 и 36MC0012500000F00)
зашиты в robotDepositary — соответствуют таблице из инструкции.

Help-страница /admin/help/robot с полным описанием: коды робота,
сценарии, управление через DocumentNumber, тестовые данные, коды ошибок
M2M01-M2M09, как переключиться на реальный TEST3 после получения ИШ.

REPORT.md — сводный отчёт для руководства о ходе работ: ~65% общей
готовности системы, ~80% готовности к интеграционному тесту с роботом
(остальное — внешние блокеры: дистрибутив ИШ, сертификат УЦ МБ).
Расписан план первичного тестирования после получения ИШ — 2-3 недели
до продакшена.

.gitignore: исключены DOC/*.pdf.bak (бэкапы doc-watcher'a).
2026-05-14 16:53:52 +03:00
fontvielle 1ffe62133c feat(admin): баннер «🟡 РЕЖИМ ЭМУЛЯЦИИ» сверху страниц когда mock
Когда ИШ НРД не настроен (NSD.IGWBaseURL пустой) или провайдер СКЗИ =
stub — рисуем жёлтую плашку сверху каждой страницы админки с явной
надписью «РЕЖИМ ЭМУЛЯЦИИ — реального обмена с НРД нет» и ссылкой
«Настроить →» на /admin/wizard. Это нужно чтобы пользователь видя
быстро-подтверждённую заявку не думал что это настоящий обмен.

Реализация: добавлено поле IsMockMode/MockReason в page struct,
nowPage() читает globalRC.Snapshot() и заполняет их. globalRC — пакетная
переменная (заполняется один раз в RegisterAdmin), чтобы не таскать
*RuntimeConfig через все renderXxx-вызовы. Сам баннер — в layout.html
перед <main>, поэтому виден везде включая карточку заявки.
2026-05-14 16:44:24 +03:00
fontvielle 19a2b6dda4 fix(admin): кнопка «Проверить документацию» возвращает на /admin/news + браузерный UA для nsd.ru
Три бага в Doc-watcher / Новостях, всплывшие при первом ручном прогоне:

1. setupFlash после POST в /admin/news/check-docs редиректил на
   /admin/setup, а не на /admin/news, и оператор «выпадал» с ленты.
   Теперь setupFlash смотрит Referer и возвращает на любой из
   /admin/wizard, /admin/news, /admin/setup — на ту страницу с которой
   пришёл POST.

2. http.DefaultClient в news.go и cacerts.go подхватывал HTTPS_PROXY
   из окружения и шёл через корпоративный zetit, который блокирует
   nsd.ru (CONNECT 403). Заменил на noProxyClient с явно отключённой
   проксификацией (Transport.Proxy = nil) — doc-watcher всегда идёт
   напрямую, независимо от ENV.

3. nsd.ru отдаёт 403 на запросы с UA «bj-server/1.0» (антибот). Заменил
   на стандартный Chrome User-Agent + браузерные Accept/Accept-Language.
   После этого moex-most-dlya-m2m.pdf найден и скачан, новость
   «Обновлена документация» опубликована.

Кроме того, по запросу — убрана форма «Добавить вручную» с /admin/news.
В UI остался только мониторинг: автоматическая лента событий +
ручная кнопка «🔄 Проверить обновления документации сейчас».
Handler /admin/news/add сохранён в коде на случай ручного ввода
инцидентов в будущем.
2026-05-14 16:36:31 +03:00
fontvielle 93f3ec240c feat(admin): блок «Новости» + doc-watcher + авто-уведомления о сертификатах УЦ
Новый раздел /admin/news — лента событий системы (окна техработ НРД,
обновления документации, переустановка сертификатов УЦ). Каждая
новость со временем, типом (maintenance/feature/doc-update/system/
manual), опциональным окном действительности (ValidFrom..ValidTo) и
ссылкой на источник. Лента не очищается — служит журналом для аудита.

На дашборде /admin/ — компактный блок «📢 Новости»: показывает максимум
3 актуальных события (активных сейчас или с окном, начинающимся в
ближайшие 7 дней; в остатке — самые свежие). Окна техработ при
наступлении становятся жёлтыми (border-left, ValidFrom..ValidTo).

В ленте можно добавлять новости вручную (форма на /admin/news), скрывать
(soft-delete через Dismissed). Дедуп по ID.

Doc-watcher: горутина в bj-server, раз в сутки качает страницы НРД
(дефолтные источники — moex-most, программы НРД, криптосервис), парсит
HTML на ссылки .pdf, скачивает новые версии в DOC/ (со старыми
переименовывая в .YYYY-MM-DD.pdf.bak для аудита), и публикует
новость «Обновлена документация: <file>». Sha256-дедуп — пере-импорта
неизменённого PDF не будет.

Cacerts.go: FetchCACertificates теперь принимает *RuntimeConfig и при
успешной переустановке сертификата эмитирует NewsItem «Обновлён
сертификат УЦ: <CN>». Если сертификат истекает в ближайшие 14 дней —
отдельная новость-предупреждение. Это закрывает запрос «получает в авто
режиме и предупреждает об этом» из обсуждения.

SeedDefaultNews публикует при старте bj-server две известные новости:
- TEST3 недоступен 18.05.2026 — 22.05.2026 (НРД письмо НРД-И-2026-8452)
- Робот-автотест MOEX МОСТ доступен на TEST3 с 12.05.2026

Скачаны три свежие инструкции с nsd.ru/services/novye-servisy/moex-most-dlya-m2m/:
- DOC/instruktsiya-po-testirovaniyu-s-robotom.pdf (новая, 12.05.2026)
- DOC/instruktsiya-dlya-osuschestvleniya-obmena-soobscheniyami-...-fizicheskim-litsom-samomu-sebe.pdf (новая, 12.05.2026)
- DOC/servis-most-m2m.pdf (актуальная общая инструкция)

Mastered tasks: #46, #47, #48.
2026-05-14 16:26:41 +03:00
fontvielle f1e05c0ca3 feat(admin): копирование контейнеров КриптоПро с флешки в HDIMAGE + уточнение PKI по докам НРД
Сканирование смонтированных USB-носителей (/run/media/$USER, /media,
/mnt) на папки вида name.000 с *.key — это «контейнер КриптоПро на
флешке». В шаге 3 wizard'а и в /admin/setup появилась таблица
найденных контейнеров с кнопкой «Скопировать в локальное хранилище»
(копирует папку в /var/opt/cprocsp/keys/$USER/, после чего контейнер
виден как \\.\HDIMAGE\name и работает без вставленной флешки).

Дедуп по AlreadyImported — если папка уже есть в HDIMAGE, кнопка не
показывается. Сертификат из контейнера импортируется отдельно через
certmgr -inst -cont (это пока вне UI, подсказка в текст flash-сообщения).

Кроме того — переписан help-блок в шаге 3 wizard'a на основании
«Инструккия M2M.pdf» (стр. 11, 16-19): явно расписано, что в режиме
ИШ подписывает сам ИШ (наш ключ в ИШ), а в режиме прямого ONYX —
bj-server. Таблица «что куда грузить»: УЦ МБ в mroot, УЦ НРД в
mroot+uRoot, наш сертификат в uMy только если без ИШ. Сертификаты
с Рутокена явно отмечены как «не грузить — сами появятся».
2026-05-14 16:12:37 +03:00
fontvielle 2142c4f586 feat(admin): авто-загрузка сертификатов УЦ НРД + ежесуточное обновление
Новый раздел /admin/setup → «Сертификаты УЦ» (и в шаге 3 wizard'а): список
URL .cer-файлов УЦ (одна ссылка на строку). По кнопке «Скачать сейчас»
система качает каждый URL, парсит X.509, и через certmgr -inst импортирует
в mroot (если cert == issuer, т.е. корневой) или uRoot (промежуточный).

Дедуп по SHA-256: если файл по URL не изменился — повторного импорта нет.
В runtime-конфиге сохраняется журнал FetchedCerts (CN/SHA-256/срок/статус)
для отображения в UI.

Чекбокс «Авто-обновление раз в сутки» включает фоновую горутину
StartCACertsAutoUpdater — стартует через 30 сек после bj-server, потом
тикает раз в 24 часа. При изменении сертификата он переустанавливается
без участия оператора.

Mastered tasks: #44.
2026-05-14 15:50:06 +03:00
fontvielle cb0f7efd4c feat(admin): мастер настройки /admin/wizard + авто-подъём PostgreSQL одной кнопкой
Для пользователя без IT-навыков — пошаговая настройка (5 шагов) с
прогресс-баром, подсказками «?» рядом с каждым полем и блоками
«Что это?» / «Где взять?» в каждом шаге. Шаги: PostgreSQL → КриптоПро →
Сертификаты → ИШ НРД → Тестовая заявка. Авто-определение текущего шага
по первому незавершённому пункту, навигация Назад/Далее, мягкие пропуски
(in-memory / mock-режимы).

В шаге 1 — « Поднять локальный PostgreSQL автоматически»: одна кнопка
запускает podman-compose, ждёт pg_isready, накатывает миграции
fansy-store + m2m-core, сохраняет DSN в runtime-конфиг. setupFlash теперь
возвращает пользователя на /admin/wizard, если POST пришёл оттуда —
визард не «теряется» после действий.

Mastered tasks: #41, #42, #43.
2026-05-14 15:46:31 +03:00
fontvielle 0ef75e05e8 feat(admin): импорт сертификатов через UI + список сертификатов на токенах + URL контуров НРД
После реальной установки КриптоПро CSP добавлены следующие
функциональности:

cryptocli/client.go:
- FindCertificates() — перечисляет CKO_CERTIFICATE объекты на всех
  подключенных слотах через PKCS#11, парсит X.509, извлекает CN, ИНН
  (OID 1.2.643.3.131.1.1), серийник, срок действия. Для каждого
  сертификата проверяет наличие парного приватного ключа (CKO_PRIVATE_KEY
  с тем же CKA_ID).
- Тип Certificate с полями: SubjectCN, IssuerCN, INN, Serial, NotBefore,
  NotAfter, DER, HasPrivateKey, TokenLabel, SlotID.

internal/lkgateway/setup.go:
- handler importCertificate (POST /admin/setup/crypto/import-cert,
  multipart). Принимает .pfx/.p12 (с PIN) или .cer/.crt. Запускает
  certmgr -inst -pfx или -inst с выбором хранилища (uMy/mroot/uRoot).
- listCertsForUI() — вспомогательный метод renderSetup для подгрузки
  актуального списка сертификатов с подключенных токенов при каждом
  открытии страницы.

internal/lkgateway/web/templates/admin_setup.html:
- секция «Сертификаты на токенах» с таблицей (Кому/Кем выдан/ИНН/срок/
  токен/есть-ли-приватный-ключ).
- форма «Импорт сертификата (.pfx/.cer/.crt)» с полями для PIN и
  выбора хранилища.
- блок «Интеграционный шлюз НРД»: добавлен JS автозаполнения URL ONYX
  и контейнера по выбору профиля (guest/test3/prod × gost/rsa) —
  значения из DOC/Ссылки для доступа в тестовые контуры.pdf.

internal/lkgateway/web/templates/admin_help_systems.html:
- секция «Интеграционный шлюз НРД и контуры тестирования» дополнена
  полной таблицей URL-ов сервисов GUEST/TEST3 (ONYX, Agate, DCS,
  Единый кабинет, Корпоративные действия). IP gost.nsd.ru для
  настройки межсетевого экрана.
- новая секция «Сертификаты УЦ НРД (для проверки квитанций)» с
  пошаговой инструкцией: куда импортировать корневой сертификат УЦ
  НРД, куда промежуточные, куда наши сертификаты из стороннего УЦ.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:34:32 +03:00
fontvielle 3e34995e69 docs(cryptopro): инструкция установки CSP по реальному опыту + LD_LIBRARY_PATH в systemd
После реальной установки на dev-ВМ выяснилось:
- Минимальный набор rpm-пакетов для работы PKCS#11 — 8 штук, в т.ч.
  lsb-cprocsp-capilite-64 (libcapi20.so.4) и lsb-cprocsp-kc1-64
  (CSP уровня КС1). Без kc1 Initialize() возвращает CKR_FUNCTION_FAILED.
- КриптоПро не пишет /etc/ld.so.conf.d, поэтому Go-клиент cryptocli
  не находит libcppkcs11.so. Решение — LD_LIBRARY_PATH=/opt/cprocsp/lib/amd64
  в окружении bj-server.
- Демо-лицензия на 3 месяца встроена в дистрибутив — отдельная
  активация не требуется до истечения.

deploy/systemd/bj-server.service:
- добавлена Environment=LD_LIBRARY_PATH=/opt/cprocsp/lib/amd64
  с пояснением в комментарии

internal/lkgateway/web/templates/admin_help_cryptopro.html:
- секция «Установка КриптоПро CSP на РЕД ОС» полностью переписана:
  - две команды: через UI (рекомендуется) и через rpm вручную
  - полный список 8 нужных пакетов с пояснением каждого
  - предупреждение про LD_LIBRARY_PATH и команда для запуска из shell
  - информация про демо-лицензию (94 дня встроены)

internal/lkgateway/setup.go:
- if-else цепочка распаковки заменена на switch (gocritic lint)

Проверено: после установки kc1 веб-кнопка «Проверить подключение СКЗИ»
показывает «Доступно токенов: 1. Криптография готова к работе.
(PKCS#11 v2.11, cryptopro.ru). Токены: CryptoPro Token (CPPKCS 3)».

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:21:08 +03:00
fontvielle 82b3186b95 feat(admin): загрузка дистрибутива КриптоПро через UI + активация лицензии
В карточке «СКЗИ» страницы /admin/setup добавлены два новых блока:

1. «Установка КриптоПро CSP» — multipart-форма с input type=file.
   Принимает .tar/.tgz/.tar.gz/.rpm (формат с cryptopro.ru). После
   загрузки на сервер (лимит 256 МБ):
   - сохраняет архив в /tmp/bj-cryptopro/
   - распаковывает (tar -xzf или tar -xf)
   - находит все .rpm в распакованной директории
   - выполняет sudo rpm -Uvh --replacepkgs --nosignature на найденные пакеты
   - возвращает результат с количеством установленных пакетов и выводом rpm

2. «Активация лицензии» — поле для ввода серийника и кнопка.
   Вызывает /opt/cprocsp/sbin/amd64/cpconfig -license -set <серийник>.
   Если cpconfig не найден — показывает подсказку про /admin/help/cryptopro.
   После успеха сохраняет серийник в runtime-конфиге.

internal/lkgateway/setup.go:
- handler installCryptoPro (multipart form, parse, untar, find rpms, sudo rpm)
- handler activateLicense (cpconfig -license -set, сохранение в RuntimeConfig)
- общие хелперы runCmd / runCmdInDir для exec через context

internal/lkgateway/web/templates/admin_setup.html:
- секция «Установка КриптоПро CSP» с формой загрузки
- секция «Активация лицензии» с полем + кнопкой
- ссылки на /admin/help/cryptopro и cryptopro.ru/products/csp/downloads

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:09:20 +03:00
fontvielle 660d71e21a fix(admin): обновить блок СКЗИ — убрать упоминания JCP, явно про PKCS#11
В карточке «Криптография» на странице /admin/setup остались упоминания
старой Java-реализации (КриптоПро JCP, путь к jcp.jar) — устаревшая
информация. Новый клиент работает напрямую через PKCS#11 (см. коммит
2e09e21).

internal/lkgateway/web/templates/admin_setup.html:
- Заголовок: «Криптография (КриптоПро JCP)» → «СКЗИ (КриптоПро CSP, Рутокен и др. через PKCS#11)»
- Опция «КриптоПро JCP» в селекте → «КриптоПро CSP (через PKCS#11)»
- Опция «Валидата JCP» → «Валидата»
- Добавлена опция «Рутокен ЭЦП 2.0 (для подписи оператора)»
- Поле «Путь к jcp.jar» → «Путь к модулю PKCS#11» с placeholder
  /opt/cprocsp/lib/amd64/libcppkcs11.so
- Поле «UDS-сокет» помечено как (legacy) — для совместимости со старым
  Java crypto-service
- Подпись «Лицензионный ключ» → «Серийный номер лицензии», placeholder
  типа реального серийника КриптоПро
- Описание под формой переписано: команды установки CSP и пути для
  Рутокен, ссылка на /admin/help/cryptopro

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:04:27 +03:00
fontvielle 9216eafb7f fix(admin): пункт «Инструкции» в верхнем меню обоих UI
В lkgateway/layout.html добавлен <a href="/admin/help">Инструкции</a>
(ссылка пропала при предыдущем мерже шаблонов).

В lkemulator/layout.html добавлен <a href="{{.GatewayURL}}/admin/help"
target="_blank">Инструкции →</a> — открывает справку lk-gateway в
новой вкладке (эмулятор сам справку не хостит, ссылка на gateway).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:02:20 +03:00
fontvielle 2e09e21ad6 feat(cryptocli): Go-клиент через PKCS#11 — КриптоПро CSP, Рутокен, etc.
Заменили stub-клиент на полноценный PKCS#11 wrapper через
github.com/miekg/pkcs11. Поддерживает любой PKCS#11-совместимый
провайдер: КриптоПро CSP (libcppkcs11.so), Рутокен ЭЦП 2.0
(librtpkcs11ecp.so), Валидата, ViPNet и др.

internal/cryptocli/client.go:
- cryptocli.Client с конфигом {Provider, ModulePath, PIN, SlotID}
- Health() — Initialize → GetInfo → GetSlotList(WithToken=true) →
  GetTokenInfo для каждого слота. Возвращает HealthInfo с
  Cryptoki/library версиями, manufacturer и списком подключённых
  токенов (label, model, serial)
- DefaultModulePath() — путь до .so для каждого провайдера (CSP,
  Рутокен, Валидата, ViPNet)
- Если провайдер=stub или модуль не найден — клиент возвращает
  понятную ошибку, lk-gateway переходит в режим без криптографии

В admin/setup wizard:
- В карточке «Криптография» появилась кнопка «Проверить подключение СКЗИ»
  → POST /admin/setup/crypto/check → cryptocli.Health() → flash с
  результатом сверху страницы (список токенов или диагностика)
- Поле "UDS-сокет" помечено как legacy (для старого Java crypto-service),
  основное поле — «Путь к модулю PKCS#11» с дефолтами и подсказками
- Расширен список провайдеров: добавлен «rutoken»

internal/cryptocli/client_test.go:
- Тесты Stub, MissingModule, EmptyPath, DefaultModulePath
- Старые тесты на UDS-сокет удалены (теперь PKCS#11)

Зависимость: github.com/miekg/pkcs11 v1.1.2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:59:19 +03:00
fontvielle 67e81e5d7f feat(admin): вкладка «Инструкции» + русификация статусов в UI
В admin-панели lk-gateway добавлен раздел /admin/help — справка по
основным интеграциям, читается прямо на сервере, без выхода во
внешнюю документацию.

Состав /admin/help:
- /admin/help — hub-страница с 4 карточками-ссылками
- /admin/help/database — подключение PostgreSQL, схемы fansy/fansy_staging/m2m_core,
  что подгружается через ETL Fansy и что пишет сама система, роли,
  миграции, полезные запросы
- /admin/help/lk-api — REST-контракт ESIA Finance V1: аутентификация,
  POST/GET/PATCH/list, формат callback'ов и ошибок, эмулятор для тестов,
  примеры curl
- /admin/help/cryptopro — установка КриптоПро CSP на РЕД ОС и Ubuntu,
  ввод серийного номера лицензии (cpconfig), путь к PKCS#11 модулю
  libcppkcs11.so, подключение Рутокен ЭЦП 2.0 для подписи оператора,
  тестирование подписи через csptest и cryptcp
- /admin/help/systems — Интеграционный шлюз НРД (профили guest/test3/prod),
  команда Fansy (порядок согласования контракта), уведомления
  (SMTP/Yandex Messenger/Telegram), контакты команд

Русификация статусов:
- Добавлены template-функции ruState и ruOutcome (в lkgateway и lkemulator)
- "draft" → "Черновик", "confirmed" → "Подтверждена", "rejected" → "Отклонена" и т.д.
- CSS-классы бейджей сохраняются (по исходному state), меняется только
  отображаемый текст. Технические термины (PostgreSQL, ИНН, GUID, REST)
  остаются как есть — они являются именами программного обеспечения.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:53:37 +03:00
fontvielle 978777ff6a refactor(cmd): объединить Go-сервисы в один бинарник bj-server
Под выбранный объём 100-1000 сделок/день микросервисная архитектура с
5 отдельными процессами избыточна. Объединяем lk-gateway, m2m-core,
nsd-adapter, notify в один Go-бинарник bj-server. lk-emulator остаётся
отдельным бинарником как QA-инструмент (имитация ЛК ESIA Finance).

cmd/bj-server/main.go:
- Поднимает lkgateway.NewServer (HTTP :8080, REST API + admin UI)
- Фоновый poller NSD ИШ (если задан BJ_NSD_PROFILE)
- Заглушка notify worker (M3-M4: e-mail / Yandex Messenger / WS-push)
- Graceful shutdown через signal.NotifyContext

cmd/lk-gateway/, cmd/m2m-core/, cmd/nsd-adapter/, cmd/notify/ — удалены.

deploy/systemd/:
- bj-server.service — systemd unit для основного сервиса (один файл, простой деплой)
- bj-emulator.service — systemd unit для эмулятора
- README.md с инструкцией по установке (useradd bj, /opt/bj, daemon-reload)

Makefile:
- build теперь собирает только bj-server и lk-emulator
- бывшие пять бинарей удалены

Размер:
- bj-server: 19.5 МБ (включает lk-gateway + m2m-core + nsd-adapter + notify)
- lk-emulator: 12.7 МБ
- общий размер артефактов уменьшился c ~50 МБ до 32 МБ

Внутренние пакеты internal/<...> не изменились — разделение сохраняется
на уровне Go-пакетов, что облегчает возврат к микросервисам если объём
вырастет до 1000+ сделок/день.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:46:21 +03:00
fontvielle ee642e5eaa feat(m2mcore): PgRepository через pgx + интеграция в lk-gateway
PostgreSQL-репозиторий для m2m_core.deals — реальное хранилище сделок
вместо in-memory. Выбор Repository происходит автоматически в
lkgateway.NewServer: если в runtime-конфиге задан Postgres DSN, поднимается
pgxpool и используется PostgresRepository; иначе fallback на MemoryRepository.

internal/m2mcore/pgrepo.go:
- PostgresRepository: Create (идемпотентный по guid через ON CONFLICT DO NOTHING),
  GetByGUID, GetByID, Update, List (с фильтрами state/investor/created_*),
  AppendEvent для журнала deal_events
- request_xml/response_xml/decision_xml хранятся как windows-1251 XML через nsdxml,
  на чтении парсятся обратно в m2m.M2M* структуры
- stages — jsonb с историей FSM-переходов

migrations/m2m-core/002__stages.sql:
- ALTER TABLE deals ADD COLUMN stages jsonb DEFAULT '[]'

internal/lkgateway/server.go:
- При NewServer проверяется runtime-config: если есть DSN → PostgresRepository,
  иначе MemoryRepository; ошибка подключения логируется с fallback на in-memory
- Тесты используют tempdir SetupPath для изоляции от реальной БД

internal/lkgateway/setup.go:
- tryPingPostgres переписан с database/sql (требует регистрации драйвера)
  на pgx.Connect — теперь форма /admin/setup/postgres реально проверяет
  подключение перед сохранением DSN

Проверено сквозным smoke-тестом: введение DSN через UI →
сохранение в ~/.bj/setup.json → перезапуск lk-gateway → лог
"PostgresRepository подключён (m2m_core.deals)" → сделки реально пишутся
в БД.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:43:49 +03:00
fontvielle 958d777751 feat(lk-gateway): admin setup wizard — конфигурация подсистем через UI + тестовый прогон
Добавлена вкладка «Настройка» в admin-панели lk-gateway. Позволяет
ввести параметры каждой подсистемы прямо через веб-интерфейс,
проверить подключение и запустить тестовую заявку в один клик.

internal/lkgateway/runtimeconfig.go:
- Runtime-конфиг с персистом в JSON (BJ_SETUP_PATH или ~/.bj/setup.json)
- Поля: PostgresDSN, Crypto (provider/socket/jcp_path/license_key),
  NSD (profile/igw_base_url/key_container), LK (callback_url),
  LastTestRun (результат последнего тестового прогона)
- ReadinessSummary() для блока «Готовность системы: X из Y»

internal/lkgateway/setup.go:
- GET /admin/setup — страница настройки
- POST /admin/setup/postgres — DSN + sql.Ping (без pgx-драйвера упадёт
  на «unknown driver postgres», что покажет пользователю)
- POST /admin/setup/crypto — provider/socket/jcp.jar/лицензия, проверка
  существования файла jcp.jar
- POST /admin/setup/nsd — профиль/URL ИШ/контейнер, GET /healthz ИШ
- POST /admin/setup/lk — callback URL + GET /healthz эмулятора/ЛК
- POST /admin/setup/test-run — пробная сквозная заявка с предзаполнением
  (Иванов, 1500 акций Газпрома, ИИС T03), опрос статуса до финального

internal/lkgateway/web/templates/admin_setup.html:
- 4 карточки подсистем со статус-индикаторами (зелёная/красная точка)
- Inline-формы через <details>/<summary>: открыты для не настроенных,
  свёрнуты для уже настроенных
- Карточка «Тестовый прогон» с историей последнего результата
- Прогресс «Готовность системы: X из Y» в верхней части

internal/lkgateway/server.go:
- Server.rc *RuntimeConfig — поднимается при NewServer
- CheckOptions для admin-дашборда теперь берутся из runtime-конфига,
  а не только из ENV — изменения в /admin/setup сразу видны в /admin/
  и /admin/status без перезапуска

В layout.html добавлена nav-ссылка «Настройка», между «Дашборд» и
«Заявки».

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 11:30:48 +03:00
fontvielle c5695bf0b6 feat(m2m): сквозной поток с веб-интерфейсами — lk-gateway BFF + admin UI + lk-emulator + mock NSD
Реализован M2-шаг-1: первый рабочий сквозной поток M2M-заявки от ЛК
через нашу систему и обратно, с двумя видимыми веб-интерфейсами.

internal/nsdadapter/mock/:
- mock NSDSender с реалистичным синтетическим Response и асинхронной
  эмиссией Decision через настраиваемую задержку (Confirm/Reject/Timeout)
- использует собственный жизненный цикл, чтобы HTTP-контексты вызывающего
  не прерывали эмиссию Decision до истечения DecisionDelay

internal/lkgateway/:
- REST по контракту ESIA Finance V1 (POST/GET/PATCH/list claims)
- admin web UI (/admin/, /admin/claims, /admin/claims/{id}, /admin/status):
  - дашборд со статусом подсистем (postgres, crypto-service UDS,
    nsd-adapter, lk-emulator callback) и счётчиками сделок
  - журнал и карточка заявки с историей FSM, ответом НРД, решением
    принимающей стороны и последним callback'ом
- in-memory SeedStore с 5 тестовыми клиентами и счетами депо
- фоновый consumeDecisions: подписан на mock.Sender.Decisions(),
  применяет ApplyDecision и отправляет PATCH callback в ЛК

internal/lkemulator/:
- имитация ЛК клиента (порт 8083)
- веб-формы: журнал, форма «новая заявка», карточка заявки
- HTTP-клиент к lk-gateway (создание заявки + регистрация callback URL)
- приёмник PATCH callback'ов, локальное хранилище заявок,
  автообновление страницы каждые 3 сек

cmd/lk-gateway/main.go и cmd/lk-emulator/main.go — заглушки заменены
на полные сервисы с graceful shutdown.

Сквозной поток проверен smoke-test'ом: подача заявки через форму
эмулятора → создание сделки в lk-gateway → Send в mock NSD →
эмиссия Decision через 3 сек → ApplyDecision → PATCH callback в ЛК →
эмулятор показывает confirmed. Дашборд lk-gateway: Total=1, Подтверждено=1.

make ci зелёный.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 11:17:11 +03:00
fontvielle e2720c09f7 refactor(nsdxml): заменить собственный кодек CP1251 на golang.org/x/text/encoding/charmap
После добавления NO_PROXY в bash-окружение (proxy.golang.org,
goproxy.cn, *.golang.org, github.com и пр.) штатные модули Go стали
доступны напрямую — zetit-прокси теперь обходится только для
внутренних/публичных хостов, которым нужен внутренний прокси, и
пропускает только нужное.

Заменено:
- internal/nsdxml/codec.go: 90+ строк собственной CP1251-таблицы →
  тонкая обёртка над golang.org/x/text/encoding/charmap.Windows1251
- go.mod: добавлен require golang.org/x/text v0.22.0
- internal/nsdxml/README.md: пометка о причине истории и текущей реализации

Покрытие nsdxml сохранилось, make ci зелёный.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 10:49:39 +03:00
fontvielle 1cf069b55b feat(crypto-service): gRPC-каркас сервиса криптографии (КриптоПро JCP)
- services/crypto-service/proto/crypto.proto — protobuf-контракт VerifyXMLDSig/SignXMLDSig/Health, package ru.zetit.bridgeandjoins.crypto.v1
- services/crypto-service/build.gradle.kts — Gradle Java 21 + protobuf-плагин + shadowJar
- services/crypto-service/src/main/java/.../CryptoServer.java — точка входа на UDS (Netty Epoll)
- services/crypto-service/src/main/java/.../CryptoServiceImpl.java — gRPC-биндинг
- services/crypto-service/src/main/java/.../{Verify,Sign,Health}Handler.java — заглушки операций
- services/crypto-service/src/main/java/.../KeystoreProvider.java — абстракция cryptopro/validata/vipnet/stub
- services/crypto-service/Dockerfile — Liberica JDK 21 → shadowJar → slim
- internal/cryptocli/client.go — Go-клиент по UDS, реализует m2mcore.CryptoVerifier (M1 stub)
- internal/cryptocli/client_test.go — тесты на доступность сокета и ErrNotImplemented
- deploy/docker-compose/docker-compose.yml — добавлен сервис crypto-service с UDS-volume

Реальная криптография КриптоПро JCP подключается после получения
лицензии и jar (положить в services/crypto-service/libs/jcp.jar) и
открытия Maven Central через прокси zetit (для grpc-java/santuario).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 00:58:10 +03:00
fontvielle a8cdeeb838 feat(nsd-adapter): REST-клиент ИШ НРД + маршрутизация типов пакетов
- internal/nsdadapter/igw/client.go: REST-клиент ИШ (SendPackage, GetStatus, ListIncoming) с base64-JSON, ретраями на 5xx, 4xx без ретраев
- internal/nsdadapter/router.go: маршрутизация MessageKind -> PackageType ЭДО (#M2MTR, #M2MTD, #M2MER, SUBBR/SUBER/SUB16, Assets_investment)
- internal/nsdadapter/sender.go: реализация m2mcore.NSDSender (Send/SendDecision) через REST ИШ, сериализация Request/Decision в windows-1251
- internal/nsdadapter/config.go: профили guest/test3/prod × gost/rsa (URL ИШ, канал, контейнер ключа, retry)
- internal/nsdadapter/onyx/onyx.go: скелет резервного канала WS ONYX (ждёт PR-6 crypto-service для подписи)
- cmd/nsd-adapter/main.go: HTTP /healthz + фоновый поллер входящих по типам ЭДО; idle-режим без BJ_NSD_PROFILE

make ci зелёный. Без внешних Go-зависимостей.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 00:55:20 +03:00
fontvielle 9e6e95f431 feat(m2m-core): FSM сделки, репозиторий, идемпотентность по GUID, метрики SLA
- internal/m2mcore/fsm.go: конечный автомат с переходами Draft → Validated → SubmittedToNSD → AwaitingDecision → Confirmed → AwaitingSUB16 → Done, ветки Rejected/TimedOut/ManualApproval
- internal/m2mcore/deal.go: доменная модель Deal с методами Validate/Submit/ReceiveDecision/Timeout/SendToManualApproval/ApproveManually/RejectManually/CompleteSUB16, журнал событий
- internal/m2mcore/uuid.go: генератор UUID v4 без внешних зависимостей (crypto/rand)
- internal/m2mcore/repo.go: порт Repository + MemoryRepository с идемпотентным Create по GUID
- internal/m2mcore/ports.go: порты NSDSender/LKCallbackClient/CryptoVerifier/FansyStore с no-op заглушками для M1
- internal/m2mcore/enrich.go: EnrichRequest — сборка M2MTransferRequest из ClaimInput + Fansy, генерация ReferenceID по каждой ЦБ
- internal/m2mcore/metrics.go: порт Recorder + MemoryRecorder в Prometheus-text формате
- cmd/m2m-core/main.go: HTTP-сервер с /healthz и /metrics, graceful shutdown
- migrations/m2m-core/001__deals.sql: схема для PostgreSQL-Repository (для M2)

Покрытие: 63.1%. make ci зелёный. Без внешних Go-зависимостей (pgx и
prometheus подключим в M2, когда прокси zetit откроет Go-модули).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 00:52:12 +03:00
fontvielle a040f8b07d feat(lk-contract): OpenAPI контракт lk-gateway по ESIA Finance API V1
- docs/lk-contract/v1/openapi.yaml — OpenAPI 3.0: POST/GET/PATCH /api/v1/back_office/claims, схемы Claim/CreateClaimRequest/StatusCallback/ErrorResponse
- docs/lk-contract/v1/examples/claim-request.json — заявка с 3 ЦБ, ИИС T03
- docs/lk-contract/v1/examples/claim-response.json — ответ на создание
- docs/lk-contract/v1/examples/callback-confirmed.json — callback подтверждения
- docs/lk-contract/v1/examples/callback-rejected.json — callback отказа
- docs/lk-contract/v1/examples/error-422.json — ошибка валидации
- docs/lk-contract/v1/changelog.md — v1.0.0

Контракт предлагается команде реального ЛК как точка синхронизации.
В lk-emulator (отдельный PR) контракт реализуется как «как-будто-ЛК»
для проверки сквозного потока.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 00:47:18 +03:00
fontvielle 93bcbca12c feat(fansy-store): DDL принимающей БД + контракт данных для команды Fansy
- docs/fansy-contract/v1/ddl/000__roles.sql: роли fansy_etl, bj_reader, bj_migrator
- docs/fansy-contract/v1/ddl/001__schemas.sql: схемы fansy_staging и fansy с грантами
- docs/fansy-contract/v1/ddl/002__working.sql: рабочая схема (participants, securities, clients, client_documents, iia_contracts, settlement_requisites, depo_accounts, portfolios, etl_errors)
- docs/fansy-contract/v1/ddl/003__staging.sql: staging-зеркало с loaded_at и сниженными ограничениями
- docs/fansy-contract/v1/ddl/004__seed_participants.sql: предзаполнение справочника (НРД, БКС 5406121446, Ренессанс 7709258228, Альфа-Банк 7728168971)
- docs/fansy-contract/v1/data-dictionary.md: семантика каждого поля
- docs/fansy-contract/v1/etl-requirements.md: требования к ETL (UPSERT в staging, SLA свежести по таблицам, обработка ошибок)
- docs/fansy-contract/v1/examples/example-claim.md: SQL-запросы для формирования M2MTransferRequest
- docs/fansy-contract/v1/examples/seed-data.sql: 5 тестовых клиентов + портфели + договоры
- migrations/fansy-store/: рабочие копии миграций

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 00:45:37 +03:00
207 changed files with 24756 additions and 239 deletions
+10
View File
@@ -1,6 +1,7 @@
# Сборки # Сборки
/bin/ /bin/
/dist/ /dist/
!/dist/ish/README.md
*.exe *.exe
*.test *.test
*.out *.out
@@ -58,3 +59,12 @@ test-results/
# macOS # macOS
.DS_Store .DS_Store
# Doc-watcher: бэкапы при переустановке свежих версий
DOC/*.pdf.bak
DOC/*.bak.pdf
# Дистрибутив ИШ НРД (большой, ~120 МБ) — не коммитим в git
/dist/ish/*.deb
/dist/ish/*.SGN
/dist/ish/*.exe
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+8
View File
@@ -0,0 +1,8 @@
Обязательно заметить в сообщениях:
- {GUID_ПЕРЕВОДА} - идентификтор перевода. Всегда меняйте его перед отправкой
- {ВАШ_ДЕПКОД} - ваш депозитарный код
- {ВАШ_ИНН} - ваш инн
- {ВАШ_ДЕПОЗИТАРНЫЙ_СЧЕТ} - депозитарный счет, с которого переводятся бумаги
- {ВАШ_РАЗДЕЛ_ДЕПОЗИТАРНОГО_СЧЕТА} - раздел депозитарного счета, с которого переводятся бумаги
Если не заменить на ваши значение - сообщение не пройдет проверку формата.
@@ -0,0 +1,4 @@
<config>
<name>request.xml</name>
<package>#M2MTR</package>
</config>
@@ -0,0 +1,119 @@
<?xml version="1.0" encoding="windows-1251"?>
<rt:M2MTransferRequest xmlns:m2m="http://nsd.ru/schemas/m2m/types" xmlns:rt="http://nsd.ru/schemas/m2m/request" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://nsd.ru/schemas/m2m/request M2MTransferRequest.xsd">
<rt:Header>
<m2m:GUID>{GUIF_吓信挛睦}</m2m:GUID>
<m2m:CreationTimestamp>2026-04-30T01:01:01(萄)</m2m:CreationTimestamp>
<m2m:SenderCode>{吕豞呐鲜文}</m2m:SenderCode>
<m2m:ReceiverCode>MC0012500000</m2m:ReceiverCode>
<m2m:CostInfo>
<m2m:Yes>
<m2m:Code>{吕豞呐鲜文}</m2m:Code>
</m2m:Yes>
</m2m:CostInfo>
</rt:Header>
<rt:Data>
<m2m:IsM2M>true</m2m:IsM2M>
<m2m:InvestorInformation>
<m2m:LastName>翌腙桧</m2m:LastName>
<m2m:FirstName>丸觇蜞</m2m:FirstName>
<m2m:MiddleName>理囹铍</m2m:MiddleName>
<m2m:IdentityDocument>
<m2m:DocumentType>21</m2m:DocumentType>
<m2m:DocumentSeries>1111</m2m:DocumentSeries>
<m2m:DocumentNumber>111101</m2m:DocumentNumber>
</m2m:IdentityDocument>
</m2m:InvestorInformation>
<m2m:TransferringDepository>
<m2m:INN>{吕豞韧蛚</m2m:INN>
</m2m:TransferringDepository>
<m2m:ReceivingDepository>
<m2m:INN>7722061076</m2m:INN>
</m2m:ReceivingDepository>
<m2m:TransferredSecurities>
<m2m:Security>
<m2m:ReferenceId>M2M2233116169101</m2m:ReferenceId>
<m2m:SecurityCode>RU0007661625</m2m:SecurityCode>
<m2m:SecurityDetails>
<m2m:ISIN>RU0007661625</m2m:ISIN>
</m2m:SecurityDetails>
<m2m:Quantity>
<m2m:Whole>1</m2m:Whole>
</m2m:Quantity>
<m2m:SettlementAccount>
<m2m:SettlementRequisites>
<m2m:INN>7702165310</m2m:INN>
</m2m:SettlementRequisites>
<m2m:SettlementLocation>
<m2m:DeponentCode>{吕豞呐鲜文}</m2m:DeponentCode>
<m2m:AccountId>{吕豞呐衔侨依型凵_炎乓}</m2m:AccountId>
<m2m:SectionId>{吕豞欣悄潘_呐衔侨依型蚊蝊炎乓纝</m2m:SectionId>
</m2m:SettlementLocation>
</m2m:SettlementAccount>
<m2m:IsolationStatus>SGDN</m2m:IsolationStatus>
</m2m:Security>
<m2m:Security>
<m2m:ReferenceId>M2M2233116869102</m2m:ReferenceId>
<m2m:SecurityCode>RU000A0JP5V6</m2m:SecurityCode>
<m2m:SecurityDetails>
<m2m:ISIN>RU000A0JP5V6</m2m:ISIN>
</m2m:SecurityDetails>
<m2m:Quantity>
<m2m:Whole>1</m2m:Whole>
</m2m:Quantity>
<m2m:SettlementAccount>
<m2m:SettlementRequisites>
<m2m:INN>7702165310</m2m:INN>
</m2m:SettlementRequisites>
<m2m:SettlementLocation>
<m2m:DeponentCode>{吕豞呐鲜文}</m2m:DeponentCode>
<m2m:AccountId>{吕豞呐衔侨依型凵_炎乓}</m2m:AccountId>
<m2m:SectionId>{吕豞欣悄潘_呐衔侨依型蚊蝊炎乓纝</m2m:SectionId>
</m2m:SettlementLocation>
</m2m:SettlementAccount>
<m2m:IsolationStatus>SGDN</m2m:IsolationStatus>
</m2m:Security>
<m2m:Security>
<m2m:ReferenceId>M2M2211116869103</m2m:ReferenceId>
<m2m:SecurityCode>RU000A0JPKH7</m2m:SecurityCode>
<m2m:SecurityDetails>
<m2m:ISIN>RU000A0JPKH7</m2m:ISIN>
</m2m:SecurityDetails>
<m2m:Quantity>
<m2m:Whole>1</m2m:Whole>
</m2m:Quantity>
<m2m:SettlementAccount>
<m2m:SettlementRequisites>
<m2m:INN>7831000034</m2m:INN>
</m2m:SettlementRequisites>
<m2m:SettlementLocation>
<m2m:DeponentCode>{吕豞呐鲜文}</m2m:DeponentCode>
<m2m:AccountId>{吕豞呐衔侨依型凵_炎乓}</m2m:AccountId>
<m2m:SectionId>{吕豞欣悄潘_呐衔侨依型蚊蝊炎乓纝</m2m:SectionId>
</m2m:SettlementLocation>
</m2m:SettlementAccount>
<m2m:IsolationStatus>SGDN</m2m:IsolationStatus>
</m2m:Security>
<m2m:Security>
<m2m:ReferenceId>M2M2233116819104</m2m:ReferenceId>
<m2m:SecurityCode>RU000A0JPGP8</m2m:SecurityCode>
<m2m:SecurityDetails>
<m2m:ISIN>RU000A0JPGP8</m2m:ISIN>
</m2m:SecurityDetails>
<m2m:Quantity>
<m2m:Whole>1</m2m:Whole>
</m2m:Quantity>
<m2m:SettlementAccount>
<m2m:SettlementRequisites>
<m2m:INN>7831000034</m2m:INN>
</m2m:SettlementRequisites>
<m2m:SettlementLocation>
<m2m:DeponentCode>{吕豞呐鲜文}</m2m:DeponentCode>
<m2m:AccountId>{吕豞呐衔侨依型凵_炎乓}</m2m:AccountId>
<m2m:SectionId>{吕豞欣悄潘_呐衔侨依型蚊蝊炎乓纝</m2m:SectionId>
</m2m:SettlementLocation>
</m2m:SettlementAccount>
<m2m:IsolationStatus>SGDN</m2m:IsolationStatus>
</m2m:Security>
</m2m:TransferredSecurities>
</rt:Data>
</rt:M2MTransferRequest>
@@ -0,0 +1,4 @@
<config>
<name>request.xml</name>
<package>#M2MTR</package>
</config>
@@ -0,0 +1,119 @@
<?xml version="1.0" encoding="windows-1251"?>
<rt:M2MTransferRequest xmlns:m2m="http://nsd.ru/schemas/m2m/types" xmlns:rt="http://nsd.ru/schemas/m2m/request" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://nsd.ru/schemas/m2m/request M2MTransferRequest.xsd">
<rt:Header>
<m2m:GUID>{GUIF_吓信挛睦}</m2m:GUID>
<m2m:CreationTimestamp>2026-04-30T01:01:01(萄)</m2m:CreationTimestamp>
<m2m:SenderCode>{吕豞呐鲜文}</m2m:SenderCode>
<m2m:ReceiverCode>MC0012500000</m2m:ReceiverCode>
<m2m:CostInfo>
<m2m:Yes>
<m2m:Code>{吕豞呐鲜文}</m2m:Code>
</m2m:Yes>
</m2m:CostInfo>
</rt:Header>
<rt:Data>
<m2m:IsM2M>true</m2m:IsM2M>
<m2m:InvestorInformation>
<m2m:LastName>翌腙桧</m2m:LastName>
<m2m:FirstName>丸觇蜞</m2m:FirstName>
<m2m:MiddleName>理囹铍</m2m:MiddleName>
<m2m:IdentityDocument>
<m2m:DocumentType>21</m2m:DocumentType>
<m2m:DocumentSeries>1111</m2m:DocumentSeries>
<m2m:DocumentNumber>111105</m2m:DocumentNumber>
</m2m:IdentityDocument>
</m2m:InvestorInformation>
<m2m:TransferringDepository>
<m2m:INN>{吕豞韧蛚</m2m:INN>
</m2m:TransferringDepository>
<m2m:ReceivingDepository>
<m2m:INN>7722061076</m2m:INN>
</m2m:ReceivingDepository>
<m2m:TransferredSecurities>
<m2m:Security>
<m2m:ReferenceId>M2M2233116169101</m2m:ReferenceId>
<m2m:SecurityCode>RU0007661625</m2m:SecurityCode>
<m2m:SecurityDetails>
<m2m:ISIN>RU0007661625</m2m:ISIN>
</m2m:SecurityDetails>
<m2m:Quantity>
<m2m:Whole>1</m2m:Whole>
</m2m:Quantity>
<m2m:SettlementAccount>
<m2m:SettlementRequisites>
<m2m:INN>7702165310</m2m:INN>
</m2m:SettlementRequisites>
<m2m:SettlementLocation>
<m2m:DeponentCode>{吕豞呐鲜文}</m2m:DeponentCode>
<m2m:AccountId>{吕豞呐衔侨依型凵_炎乓}</m2m:AccountId>
<m2m:SectionId>{吕豞欣悄潘_呐衔侨依型蚊蝊炎乓纝</m2m:SectionId>
</m2m:SettlementLocation>
</m2m:SettlementAccount>
<m2m:IsolationStatus>SGDN</m2m:IsolationStatus>
</m2m:Security>
<m2m:Security>
<m2m:ReferenceId>M2M2233116869102</m2m:ReferenceId>
<m2m:SecurityCode>RU000A0JP5V6</m2m:SecurityCode>
<m2m:SecurityDetails>
<m2m:ISIN>RU000A0JP5V6</m2m:ISIN>
</m2m:SecurityDetails>
<m2m:Quantity>
<m2m:Whole>1</m2m:Whole>
</m2m:Quantity>
<m2m:SettlementAccount>
<m2m:SettlementRequisites>
<m2m:INN>7702165310</m2m:INN>
</m2m:SettlementRequisites>
<m2m:SettlementLocation>
<m2m:DeponentCode>{吕豞呐鲜文}</m2m:DeponentCode>
<m2m:AccountId>{吕豞呐衔侨依型凵_炎乓}</m2m:AccountId>
<m2m:SectionId>{吕豞欣悄潘_呐衔侨依型蚊蝊炎乓纝</m2m:SectionId>
</m2m:SettlementLocation>
</m2m:SettlementAccount>
<m2m:IsolationStatus>SGDN</m2m:IsolationStatus>
</m2m:Security>
<m2m:Security>
<m2m:ReferenceId>M2M2211116869103</m2m:ReferenceId>
<m2m:SecurityCode>RU000A0JPKH7</m2m:SecurityCode>
<m2m:SecurityDetails>
<m2m:ISIN>RU000A0JPKH7</m2m:ISIN>
</m2m:SecurityDetails>
<m2m:Quantity>
<m2m:Whole>1</m2m:Whole>
</m2m:Quantity>
<m2m:SettlementAccount>
<m2m:SettlementRequisites>
<m2m:INN>7831000034</m2m:INN>
</m2m:SettlementRequisites>
<m2m:SettlementLocation>
<m2m:DeponentCode>{吕豞呐鲜文}</m2m:DeponentCode>
<m2m:AccountId>{吕豞呐衔侨依型凵_炎乓}</m2m:AccountId>
<m2m:SectionId>{吕豞欣悄潘_呐衔侨依型蚊蝊炎乓纝</m2m:SectionId>
</m2m:SettlementLocation>
</m2m:SettlementAccount>
<m2m:IsolationStatus>SGDN</m2m:IsolationStatus>
</m2m:Security>
<m2m:Security>
<m2m:ReferenceId>M2M2233116819104</m2m:ReferenceId>
<m2m:SecurityCode>RU000A0JPGP8</m2m:SecurityCode>
<m2m:SecurityDetails>
<m2m:ISIN>RU000A0JPGP8</m2m:ISIN>
</m2m:SecurityDetails>
<m2m:Quantity>
<m2m:Whole>1</m2m:Whole>
</m2m:Quantity>
<m2m:SettlementAccount>
<m2m:SettlementRequisites>
<m2m:INN>7831000034</m2m:INN>
</m2m:SettlementRequisites>
<m2m:SettlementLocation>
<m2m:DeponentCode>{吕豞呐鲜文}</m2m:DeponentCode>
<m2m:AccountId>{吕豞呐衔侨依型凵_炎乓}</m2m:AccountId>
<m2m:SectionId>{吕豞欣悄潘_呐衔侨依型蚊蝊炎乓纝</m2m:SectionId>
</m2m:SettlementLocation>
</m2m:SettlementAccount>
<m2m:IsolationStatus>SGDN</m2m:IsolationStatus>
</m2m:Security>
</m2m:TransferredSecurities>
</rt:Data>
</rt:M2MTransferRequest>
+8
View File
@@ -0,0 +1,8 @@
Обязательно заметить в сообщениях:
- {GUID_ПЕРЕВОДА} - идентификтор перевода. Всегда меняйте его перед отправкой
- {ВАШ_ДЕПКОД} - ваш депозитарный код
- {ВАШ_ИНН} - ваш инн
- {ВАШ_ДЕПОЗИТАРНЫЙ_СЧЕТ} - депозитарный счет, с которого переводятся бумаги
- {ВАШ_РАЗДЕЛ_ДЕПОЗИТАРНОГО_СЧЕТА} - раздел депозитарного счета, с которого переводятся бумаги
Если не заменить на ваши значение - сообщение не пройдет проверку формата.
@@ -0,0 +1,4 @@
<config>
<name>request.xml</name>
<package>#M2MTR</package>
</config>
@@ -0,0 +1,119 @@
<?xml version="1.0" encoding="windows-1251"?>
<rt:M2MTransferRequest xmlns:m2m="http://nsd.ru/schemas/m2m/types" xmlns:rt="http://nsd.ru/schemas/m2m/request" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://nsd.ru/schemas/m2m/request M2MTransferRequest.xsd">
<rt:Header>
<m2m:GUID>{GUIF_吓信挛睦}</m2m:GUID>
<m2m:CreationTimestamp>2026-04-30T01:01:01(萄)</m2m:CreationTimestamp>
<m2m:SenderCode>{吕豞呐鲜文}</m2m:SenderCode>
<m2m:ReceiverCode>MC0012500000</m2m:ReceiverCode>
<m2m:CostInfo>
<m2m:Yes>
<m2m:Code>{吕豞呐鲜文}</m2m:Code>
</m2m:Yes>
</m2m:CostInfo>
</rt:Header>
<rt:Data>
<m2m:IsM2M>true</m2m:IsM2M>
<m2m:InvestorInformation>
<m2m:LastName>翌腙桧</m2m:LastName>
<m2m:FirstName>丸觇蜞</m2m:FirstName>
<m2m:MiddleName>理囹铍</m2m:MiddleName>
<m2m:IdentityDocument>
<m2m:DocumentType>21</m2m:DocumentType>
<m2m:DocumentSeries>2001</m2m:DocumentSeries>
<m2m:DocumentNumber>111111</m2m:DocumentNumber>
</m2m:IdentityDocument>
</m2m:InvestorInformation>
<m2m:TransferringDepository>
<m2m:INN>{吕豞韧蛚</m2m:INN>
</m2m:TransferringDepository>
<m2m:ReceivingDepository>
<m2m:INN>7722061076</m2m:INN>
</m2m:ReceivingDepository>
<m2m:TransferredSecurities>
<m2m:Security>
<m2m:ReferenceId>M2M2233116169101</m2m:ReferenceId>
<m2m:SecurityCode>RU0007661625</m2m:SecurityCode>
<m2m:SecurityDetails>
<m2m:ISIN>RU0007661625</m2m:ISIN>
</m2m:SecurityDetails>
<m2m:Quantity>
<m2m:Whole>1</m2m:Whole>
</m2m:Quantity>
<m2m:SettlementAccount>
<m2m:SettlementRequisites>
<m2m:INN>7702165310</m2m:INN>
</m2m:SettlementRequisites>
<m2m:SettlementLocation>
<m2m:DeponentCode>{吕豞呐鲜文}</m2m:DeponentCode>
<m2m:AccountId>{吕豞呐衔侨依型凵_炎乓}</m2m:AccountId>
<m2m:SectionId>{吕豞欣悄潘_呐衔侨依型蚊蝊炎乓纝</m2m:SectionId>
</m2m:SettlementLocation>
</m2m:SettlementAccount>
<m2m:IsolationStatus>SGDN</m2m:IsolationStatus>
</m2m:Security>
<m2m:Security>
<m2m:ReferenceId>M2M2233116869102</m2m:ReferenceId>
<m2m:SecurityCode>RU000A0JP5V6</m2m:SecurityCode>
<m2m:SecurityDetails>
<m2m:ISIN>RU000A0JP5V6</m2m:ISIN>
</m2m:SecurityDetails>
<m2m:Quantity>
<m2m:Whole>1</m2m:Whole>
</m2m:Quantity>
<m2m:SettlementAccount>
<m2m:SettlementRequisites>
<m2m:INN>7702165310</m2m:INN>
</m2m:SettlementRequisites>
<m2m:SettlementLocation>
<m2m:DeponentCode>{吕豞呐鲜文}</m2m:DeponentCode>
<m2m:AccountId>{吕豞呐衔侨依型凵_炎乓}</m2m:AccountId>
<m2m:SectionId>{吕豞欣悄潘_呐衔侨依型蚊蝊炎乓纝</m2m:SectionId>
</m2m:SettlementLocation>
</m2m:SettlementAccount>
<m2m:IsolationStatus>SGDN</m2m:IsolationStatus>
</m2m:Security>
<m2m:Security>
<m2m:ReferenceId>M2M2211116869103</m2m:ReferenceId>
<m2m:SecurityCode>RU000A0JPKH7</m2m:SecurityCode>
<m2m:SecurityDetails>
<m2m:ISIN>RU000A0JPKH7</m2m:ISIN>
</m2m:SecurityDetails>
<m2m:Quantity>
<m2m:Whole>1</m2m:Whole>
</m2m:Quantity>
<m2m:SettlementAccount>
<m2m:SettlementRequisites>
<m2m:INN>7831000034</m2m:INN>
</m2m:SettlementRequisites>
<m2m:SettlementLocation>
<m2m:DeponentCode>{吕豞呐鲜文}</m2m:DeponentCode>
<m2m:AccountId>{吕豞呐衔侨依型凵_炎乓}</m2m:AccountId>
<m2m:SectionId>{吕豞欣悄潘_呐衔侨依型蚊蝊炎乓纝</m2m:SectionId>
</m2m:SettlementLocation>
</m2m:SettlementAccount>
<m2m:IsolationStatus>SGDN</m2m:IsolationStatus>
</m2m:Security>
<m2m:Security>
<m2m:ReferenceId>M2M2233116819104</m2m:ReferenceId>
<m2m:SecurityCode>RU000A0JPGP8</m2m:SecurityCode>
<m2m:SecurityDetails>
<m2m:ISIN>RU000A0JPGP8</m2m:ISIN>
</m2m:SecurityDetails>
<m2m:Quantity>
<m2m:Whole>1</m2m:Whole>
</m2m:Quantity>
<m2m:SettlementAccount>
<m2m:SettlementRequisites>
<m2m:INN>7831000034</m2m:INN>
</m2m:SettlementRequisites>
<m2m:SettlementLocation>
<m2m:DeponentCode>{吕豞呐鲜文}</m2m:DeponentCode>
<m2m:AccountId>{吕豞呐衔侨依型凵_炎乓}</m2m:AccountId>
<m2m:SectionId>{吕豞欣悄潘_呐衔侨依型蚊蝊炎乓纝</m2m:SectionId>
</m2m:SettlementLocation>
</m2m:SettlementAccount>
<m2m:IsolationStatus>SGDN</m2m:IsolationStatus>
</m2m:Security>
</m2m:TransferredSecurities>
</rt:Data>
</rt:M2MTransferRequest>
@@ -0,0 +1,4 @@
<config>
<name>request.xml</name>
<package>#M2MTR</package>
</config>
@@ -0,0 +1,119 @@
<?xml version="1.0" encoding="windows-1251"?>
<rt:M2MTransferRequest xmlns:m2m="http://nsd.ru/schemas/m2m/types" xmlns:rt="http://nsd.ru/schemas/m2m/request" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://nsd.ru/schemas/m2m/request M2MTransferRequest.xsd">
<rt:Header>
<m2m:GUID>{GUIF_吓信挛睦}</m2m:GUID>
<m2m:CreationTimestamp>2026-04-30T01:01:01(萄)</m2m:CreationTimestamp>
<m2m:SenderCode>{吕豞呐鲜文}</m2m:SenderCode>
<m2m:ReceiverCode>MC0012500000</m2m:ReceiverCode>
<m2m:CostInfo>
<m2m:Yes>
<m2m:Code>{吕豞呐鲜文}</m2m:Code>
</m2m:Yes>
</m2m:CostInfo>
</rt:Header>
<rt:Data>
<m2m:IsM2M>true</m2m:IsM2M>
<m2m:InvestorInformation>
<m2m:LastName>翌腙桧</m2m:LastName>
<m2m:FirstName>丸觇蜞</m2m:FirstName>
<m2m:MiddleName>理囹铍</m2m:MiddleName>
<m2m:IdentityDocument>
<m2m:DocumentType>21</m2m:DocumentType>
<m2m:DocumentSeries>2001</m2m:DocumentSeries>
<m2m:DocumentNumber>121212</m2m:DocumentNumber>
</m2m:IdentityDocument>
</m2m:InvestorInformation>
<m2m:TransferringDepository>
<m2m:INN>{吕豞韧蛚</m2m:INN>
</m2m:TransferringDepository>
<m2m:ReceivingDepository>
<m2m:INN>7722061076</m2m:INN>
</m2m:ReceivingDepository>
<m2m:TransferredSecurities>
<m2m:Security>
<m2m:ReferenceId>M2M2233116169101</m2m:ReferenceId>
<m2m:SecurityCode>RU0007661625</m2m:SecurityCode>
<m2m:SecurityDetails>
<m2m:ISIN>RU0007661625</m2m:ISIN>
</m2m:SecurityDetails>
<m2m:Quantity>
<m2m:Whole>1</m2m:Whole>
</m2m:Quantity>
<m2m:SettlementAccount>
<m2m:SettlementRequisites>
<m2m:INN>7702165310</m2m:INN>
</m2m:SettlementRequisites>
<m2m:SettlementLocation>
<m2m:DeponentCode>{吕豞呐鲜文}</m2m:DeponentCode>
<m2m:AccountId>{吕豞呐衔侨依型凵_炎乓}</m2m:AccountId>
<m2m:SectionId>{吕豞欣悄潘_呐衔侨依型蚊蝊炎乓纝</m2m:SectionId>
</m2m:SettlementLocation>
</m2m:SettlementAccount>
<m2m:IsolationStatus>SGDN</m2m:IsolationStatus>
</m2m:Security>
<m2m:Security>
<m2m:ReferenceId>M2M2233116869102</m2m:ReferenceId>
<m2m:SecurityCode>RU000A0JP5V6</m2m:SecurityCode>
<m2m:SecurityDetails>
<m2m:ISIN>RU000A0JP5V6</m2m:ISIN>
</m2m:SecurityDetails>
<m2m:Quantity>
<m2m:Whole>1</m2m:Whole>
</m2m:Quantity>
<m2m:SettlementAccount>
<m2m:SettlementRequisites>
<m2m:INN>7702165310</m2m:INN>
</m2m:SettlementRequisites>
<m2m:SettlementLocation>
<m2m:DeponentCode>{吕豞呐鲜文}</m2m:DeponentCode>
<m2m:AccountId>{吕豞呐衔侨依型凵_炎乓}</m2m:AccountId>
<m2m:SectionId>{吕豞欣悄潘_呐衔侨依型蚊蝊炎乓纝</m2m:SectionId>
</m2m:SettlementLocation>
</m2m:SettlementAccount>
<m2m:IsolationStatus>SGDN</m2m:IsolationStatus>
</m2m:Security>
<m2m:Security>
<m2m:ReferenceId>M2M2211116869103</m2m:ReferenceId>
<m2m:SecurityCode>RU000A0JPKH7</m2m:SecurityCode>
<m2m:SecurityDetails>
<m2m:ISIN>RU000A0JPKH7</m2m:ISIN>
</m2m:SecurityDetails>
<m2m:Quantity>
<m2m:Whole>1</m2m:Whole>
</m2m:Quantity>
<m2m:SettlementAccount>
<m2m:SettlementRequisites>
<m2m:INN>7831000034</m2m:INN>
</m2m:SettlementRequisites>
<m2m:SettlementLocation>
<m2m:DeponentCode>{吕豞呐鲜文}</m2m:DeponentCode>
<m2m:AccountId>{吕豞呐衔侨依型凵_炎乓}</m2m:AccountId>
<m2m:SectionId>{吕豞欣悄潘_呐衔侨依型蚊蝊炎乓纝</m2m:SectionId>
</m2m:SettlementLocation>
</m2m:SettlementAccount>
<m2m:IsolationStatus>SGDN</m2m:IsolationStatus>
</m2m:Security>
<m2m:Security>
<m2m:ReferenceId>M2M2233116819104</m2m:ReferenceId>
<m2m:SecurityCode>RU000A0JPGP8</m2m:SecurityCode>
<m2m:SecurityDetails>
<m2m:ISIN>RU000A0JPGP8</m2m:ISIN>
</m2m:SecurityDetails>
<m2m:Quantity>
<m2m:Whole>1</m2m:Whole>
</m2m:Quantity>
<m2m:SettlementAccount>
<m2m:SettlementRequisites>
<m2m:INN>7831000034</m2m:INN>
</m2m:SettlementRequisites>
<m2m:SettlementLocation>
<m2m:DeponentCode>{吕豞呐鲜文}</m2m:DeponentCode>
<m2m:AccountId>{吕豞呐衔侨依型凵_炎乓}</m2m:AccountId>
<m2m:SectionId>{吕豞欣悄潘_呐衔侨依型蚊蝊炎乓纝</m2m:SectionId>
</m2m:SettlementLocation>
</m2m:SettlementAccount>
<m2m:IsolationStatus>SGDN</m2m:IsolationStatus>
</m2m:Security>
</m2m:TransferredSecurities>
</rt:Data>
</rt:M2MTransferRequest>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -5
View File
@@ -19,11 +19,8 @@ help:
build: build:
@mkdir -p bin @mkdir -p bin
$(GO) build -o bin/lk-gateway ./cmd/lk-gateway $(GO) build -o bin/bj-server ./cmd/bj-server
$(GO) build -o bin/m2m-core ./cmd/m2m-core $(GO) build -o bin/lk-emulator ./cmd/lk-emulator
$(GO) build -o bin/nsd-adapter ./cmd/nsd-adapter
$(GO) build -o bin/lk-emulator ./cmd/lk-emulator
$(GO) build -o bin/notify ./cmd/notify
test: test:
$(GO) test ./... -race -count=1 $(GO) test ./... -race -count=1
+254
View File
@@ -0,0 +1,254 @@
# Bridge-and-Join-s — отчёт о ходе работ
**Дата:** 14.05.2026 (3-я редакция за день — скачан дистрибутив ИШ + полная документация ИШ)
**Контур:** дев-стенд РЕД ОС 8 (10.10.10.22), bj-server на :8080, lk-emulator на :8083
**Целевая интеграция:** сервис MOEX МОСТ (M2M) через НКО АО НРД
---
## Готовность по областям
| Область | Готовность | Статус |
|---|---:|---|
| Контракты и модели M2M (XSD → Go) | **100%** | ✅ Готово |
| Журнал сделок (PostgreSQL + in-memory) | **100%** | ✅ Готово |
| Бизнес-логика FSM (стейт-машина заявок) | **100%** | ✅ Готово |
| Веб-интерфейс администратора | **95%** | ✅ Готово |
| Мастер настройки (wizard) для оператора | **100%** | ✅ Готово |
| Установка и конфигурация КриптоПро CSP через UI | **100%** | ✅ Готово |
| Авто-загрузка сертификатов УЦ (мониторинг + ежесуточное обновление) | **100%** | ✅ Готово |
| Контейнеры КриптоПро с флешки (импорт в HDIMAGE) | **80%** | ⚠ Без UI-импорта сертификата из контейнера |
| Лента новостей + мониторинг сайта НРД (doc-watcher) | **100%** | ✅ Готово |
| Эмулятор робота-автотеста НРД (внутренний mock) | **90%** | ⚠ Сценарий 3333 — частично |
| Реальное подключение к роботу на TEST3 НРД | **30%** | ⚠ REST-клиент ИШ готов, ждём сам ИШ + сертификат |
| REST-клиент ИШ НРД (по DOC/instr-ish-rest-api.pdf) | **100%** | ✅ POST file, GET status, GET list, GET package, упаковщик ZIP, 10/10 тестов |
| Дистрибутив ИШ НРД и полная документация | **100%** | ✅ Скачаны: `igate_100.0-765_amd64.deb` (117 МБ) + 6 PDF |
| Установка ИШ на наш стенд | **30%** | ⚠ Скрипты установки готовы, ждём Astra Linux ВМ от инфра-команды |
| Авто-установщик «одной командой» | **100%** | ✅ `curl … \| sudo bash` на свежей Astra/Debian/Ubuntu — bj-server + БД + ИШ через 5-10 мин |
| Получение СКЗИ «Валидата CSP» для Linux | **0%** | ⏳ Запрос в soed@nsd.ru / pki@moex.com — см. блокер #2 |
| Сертификат УЦ Московской Биржи для подписи | **0%** | ⏳ Не получен — см. блокер #3 |
| Подключение реального ЛК ESIA Finance | **20%** | ⚠ Эмулятор lk-emulator работает, реальный URL не указан |
| Контракт с Fansy (ETL) | **30%** | ⚠ Контракт документирован, ETL не реализован стороной Fansy |
| Уведомления (e-mail, мессенджеры) | **0%** | ⏳ M3-M4 |
| Тесты, CI/CD | **40%** | ⚠ Unit-тесты компонентов, нет E2E против реального НРД |
**Общая готовность системы:** **≈ 75%** (по объёму функциональности)
**Готовность к интеграционному тесту с роботом:** **≈ 88%** (зависит только от внешних факторов: Astra Linux ВМ, Валидата CSP, сертификат УЦ МБ — на нашей стороне установщик готов)
---
## Что сделано (28 коммитов)
### Архитектура и ядро (M1)
- Реализованы Go-модели всех M2M-сообщений (M2MTransferRequest, Response, Decision, History, Movement) с валидацией.
- Стейт-машина обработки заявок (FSM): `draft → validated → submitted_to_nsd → awaiting_decision → confirmed/rejected/timed_out → done` + ветка ручного разбора.
- Один исполняемый бинарник `bj-server` (вместо запланированных микросервисов) — рассчитано на нагрузку до 1000 сделок/день.
- Хранилище: PostgreSQL 16 в контейнере podman (один клик «Поднять автоматически» в UI), миграции для двух схем — `fansy.*` (данные от Fansy) и `m2m_core.*` (журнал сделок). Fallback на in-memory для дева.
### Криптография
- Переход с КриптоПро JCP (~82 000₽, Java) на КриптоПро CSP (~30-50 000₽, нативный) — экономия лицензии в ~2 раза. Подходит для нашего объёма (100-1000 сделок/день).
- Go-клиент к СКЗИ через стандартный PKCS#11 интерфейс (`internal/cryptocli`). Один клиент работает с КриптоПро CSP, Рутокен ЭЦП 2.0, Валидата, ViPNet — меняется только путь к .so модулю.
- UI-кнопка «Загрузить дистрибутив КриптоПро»: загружаешь tar/tgz/rpm, система сама распаковывает и устанавливает через `sudo rpm -Uvh`.
- Активация лицензии через UI (`cpconfig -license -set` под капотом).
- Импорт сертификатов (.pfx/.p12 с PIN, .cer/.crt без) в хранилища `uMy`/`mroot`/`uRoot` через `certmgr -inst`.
- **Авто-обнаружение контейнеров КриптоПро на USB-флешках** (формат `name.000`): сканирует `/run/media/$USER/`, `/media/`, `/mnt/`; кнопка «Скопировать в локальное хранилище» переносит контейнер в `/var/opt/cprocsp/keys/$USER/`.
- **Авто-обнаружение сертификатов на Рутокене ЭЦП 2.0** — список заполняется автоматически после подключения токена в USB.
### Сертификаты УЦ
- Авто-загрузка корневых и подписных сертификатов УЦ по списку URL: SHA-256 дедуп, импорт в `mroot`/`uRoot` через `certmgr`.
- Ежесуточная фоновая горутина обновляет сертификаты, в ленте новостей появляется уведомление «Обновлён сертификат УЦ: <CN>».
- За 14 дней до истечения сертификата — отдельное предупреждение в ленте.
### Веб-интерфейс администратора (порт 8080)
6 разделов меню:
- **Дашборд** — счётчики сделок, состояние подсистем, последние заявки, блок «Новости» сверху.
- **Мастер настройки** — пошаговая настройка (5 шагов) с прогресс-баром, подсказки «?» и «Где взять?» рядом с каждым полем.
- **Настройка** — расширенные параметры всех подсистем.
- **Заявки** — журнал + карточка заявки с историей FSM.
- **Статус системы** — health-check всех подсистем.
- **Инструкции** — 5 help-страниц: БД, API ЛК, КриптоПро, Внешние системы, **Тестирование с роботом**.
- **Новости** — лента событий + кнопка «Проверить обновления документации НРД сейчас».
Все надписи на русском.
### Мониторинг НРД (doc-watcher)
- Раз в сутки скачивает страницы с сайта НРД, парсит ссылки на PDF, обновляет файлы в `DOC/` (старые версии переименовываются в `.YYYY-MM-DD.pdf.bak` для аудита).
- Каждое обновление публикуется как новость в ленту.
- Уже скачаны три свежие инструкции от 12.05.2026:
- `instruktsiya-po-testirovaniyu-s-robotom.pdf` — инструкция по роботу-автотесту
- `instruktsiya-...-fizicheskim-litsom-samomu-sebe.pdf` — обмен при self-transfer
- `servis-most-m2m.pdf` — обзор сервиса
### Робот-автотест MOEX МОСТ
- Реализован **внутренний робот-эмулятор**: bj-server понимает код робота `MC0012500000` и 4 тестовых сценария (1111/2001/2002/3333) через DocumentSeries.
- Это позволяет проверить нашу логику обработки ответов **до того**, как у нас появится реальный ИШ + сертификат + доступ к TEST3.
- Help-страница `/admin/help/robot` с полной документацией (коды ошибок M2M01-M2M09, тестовые наборы депозитариев, схема обмена).
- Когда подключим реальный ИШ — переключение прозрачное, те же заявки пойдут на реальный TEST3.
### REST-клиент ИШ НРД (готов на нашей стороне)
- По свежей спецификации НРД (`DOC/instr-ish-rest-api.pdf`) реализован Go-клиент в `internal/nsdadapter/igw`:
- `POST /api/package/{channel}/file` — отправка ZIP (Type=archive, File=base64)
- `GET /api/package/status/{id}` — статус: NEW / SENT / ERROR
- `GET /api/package?channel=&type=M2MTD&...` — список входящих от НРД
- `GET /api/package/{id}` — скачать ZIP пакета (поддерживает и raw ZIP, и base64-в-JSON)
- Упаковщик (`pack.go`): `M2MTransferRequest → ZIP (XML + config.xml)` по разделу 2.3 инструкции
- Распаковщик: ZIP → DocXML + winf.xml + .sgn (отсоединённая подпись НРД)
- Парсеры: `ParseDecision`, `ParseResponse` — из XML в Go-структуры через `nsdxml.Unmarshal`
- Покрыто тестами: 10/10 PASS (httptest + zip round-trip + 4xx без ретраев + retry на 5xx)
- Готов к переключению: как только получим живой ИШ от НРД, нужно только указать BaseURL и Channel в `/admin/setup` — код уже всё умеет
### Авто-установщик «одной командой» (14.05.2026, поздний вечер)
Главная цель — оператор без знания Linux должен поднять систему **одной командой**:
```bash
curl -sSL https://git.zetit.ru/zuevav/Bridge-and-Join-s/raw/main/deploy/astra/install.sh | sudo bash
```
Через 5-10 минут на свежей Astra Linux / Debian / Ubuntu ВМ работает веб-админка на :8080. Установщик `deploy/astra/install.sh`:
1. **Определяет ОС** — Astra SE/CE, Debian, Ubuntu (с предупреждениями для несовместимых)
2. **Ставит зависимости через apt** — podman, postgresql-client, git, curl
3. **Скачивает Go 1.24+** с go.dev (~70 МБ)
4. **Создаёт пользователя bj** и каталоги /opt/bj /var/lib/bj /var/log/bj
5. **Клонирует репо** в /opt/bj/src
6. **Собирает bj-server** через go build
7. **Поднимает PostgreSQL 16** в podman-контейнере, накатывает миграции
8. **Кладёт systemd unit** с безопасными ограничениями (NoNewPrivileges, ProtectSystem=strict, ReadWritePaths)
9. **Скачивает ИШ НРД** (~120 МБ) с `old.nsd.ru` и пытается установить через `dpkg -i`
10. **Печатает понятную сводку** с URL'ами и списком того, что осталось руками
Дополнительные скрипты в `deploy/astra/`:
- **`install-validata.sh`** — установка СКЗИ Валидата CSP когда придёт от НРД. Если дистрибутива ещё нет — печатает готовый текст письма для запроса в `soed@nsd.ru`
- **`install-ish.sh`** — ручная установка ИШ из локального .deb (если автоскачивание не сработало)
- **`healthcheck.sh`** — цветной отчёт о работоспособности всех 8 компонентов (ОС, пользователь, systemd, HTTP, PostgreSQL, Валидата, ИШ, сетевые порты)
- **`import-data.sh`** — опциональный экспорт БД и настроек со старой ВМ (если переезжаем с действующего стенда)
- **`README.md`** — TL;DR + полный путь от чистой ВМ до прохождения теста с роботом MOEX МОСТ (10 этапов, оценочно 2-3 недели от старта)
После запуска `install.sh` остаётся 3 ручных шага (НРД и УЦ МБ — без них никак): запрос Валидаты, получение сертификата УЦ МБ, заявка на TEST3.
### Дистрибутив ИШ и полная документация (получены 14.05.2026)
По наводке от заказчика на странице `https://www.nsd.ru/workflow/system/programs/web-service/` найдены и скачаны все официальные материалы:
- **Дистрибутив ИШ Linux**: `dist/ish/igate_100.0-765_amd64.deb` (117 МБ, для Astra Linux)
- **Электронная подпись к дистрибутиву**: `dist/ish/igate_95.0-716_amd64.SGN`
- **DOC/ruk_install_ish_2025_11_10.pdf** (4.7 МБ) — Руководство по установке ИШ от 10.11.2025. Главное:
- Поддерживаемые ОС: Windows 10/Server, **Astra Linux SE 1.6/1.7** (РЕД ОС не упомянута)
- СКЗИ: **«Валидата CSP» + АПК «Валидата Клиент L»** (НЕ КриптоПро)
- БД: SQLite или PostgreSQL (PostgreSQL обязателен для REST API)
- Только ГОСТ-криптография под Linux (RSA — только Windows)
- Только сертификаты от УЦ МБ
- **DOC/ruk_pol_ish.pdf** (3.5 МБ) — Руководство пользователя ИШ
- **DOC/QA_ish.pdf** (2.5 МБ) — Q&A
- **DOC/test-case_ish.pdf** (1.3 МБ) — Тест-кейсы для проверки работоспособности ИШ
- **DOC/instr_int_sh_01072025.pdf** (0.4 МБ) — Инструкция по созданию заявки на тестирование
- **DOC/web_service_nrd_standard_soap_rest.pdf** (2.2 МБ) — Техрекомендации Web-сервиса ONYX
`dist/ish/.deb` не коммитится в git (большой), но `dist/ish/README.md` содержит все ссылки на повторное скачивание.
### Безопасность и надёжность
- Баннер «🟡 РЕЖИМ ЭМУЛЯЦИИ» отображается на каждой странице админки пока не настроен ИШ или СКЗИ — оператор не сможет случайно принять mock-результат за реальный.
- Контекстная навигация после действий (после POST возврат на ту же страницу, не в /admin/setup).
- HTTP-клиенты для запросов на nsd.ru/cryptopro.ru идут напрямую (игнорируют корпоративный прокси), браузерный User-Agent для обхода антибот-фильтров.
- systemd-unit `deploy/systemd/bj-server.service` с `Environment=LD_LIBRARY_PATH=/opt/cprocsp/lib/amd64`, ProtectSystem=strict, NoNewPrivileges, и т.п.
---
## Что в процессе и в очереди
### Внешние блокеры (без них не двинемся к реальному НРД)
1. **Astra Linux ВМ для ИШ** ⭐ новый блокер
- Дистрибутив ИШ — только `igate_100.0-765_amd64.deb` (под Astra Linux SE 1.6/1.7). РЕД ОС официально не поддерживается, RPM-версии нет.
- Что нужно: поднять отдельную Astra Linux ВМ (10.10.10.23?) или попробовать запустить ИШ в Docker-контейнере с Astra-образом.
- Альтернативы: Windows 10/Server (есть .exe-дистрибутив, но это шаг назад от Linux).
- Срок: зависит от инфра-команды; ~1 день поднять ВМ + ~30 мин установить ИШ.
2. **СКЗИ «Валидата CSP» + АПК «Валидата Клиент L»** ⭐ новый блокер
- ИШ требует именно Валидату, **НЕ КриптоПро CSP** (у нас стоит КриптоПро, придётся ставить параллельно или вместо).
- Где взять: только по запросу — `soed@nsd.ru` (НРД) или `pki@moex.com` (МБ). Временная лицензия выдаётся.
- Что нужно: отправить письмо с реквизитами организации и обоснованием (подключение к MOEX МОСТ M2M).
- Срок: ~1-3 дня на ответ НРД.
3. **Сертификат подписи УЦ Московской Биржи** (ca.moex.com)
- Нужен для подписи отправляемых сообщений. Кладётся в Справочник сертификатов АПК Валидата Клиент L, экспортируется в системное хранилище.
- Что нужно: оформить заявку в УЦ МБ от организации, получить сертификат + приватный ключ.
- Срок: зависит от УЦ МБ.
4. **Заявка на тестирование в TEST3 НРД**
- Форма: `https://www.nsd.ru/workflow/zayavka-na-testirovanie/`
- Инструкция: `DOC/instr_int_sh_01072025.pdf`
- Получаем код депонента-тестера и доступ к контурам GUEST/TEST3.
5. **Сертификаты УЦ НРД** (для проверки квитанций)
- Где взять: `https://www.nsd.ru/workflow/system/cryptography/` — сейчас отдаёт 404 на нашем дев-стенде (вероятно перенесено в ЛК НРД депонента).
- В коде уже есть форма «Авто-загрузка сертификатов УЦ» в `/admin/setup` — как только получим прямые URL .cer, добавим их.
6. **Окно техработ TEST3: 18.05.2026 — 22.05.2026**
- Полевое тестирование в этот период невозможно. Реальные прогоны — до 18-го или после 22-го мая.
7. **Доступ к API реального ЛК ESIA Finance**
- Сейчас bj-server работает с встроенным эмулятором `lk-emulator` на :8083.
- Что нужно: URL продакшен/тест ЛК, Basic-auth учётка.
### Внутренние задачи (можем делать параллельно)
| Задача | Приоритет | Эффект |
|---|---|---|
| Завершить сценарий 3333 робота — приёмная сторона bj-server (входящие M2MTransferRequest) | средний | Полное покрытие тестов с роботом |
| UI для импорта сертификата из контейнера КриптоПро (после копирования с флешки) | низкий | Сейчас делается вручную через certmgr |
| Уведомления: SMTP, Yandex Messenger, Telegram (плагины через единый интерфейс Notifier) | средний (M3) | Операторам — критичные события в мессенджеры |
| Расширение тестов: unit + интеграционные с mock-роботом, нагрузочные | низкий | Уверенность перед прод |
| Документация для команды Fansy (ETL): передача контракта, согласование SLA, прописывание IP в pg_hba.conf | средний | Запуск ETL-потока |
---
## Что после получения ИШ и сертификата (план первичного тестирования с роботом)
1. **День 0** (получили дистрибутив ИШ + сертификат + руководство)
- Поставить ИШ на dev-ВМ.
- Положить сертификат в ИШ-хранилище.
- В bj-server: `/admin/setup` → ИШ профиль `test3-gost`, URL ИШ.
2. **День 1** (smoke-тест)
- Отправить через bj-server заявку с ReceiverCode = `MC0012500000`, DocumentSeries = `2001`, DocumentNumber = `111111` → ожидаем «принять все бумаги» от робота.
- Проверить: квитанция от НРД, Decision от робота, callback в `lk-emulator`, статус в журнале → `confirmed`.
3. **День 2-3** (полное покрытие сценариев)
- 1111 (отказ M2M01..M2M09) — все коды ошибок.
- 2001 / 2002 — все депозитарии, все варианты частичного приёма.
- 3333 — встречный перевод (когда доделаем приёмную сторону).
4. **День 4-5** (нагрузка)
- 50-100 одновременных заявок, проверка очередей, БД, корректность статусов.
5. **День 6** (живой контрагент)
- Согласовать с любым подключённым к НРД депозитарием тестовый обмен.
- Это последний шаг перед присоединением к Правилам ЭДО НРД (продакшен).
**Реалистичный срок от получения ИШ до готовности к продакшену: 2-3 недели** (включая обкатку, fix багов, документацию).
---
## Стоимостная сводка
| Статья | Сумма (руб) | Статус |
|---|---:|---|
| Лицензия КриптоПро CSP (сервер) | ~30 000-50 000 | Демо 3 мес. — активна |
| Лицензия КриптоПро CSP (рабочее место оператора, опц.) | ~2 000-3 000 | Не куплена |
| Рутокен ЭЦП 2.0 для оператора (железо, опц.) | ~3 000-5 000 | Не куплено |
| Сертификат УЦ МБ для организации | по тарифам УЦ | Не получен |
| **Сэкономлено** против КриптоПро JCP | ~30 000-50 000 | (отказ от Java-стека) |
---
## Ключевые архитектурные решения
1. **Один бинарник вместо микросервисов** — рассчитано на наш объём (100-1000 сделок/день). Упрощает деплой, отладку, мониторинг. Все компоненты в одном процессе с понятными границами пакетов (`internal/m2mcore`, `internal/nsdadapter`, `internal/lkgateway`, ...).
2. **PKCS#11 как единый интерфейс к СКЗИ** — позволяет менять провайдер (CSP/Рутокен/Валидата) без изменения кода bj-server.
3. **Двух-уровневая БД** (`fansy.*` для входных данных, `m2m_core.*` для журнала сделок) — позволяет команде Fansy писать в свою схему без знания о нашем pipeline.
4. **Mock-робот внутри bj-server** — даёт возможность работать без живого НРД для значительной части интеграционного тестирования.
5. **«Дружественный» UI** — установка/настройка не требует SSH-доступа: всё через веб (КриптоПро, лицензии, сертификаты, контейнеры с флешки).
---
**Готов к интеграционному тестированию с роботом на TEST3:** да, как только будет ИШ + сертификат.
**Готов к продакшену:** ориентировочно через 3-4 недели после получения всех внешних компонентов.
— Команда разработки Bridge-and-Join-s
+34
View File
@@ -0,0 +1,34 @@
# Логотип сервиса MOEX МОСТ
Официальные ассеты и правила размещения логотипа сервиса MOEX МОСТ
(НКО АО НРД). Требование для участников сервиса при интеграции M2M
в свои интерфейсы (личный кабинет, веб-кабинет).
Источники (10.06.2025):
- Руководство: https://www.nsd.ru/media/docs/rukovodstvo-o-razmeschenii-logotipa.pdf
- Ассеты: https://www.nsd.ru/media/docs/dep/logo-moex-most.zip
- Вопросы: moexmost-logo@nsd.ru
## Файлы
- `main/moex-most.{svg,png,pdf,jpg}` — основная (полноцветная, красная) версия.
- White и Add версии в исходном архиве НРД отсутствовали (только метаданные) —
при необходимости запросить у moexmost-logo@nsd.ru.
## Правила размещения (из руководства)
- **Наименование всегда полное: «MOEX МОСТ»** (два слова, оба обязательны).
- **Три версии**: Main (основная, на светлом фоне), White (на тёмном/цветном),
Add (дополнительная). Выбор — по фону.
- **Охранное поле**: минимальный отступ до соседних элементов = 0.5×высоты лого.
- **Минимальная высота** логосимвола — 20px. Для очень маленьких носителей
(иконка в моб. приложении) — только логосимвол на плашке (приоритет — красная,
радиус скругления 6px для плашки 24×24).
- **Web-интеграция — единообразие с окружением**: если соседние сервисы показаны
полноцветными лого — MOEX МОСТ тоже полноцветный (main); если линейными
иконками — MOEX МОСТ линейной синей иконкой с полным наименованием.
## Где применяем у нас
- Веб-кабинет клиента (отдельный проект) — обязательно, как участник сервиса.
- Личный кабинет / admin bj-server — где показываем канал перевода M2M.
Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+114
View File
@@ -0,0 +1,114 @@
// Command bj-artifactory — простой сервер раздачи релизов и обновлений.
//
// Раскладка хранилища (--root), один подкаталог на канал:
//
// <root>/stable/manifest.json — подписанный SignedManifest
// <root>/stable/bj-server — артефакты, перечисленные в манифесте
// <root>/stable/crypto-service.jar
// <root>/beta/manifest.json
// ...
//
// HTTP API (потребляет bj-server auto-update и install.sh):
//
// GET /v1/<channel>/manifest.json — манифест канала
// GET /v1/<channel>/files/<name> — артефакт по имени
// GET /healthz — проверка живости
//
// Подпись манифеста делает bj-release; здесь только статическая раздача.
// Перед прод-выкаткой ставится за TLS-reverse-proxy (nginx, см.
// deploy/artifactory/nginx.conf).
package main
import (
"flag"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
func main() {
addr := flag.String("addr", ":8090", "адрес прослушивания")
root := flag.String("root", "./releases", "корень хранилища релизов")
flag.Parse()
abs, err := filepath.Abs(*root)
if err != nil {
log.Fatalf("bj-artifactory: root: %v", err)
}
if _, err := os.Stat(abs); err != nil {
log.Fatalf("bj-artifactory: каталог релизов %s недоступен: %v", abs, err)
}
srv := &server{root: abs}
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) })
mux.HandleFunc("/v1/", srv.handleV1)
log.Printf("bj-artifactory: раздаю %s на %s", abs, *addr)
httpSrv := &http.Server{Addr: *addr, Handler: logging(mux), ReadHeaderTimeout: 10 * time.Second}
log.Fatal(httpSrv.ListenAndServe())
}
type server struct{ root string }
// handleV1 разбирает /v1/<channel>/manifest.json и /v1/<channel>/files/<name>.
func (s *server) handleV1(w http.ResponseWriter, r *http.Request) {
rest := strings.TrimPrefix(r.URL.Path, "/v1/")
parts := strings.SplitN(rest, "/", 3)
if len(parts) < 2 {
http.NotFound(w, r)
return
}
channel := parts[0]
if !safeName(channel) {
http.Error(w, "bad channel", http.StatusBadRequest)
return
}
switch {
case len(parts) == 2 && parts[1] == "manifest.json":
s.serveFile(w, r, filepath.Join(s.root, channel, "manifest.json"), "application/json")
case len(parts) == 3 && parts[1] == "files":
name := parts[2]
if !safeName(name) {
http.Error(w, "bad name", http.StatusBadRequest)
return
}
s.serveFile(w, r, filepath.Join(s.root, channel, name), "application/octet-stream")
default:
http.NotFound(w, r)
}
}
func (s *server) serveFile(w http.ResponseWriter, r *http.Request, path, ctype string) {
f, err := os.Open(path)
if err != nil {
http.NotFound(w, r)
return
}
defer f.Close()
fi, err := f.Stat()
if err != nil || fi.IsDir() {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", ctype)
http.ServeContent(w, r, filepath.Base(path), fi.ModTime(), f)
}
// safeName запрещает обход каталогов (.., /, пустые).
func safeName(s string) bool {
if s == "" || s == "." || s == ".." {
return false
}
return !strings.ContainsAny(s, "/\\") && !strings.Contains(s, "..")
}
func logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r)
log.Printf("%s %s %s", r.RemoteAddr, r.Method, r.URL.Path)
})
}
+111
View File
@@ -0,0 +1,111 @@
// Package main — bj-installer.
//
// Web-инсталлятор для bj-server: на машине клиента после установки
// Debian/Astra поднимает локальный HTTP на 127.0.0.1:8181, проводит
// через 5-страничный wizard (welcome → precheck → config → install → done)
// и за кадром выполняет 20+ шагов установки Валидаты + bj-server + ИШ.
//
// Прогресс шагов прилетает в UI через Server-Sent Events. Каждый шаг
// идемпотентен — можно повторно запускать инсталлятор на уже настроенной
// машине, он пропустит то, что сделано.
//
// Запуск: 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 (ИШ НРД)
package main
import (
"context"
"flag"
"fmt"
"log"
"net/http"
"os"
"os/exec"
"os/signal"
"runtime"
"syscall"
"time"
)
const banner = `
======================================================================
bj-installer — мастер установки Bridge-and-Join-s
======================================================================
`
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()
if os.Geteuid() != 0 {
fmt.Fprintln(os.Stderr, "Установщик должен быть запущен от root (sudo).")
os.Exit(1)
}
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)
}
// tryOpenBrowser — без фанатизма. Если xdg-open/sensible-browser есть и
// $DISPLAY поднят (xrdp, Fly DE) — откроем. Иначе пользователь увидит URL
// в выводе и перейдёт сам с другого компа (типичный сценарий headless).
func tryOpenBrowser(url string) {
if os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == "" {
return
}
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
}
}
}
if bin == "" {
return
}
_ = exec.Command(bin, url).Start()
}
+132
View File
@@ -0,0 +1,132 @@
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
@@ -0,0 +1,129 @@
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
@@ -0,0 +1,86 @@
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
@@ -0,0 +1,140 @@
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
@@ -0,0 +1,84 @@
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
@@ -0,0 +1,448 @@
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
@@ -0,0 +1,168 @@
// Минимальный клиент 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
@@ -0,0 +1,110 @@
<!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
@@ -0,0 +1,179 @@
: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;
}
+96
View File
@@ -0,0 +1,96 @@
// Command bj-license-server — онлайн-сервис учёта и отзыва лицензий.
//
// Базовая модель лицензирования офлайновая: bj-server проверяет подпись и
// срок сам. Этот сервер нужен для:
// - реестра выданных лицензий (учёт);
// - ОТЗЫВА (revocation) до окончания срока;
// - проверки клиентом «не отозвана ли» (опциональный online-чек).
//
// Хранилище — JSON-файл со списком отозванных ID (для каркаса; в проде —
// PostgreSQL). API:
//
// GET /v1/check?id=<license-id> → {"revoked":bool}
// GET /healthz
//
// Управление отзывом — правкой файла revoked.json (или будущим admin API).
package main
import (
"encoding/json"
"flag"
"log"
"net/http"
"os"
"sync"
"time"
)
type store struct {
mu sync.RWMutex
path string
revoked map[string]bool
}
func newStore(path string) *store {
s := &store{path: path, revoked: map[string]bool{}}
s.load()
return s
}
func (s *store) load() {
s.mu.Lock()
defer s.mu.Unlock()
b, err := os.ReadFile(s.path)
if err != nil {
return // файла нет — пустой список
}
var ids []string
if err := json.Unmarshal(b, &ids); err != nil {
log.Printf("license-server: разбор %s: %v", s.path, err)
return
}
s.revoked = map[string]bool{}
for _, id := range ids {
s.revoked[id] = true
}
log.Printf("license-server: загружено отозванных лицензий: %d", len(s.revoked))
}
func (s *store) isRevoked(id string) bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.revoked[id]
}
func main() {
addr := flag.String("addr", ":8091", "адрес прослушивания")
file := flag.String("revoked", "./revoked.json", "JSON-файл со списком отозванных license ID")
flag.Parse()
st := newStore(*file)
// Перечитываем файл отзывов раз в минуту (горячее применение).
go func() {
t := time.NewTicker(time.Minute)
defer t.Stop()
for range t.C {
st.load()
}
}()
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) })
mux.HandleFunc("/v1/check", func(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id")
if id == "" {
http.Error(w, "id required", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]bool{"revoked": st.isRevoked(id)})
})
log.Printf("license-server: слушаю %s, отзывы из %s", *addr, *file)
srv := &http.Server{Addr: *addr, Handler: mux, ReadHeaderTimeout: 10 * time.Second}
log.Fatal(srv.ListenAndServe())
}
+192
View File
@@ -0,0 +1,192 @@
// 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)
}
+169
View File
@@ -0,0 +1,169 @@
// Command bj-release — инструмент издателя: генерация ключей подписи,
// сборка манифеста релиза из каталога артефактов и его подпись Ed25519.
//
// Использование:
//
// bj-release keygen -out ./keys/signing
// → создаёт signing.priv (base64 seed) и signing.pub (base64 pubkey)
//
// bj-release build -dir ./dist -version 1.2.0 -channel stable \
// -key ./keys/signing.priv -keyid main -out ./dist/manifest.json
// → хеширует все файлы в ./dist, собирает Manifest, подписывает,
// пишет SignedManifest в manifest.json
//
// Манифест подписывается целиком; клиент (bj-server auto-update) проверяет
// подпись зашитым публичным ключом ДО доверия версиям/хешам.
package main
import (
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/release"
)
func main() {
if len(os.Args) < 2 {
usage()
}
switch os.Args[1] {
case "keygen":
keygen(os.Args[2:])
case "build":
build(os.Args[2:])
default:
usage()
}
}
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>]")
os.Exit(2)
}
func keygen(args []string) {
fs := flag.NewFlagSet("keygen", flag.ExitOnError)
out := fs.String("out", "signing", "префикс файлов ключей (создаст <out>.priv и <out>.pub)")
_ = fs.Parse(args)
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
fatal("keygen: %v", err)
}
seed := priv.Seed()
if err := os.WriteFile(*out+".priv", []byte(base64.StdEncoding.EncodeToString(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("Публичный ключ: %s.pub\n", *out)
fmt.Printf("Публичный ключ (зашить в bj-server):\n %s\n", base64.StdEncoding.EncodeToString(pub))
}
func build(args []string) {
fs := flag.NewFlagSet("build", flag.ExitOnError)
dir := fs.String("dir", "./dist", "каталог с артефактами")
version := fs.String("version", "", "версия релиза, напр. 1.2.0")
channel := fs.String("channel", "stable", "канал: stable|beta")
keyPath := fs.String("key", "", "путь к приватному ключу (base64 seed)")
keyID := fs.String("keyid", "main", "идентификатор ключа")
out := fs.String("out", "", "путь для записи manifest.json (по умолчанию <dir>/manifest.json)")
notes := fs.String("notes", "", "заметки к релизу")
_ = fs.Parse(args)
if *version == "" || *keyPath == "" {
fatal("build: требуются -version и -key")
}
if *out == "" {
*out = filepath.Join(*dir, "manifest.json")
}
priv, err := release.LoadPrivateKey(*keyPath)
if err != nil {
fatal("load key: %v", err)
}
// Имена артефактов, которые издаём (логическое имя → ставить +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,
}
entries, err := os.ReadDir(*dir)
if err != nil {
fatal("read dir: %v", err)
}
var arts []release.Artifact
for _, e := range entries {
if e.IsDir() || e.Name() == "manifest.json" {
continue
}
full := filepath.Join(*dir, e.Name())
sha, size, err := release.HashFile(full)
if err != nil {
fatal("hash %s: %v", e.Name(), err)
}
exec, ok := known[e.Name()]
if !ok {
// неизвестный файл — включаем, +x по расширению
exec = strings.HasSuffix(e.Name(), ".sh")
}
arts = append(arts, release.Artifact{
Name: e.Name(),
File: e.Name(),
Version: *version,
SHA256: sha,
Size: size,
Exec: exec,
})
}
sort.Slice(arts, func(i, j int) bool { return arts[i].Name < arts[j].Name })
if len(arts) == 0 {
fatal("build: в каталоге %s нет артефактов", *dir)
}
m := &release.Manifest{
Schema: release.CurrentSchema,
Version: *version,
Channel: *channel,
ReleasedAt: time.Now().UTC(),
Notes: *notes,
Artifacts: arts,
}
sm, err := release.Sign(m, priv, *keyID)
if err != nil {
fatal("sign: %v", err)
}
b, err := json.MarshalIndent(sm, "", " ")
if err != nil {
fatal("marshal: %v", err)
}
if err := os.WriteFile(*out, b, 0o644); err != nil {
fatal("write manifest: %v", err)
}
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])
}
}
func fatal(format string, a ...any) {
fmt.Fprintf(os.Stderr, "bj-release: "+format+"\n", a...)
os.Exit(1)
}
+147
View File
@@ -0,0 +1,147 @@
// Package main — единый сервис bj-server.
//
// Объединяет в одном процессе: lk-gateway (REST API ЛК + admin web UI),
// m2m-core (FSM сделки, репозиторий, эмиссия и потребление Decision),
// nsd-adapter (REST к ИШ НРД и опрос входящих, когда профиль настроен),
// notify (заглушка отправки уведомлений). lk-emulator живёт отдельным
// бинарником как QA-инструмент.
//
// Архитектура подсказана объёмом 100-1000 сделок/день: для такого
// потока избыточно держать 5 отдельных процессов и микросервисную
// шину. Один Go-бинарник проще деплоить, проще наблюдать и
// масштабировать вертикально, а компоненты внутри по-прежнему
// разделены пакетами internal/<...>.
package main
import (
"context"
"log"
"os"
"os/signal"
"syscall"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/lkgateway"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/igw"
)
const serviceName = "bj-server"
func main() {
addr := getenv("BJ_HTTP_ADDR", ":8080")
defaultSender := m2m.DeponentCode(getenv("BJ_M2M_SENDER", "MC0079200000"))
defaultReceiver := m2m.DeponentCode(getenv("BJ_M2M_RECEIVER", "MC0010300000"))
setupPath := os.Getenv("BJ_SETUP_PATH")
cfg := lkgateway.ServerConfig{
Addr: addr,
DefaultSender: defaultSender,
DefaultReceiver: defaultReceiver,
SetupPath: setupPath,
// CheckOptions не задаём — server.go использует свой снапшот-based
// вариант, который читает актуальные значения из setup.json
// (DSN, crypto-сокет, URL ИШ, профиль), а не из ENV. Так проверки
// статуса совпадают с тем, что реально настроено в UI.
}
srv, err := lkgateway.NewServer(cfg)
if err != nil {
log.Fatalf("%s: NewServer: %v", serviceName, err)
}
if cb := os.Getenv("BJ_LK_CALLBACK_URL"); cb != "" {
srv.SetCallbackURL(cb)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
// Опционально — поллер входящих пакетов ИШ НРД. Запускается если
// BJ_NSD_PROFILE задан (после установки реального ИШ через UI этот
// блок будет тянуть Decisions из настоящего НРД и применять их через
// lkgateway.Service.ApplyDecision).
if profileName := os.Getenv("BJ_NSD_PROFILE"); profileName != "" {
go runNSDPoller(ctx, profileName)
}
// notify-демон: пока заглушка, в M3-M4 будет рассылать события
// (e-mail, Yandex Messenger, Telegram, WS-push в admin-ui).
go runNotifyWorker(ctx)
log.Printf("%s: запуск, HTTP %s", serviceName, addr)
runErr := srv.Run(ctx)
stop()
if runErr != nil {
log.Printf("%s: %v", serviceName, runErr)
os.Exit(1)
}
}
// runNSDPoller — фоновый поллер входящих пакетов ИШ НРД.
func runNSDPoller(ctx context.Context, profileName string) {
profile, err := nsdadapter.LookupProfile(profileName)
if err != nil {
log.Printf("%s: NSD poller: %v (доступные профили: %v)", serviceName, err, nsdadapter.AvailableProfiles())
return
}
interval := 30 * time.Second
if v := os.Getenv("BJ_NSD_POLL_INTERVAL"); v != "" {
if d, err := time.ParseDuration(v); err == nil {
interval = d
}
}
client := igw.NewClient(profile.IGWBaseURL, igw.WithRetry(profile.RetryMax, profile.RetryBackoff))
log.Printf("%s: NSD poller: профиль %s, канал %s, ИШ %s, интервал %s",
serviceName, profile.Name, profile.Channel, profile.IGWBaseURL, interval)
t := time.NewTicker(interval)
defer t.Stop()
since := time.Now().UTC().Add(-time.Hour)
for {
select {
case <-ctx.Done():
return
case <-t.C:
for _, kind := range nsdadapter.IncomingPackageKinds() {
pkgs, err := client.ListIncoming(ctx, igw.ListFilter{
Channel: profile.Channel,
Date: since,
Type: string(kind),
})
if err != nil {
log.Printf("%s: NSD poller ListIncoming(%s, %s): %v", serviceName, profile.Channel, kind, err)
continue
}
for _, p := range pkgs {
log.Printf("%s: NSD входящий пакет id=%d (%s) типа %s, канал %s, state %s",
serviceName, p.ID, p.Name, p.Type, p.Channel, p.State)
// TODO(M3): GetPackage(p.ID) → unpack ZIP → парсить XML →
// передавать в lkgateway.Service.ApplyDecision
}
}
since = time.Now().UTC()
}
}
}
// runNotifyWorker — заглушка демона уведомлений.
func runNotifyWorker(ctx context.Context) {
t := time.NewTicker(time.Minute)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
// На M3-M4 здесь будет: вытащить очередь событий из БД,
// разослать по настроенным каналам (e-mail, мессенджер).
}
}
}
func getenv(k, def string) string {
if v, ok := os.LookupEnv(k); ok && v != "" {
return v
}
return def
}
+39 -11
View File
@@ -1,20 +1,48 @@
// Package main — сервис lk-emulator. Эмулятор ЛК клиента (ESIA Finance API V1) // Package main — сервис lk-emulator. Имитация ЛК клиента (ESIA Finance
// на время, пока реальный ЛК не готов. Позволяет «как будто загрузить» // API V1) на время, пока реальный ЛК не готов. Веб-форма «новая заявка»,
// заявление через веб-форму и запустить полный путь обработки документа. // журнал моих заявок, приёмник callback'ов от lk-gateway.
// //
// Когда реальный ЛК подключится эмулятор остаётся как тестовый инструмент // Когда реальный ЛК подключится, эмулятор остаётся как тестовый
// в QA-окружении. // инструмент в QA-окружении: даёт сквозной сценарий без зависимости от
// // внешней стороны.
// На этапе M1 — заглушка.
package main package main
import ( import (
"fmt" "context"
"log"
"os" "os"
"os/signal"
"syscall"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/lkemulator"
) )
const serviceName = "lk-emulator"
func main() { func main() {
fmt.Fprintf(os.Stdout, "%s: запуск (заглушка M1)\n", serviceName) addr := getenv("BJ_HTTP_ADDR", ":8083")
gw := getenv("BJ_GATEWAY_URL", "http://127.0.0.1:8080")
self := getenv("BJ_EMULATOR_PUBLIC_URL", "http://127.0.0.1:8083")
srv, err := lkemulator.NewServer(lkemulator.ServerConfig{
Addr: addr,
GatewayURL: gw,
SelfPublicURL: self,
})
if err != nil {
log.Fatalf("lk-emulator: NewServer: %v", err)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
runErr := srv.Run(ctx)
stop()
if runErr != nil {
log.Printf("lk-emulator: %v", runErr)
os.Exit(1)
}
}
func getenv(k, def string) string {
if v, ok := os.LookupEnv(k); ok && v != "" {
return v
}
return def
} }
-17
View File
@@ -1,17 +0,0 @@
// Package main — сервис lk-gateway. Принимает заявления от ЛК клиента
// (платформа ESIA Finance, /api/v1/back_office/...), валидирует их подпись,
// передаёт в m2m-core, отдаёт callback-статусы обратно в ЛК.
//
// На этапе M1 — заглушка. Реализация контракта — M2.
package main
import (
"fmt"
"os"
)
const serviceName = "lk-gateway"
func main() {
fmt.Fprintf(os.Stdout, "%s: запуск (заглушка M1)\n", serviceName)
}
-17
View File
@@ -1,17 +0,0 @@
// Package main — сервис m2m-core. Бизнес-логика и FSM сделки M2M-перевода:
// идемпотентность по GUID, валидация по XSD, метрики SLA, ветка ручного
// согласования и таймаут-отказа MOST.
//
// На этапе M1 — заглушка.
package main
import (
"fmt"
"os"
)
const serviceName = "m2m-core"
func main() {
fmt.Fprintf(os.Stdout, "%s: запуск (заглушка M1)\n", serviceName)
}
-19
View File
@@ -1,19 +0,0 @@
// Package main — сервис notify. Отправка уведомлений по нескольким каналам:
// e-mail (SMTP), Yandex Messenger (Yandex 360), WebSocket-push в admin-ui,
// плюс расширяемая модель провайдеров-плагинов (smtp, yandex360, telegram,
// mattermost, webhook) под единый интерфейс Notifier — для тиражирования
// продукта другим компаниям.
//
// На этапе M1 — заглушка.
package main
import (
"fmt"
"os"
)
const serviceName = "notify"
func main() {
fmt.Fprintf(os.Stdout, "%s: запуск (заглушка M1)\n", serviceName)
}
-22
View File
@@ -1,22 +0,0 @@
// Package main — сервис nsd-adapter. Транспорт к НРД:
// - Интеграционный шлюз через REST API (основной канал, ИШ сам подписывает);
// - Web-сервис ONYX напрямую (резерв);
// - Файловый шлюз / обменные папки ИШ (fallback).
//
// Сериализация и парсинг XML по схемам M2MSchemas в windows-1251,
// маршрутизация по типам пакетов (#M2MTR / #M2MTD / #M2MER / SUBBR / SUBER /
// SUB16 / Справки / квитанции ЭДО).
//
// На этапе M1 — заглушка.
package main
import (
"fmt"
"os"
)
const serviceName = "nsd-adapter"
func main() {
fmt.Fprintf(os.Stdout, "%s: запуск (заглушка M1)\n", serviceName)
}
+79
View File
@@ -0,0 +1,79 @@
# Артефактория Bridge-and-Join-s
Сервис раздачи релизов и обновлений (#18). Клиенты (bj-server auto-update,
install.sh) скачивают **подписанный** манифест канала, проверяют подпись
зашитым публичным ключом и обновляют компоненты.
## Компоненты
- `internal/release` — формат манифеста + подпись Ed25519 (sign/verify, хеши).
- `cmd/bj-release` — инструмент издателя: генерация ключей, сборка и подпись манифеста.
- `cmd/bj-artifactory` — HTTP-сервер раздачи манифеста и артефактов.
- `deploy/artifactory/` — nginx (TLS) + systemd unit.
## Модель доверия
Один корневой Ed25519-ключ. Приватный (`signing.priv`) держит издатель в
секрете (НЕ в git). Публичный (`signing.pub`) зашивается в bj-server и в
install.sh. Манифест подписывается целиком — клиент проверяет подпись ДО
доверия версиям и хешам артефактов, затем сверяет sha256 каждого скачанного
файла с манифестом.
## Релизный цикл (издатель)
```bash
# 1. Однократно — сгенерировать ключи подписи (приватный хранить в секрете!)
bj-release keygen -out ./keys/signing
# → keys/signing.priv (секрет), keys/signing.pub
# Публичный base64 из вывода — зашить в bj-server (auto-update, #20)
# 2. Собрать артефакты релиза в каталог
mkdir -p dist/stable
cp bj-server crypto-service.jar dist/stable/
cp deploy/linux/install-validata.sh deploy/ish/configure-ish.sql dist/stable/
# 3. Собрать + подписать манифест
bj-release build -dir dist/stable -version 1.0.0 -channel stable \
-key keys/signing.priv -keyid main -out dist/stable/manifest.json \
-notes "Первый релиз"
# 4. Выложить каталог в хранилище артефактории
rsync -a dist/stable/ server:/var/lib/bj-artifactory/releases/stable/
```
## Сервер
```bash
bj-artifactory --addr 127.0.0.1:8090 --root /var/lib/bj-artifactory/releases
```
Раскладка хранилища:
```
releases/
stable/
manifest.json ← подписанный SignedManifest
bj-server
crypto-service.jar
install-validata.sh
configure-ish.sql
beta/
manifest.json
...
```
## HTTP API
| Метод | Путь | Ответ |
|---|---|---|
| GET | `/v1/<channel>/manifest.json` | подписанный манифест канала |
| GET | `/v1/<channel>/files/<name>` | артефакт по имени |
| GET | `/healthz` | `ok` |
За TLS-reverse-proxy (`nginx.conf`). Прод: `updates.example.com` → 127.0.0.1:8090.
## Дальше
- **#19 License-сервер** — манифест/обновления гейтятся годовым ключом.
- **#20 Auto-update в bj-server** — горутина: качает манифест канала, проверяет
подпись, сравнивает версии, atomic-replace бинарей, systemctl restart.
+21
View File
@@ -0,0 +1,21 @@
[Unit]
Description=Bridge-and-Join-s — Artifactory (раздача релизов и обновлений)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=bj-updates
Group=bj-updates
ExecStart=/opt/bj-artifactory/bj-artifactory --addr 127.0.0.1:8090 --root /var/lib/bj-artifactory/releases
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadOnlyPaths=/var/lib/bj-artifactory
PrivateTmp=true
[Install]
WantedBy=multi-user.target
+44
View File
@@ -0,0 +1,44 @@
# nginx.conf — reverse-proxy для bj-artifactory с TLS.
# Раздаёт релизы и обновления bj-server по HTTPS.
#
# Установка: положить в /etc/nginx/sites-available/, заменить server_name
# и пути сертификатов, выпустить TLS через certbot, symlink в sites-enabled.
#
# updates.example.com → bj-artifactory на 127.0.0.1:8090
#
# bj-artifactory запускается как systemd-сервис (см. artifactory.service).
server {
listen 80;
server_name updates.example.com;
# Редирект на HTTPS (кроме ACME-челленджа certbot).
location /.well-known/acme-challenge/ { root /var/www/certbot; }
location / { return 301 https://$host$request_uri; }
}
server {
listen 443 ssl http2;
server_name updates.example.com;
ssl_certificate /etc/letsencrypt/live/updates.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/updates.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
# Манифесты маленькие — не кэшируем агрессивно (быстрое распространение релизов).
location /v1/ {
proxy_pass http://127.0.0.1:8090;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# Артефакты могут быть крупными (jar ~20МБ) — без буферизации тела.
proxy_buffering off;
client_max_body_size 0;
}
location /healthz {
proxy_pass http://127.0.0.1:8090;
}
# Всё остальное — 404.
location / { return 404; }
}
+145
View File
@@ -0,0 +1,145 @@
# Bridge-and-Join-s — установка одной командой
## TL;DR — на свежей Astra Linux / Debian / Ubuntu ВМ
```bash
curl -sSL https://git.zetit.ru/zuevav/Bridge-and-Join-s/raw/main/deploy/astra/install.sh | sudo bash
```
Через **5-10 минут** будет работать веб-админка на `http://<ip>:8080/admin/`.
Установщик сам:
- Определит ОС (Astra SE/CE, Debian, Ubuntu)
- Поставит зависимости (apt: podman, postgresql-client, git)
- Скачает и установит Go 1.24+
- Создаст системного пользователя `bj` и каталоги
- Склонирует репозиторий в `/opt/bj/src`
- Соберёт `bj-server` из исходников
- Поднимет PostgreSQL 16 в podman-контейнере и накатит миграции
- Поставит systemd unit и запустит сервис
- Скачает дистрибутив ИШ НРД (~120 МБ) и попытается установить через `dpkg`
После завершения скрипта тебе печатается понятная сводка с URL'ами и
списком того, что осталось сделать руками.
---
## Какая нужна ВМ
| Параметр | Минимум | Рекомендуется |
|---|---|---|
| ОС | Debian 11+ / Astra CE 1.8 / Astra SE 1.6+ / Ubuntu 22.04+ | **Astra Linux SE 1.7** (для прод) |
| CPU | 2 ядра | 4 ядра |
| RAM | 2 ГБ | 4 ГБ |
| Диск | 20 ГБ | 50 ГБ SSD |
| Сеть | прямой выход в интернет | + статический IP |
**Что я понимаю про лицензии Astra Linux:**
- **Astra SE** — платная (~2-5 тыс. ₽/лицензия), сертифицирована ФСТЭК/ФСБ → нужна для прода с гос-требованиями
- **Astra CE** — бесплатная, без сертификации, тот же базовый дистрибутив → можно использовать для дева и тестов, а для прода докупить SE
- **Debian 12** — полностью бесплатный, технически на 95% совместим с Astra (один и тот же базовый дистрибутив), ИШ скорее всего тоже взлетит, но НРД официально не поддерживает
---
## Скрипты в этом каталоге
| Файл | Когда запускать | Что делает |
|---|---|---|
| **`install.sh`** | сразу после поднятия ВМ | Главный скрипт. Делает всё одной командой |
| **`install-validata.sh`** | когда придёт Валидата от НРД | Установка СКЗИ Валидата CSP |
| **`install-ish.sh`** | если `install.sh` не установил ИШ автоматически | Ручная установка ИШ из локального .deb |
| **`healthcheck.sh`** | для проверки состояния | Цветной отчёт о работоспособности всех компонентов |
| **`import-data.sh`** | (опционально) если переносишь с другой ВМ | Экспорт БД и настроек со старой ВМ для импорта на новую |
---
## Что произойдёт ПОСЛЕ автоматической установки
`install.sh` дойдёт до точки, где **bj-server работает, но в режиме эмуляции** — потому что Валидата и сертификат УЦ МБ автоматически получить нельзя. В админке сверху будет жёлтая плашка «РЕЖИМ ЭМУЛЯЦИИ». Это ожидаемо.
### Что нужно сделать пользователю руками
#### 1. Запросить Валидата CSP в НРД (1 письмо)
Email: `soed@nsd.ru` или `pki@moex.com`. Текст подскажет сам скрипт `install-validata.sh` — есть шаблон. Срок ответа НРД — 1-3 дня.
Когда придёт .deb пакет:
```bash
sudo bash /opt/bj/src/deploy/astra/install-validata.sh /path/to/validata.deb
```
#### 2. Получить сертификат УЦ Московской Биржи
`https://ca.moex.com/` — оформить заявку от организации. Срок — зависит от УЦ.
#### 3. Подать заявку на тестирование в TEST3 НРД
`https://www.nsd.ru/workflow/zayavka-na-testirovanie/` — получить код депонента-тестера.
#### 4. Когда всё пришло — настроить ИШ через его GUI
По `DOC/ruk_install_ish_2025_11_10.pdf` (раздел 10):
- Указать БД PostgreSQL (DSN уже в `/var/lib/bj/.bj/setup.json`)
- Создать канал WSL с URL `https://gost.nsd.ru/onyxt3/WslService` (TEST3)
- Импортировать сертификат УЦ МБ из системного хранилища
- Запустить ИШ как сервис: `sudo systemctl enable --now igate`
#### 5. Привязать bj-server к ИШ
`http://<ip>:8080/admin/setup` → раздел «ИШ НРД»:
- URL ИШ: `http://localhost:8090` (порт REST API ИШ)
- Имя канала: то что задал в ИШ на шаге 4
После этого жёлтая плашка «РЕЖИМ ЭМУЛЯЦИИ» исчезнет — сообщения пойдут в реальный НРД.
---
## Параметры установки
`install.sh` принимает флаги:
```bash
sudo bash install.sh --bind=:8080 --skip-ish --yes
```
| Флаг | По умолчанию | Что делает |
|---|---|---|
| `--bind=:8080` | `:8080` | На каком адресе/порту слушать |
| `--branch=main` | `main` | Из какой ветки репо собирать |
| `--skip-ish` | (выкл) | Не скачивать дистрибутив ИШ (если стоят жёсткие ограничения по интернету) |
| `--yes` / `-y` | (выкл) | Не задавать вопросов, отвечать «да» автоматически |
Также через переменные окружения: `REPO_URL`, `BRANCH`, `BIND_ADDR`, `ISH_DEB_URL`, `NON_INTERACTIVE`.
---
## Если что-то сломалось
| Симптом | Решение |
|---|---|
| `bj-server.service не active` | `journalctl -u bj-server -n 50` |
| HTTP 200 не отвечает | проверь что :8080 открыт; `ss -tlnp \| grep 8080` |
| Миграции не накатились | `podman exec bj-postgres psql -U bj -l` и `\dt fansy.*` |
| ИШ не скачался | положи `igate_100.0-765_amd64.deb` в `/opt/bj/src/dist/ish/` и перезапусти `install.sh` |
| Валидата не установлена | это **нормально** на старте — заказывай у НРД, потом `install-validata.sh` |
| Не определилась ОС | поддерживаются: Astra, Debian, Ubuntu. Для других — открой issue |
Health-check всё сразу:
```bash
sudo bash /opt/bj/src/deploy/astra/healthcheck.sh
```
---
## Полный путь от чистой ВМ до прохождения теста с роботом MOEX МОСТ
| Этап | Что делается | Срок |
|---|---|---|
| 1. Поднять Astra Linux ВМ | у инфра-команды | 1 день |
| 2. Запустить `install.sh` | автоматически | 5-10 мин |
| 3. Запросить Валидату в НРД | письмо в `soed@nsd.ru` | 1-3 дня ожидания |
| 4. Получить сертификат УЦ МБ | заявка в `ca.moex.com` | 1-2 недели ожидания |
| 5. Подать заявку на TEST3 | форма на сайте НРД | 2-5 дней |
| 6. Установить Валидату | `install-validata.sh` | 5 мин |
| 7. Импортировать сертификат | GUI Валидаты, экспорт в системное хранилище | 15 мин |
| 8. Настроить ИШ | GUI ИШ, создать канал WSL | 30 мин |
| 9. Привязать bj-server к ИШ | `/admin/setup` через UI | 5 мин |
| 10. Прогнать тест с роботом | `/admin/setup` → кнопка | 1 мин |
**Итог: 2-3 недели от старта до зелёного теста с роботом MOEX МОСТ.** На нашей стороне всё уже готово — задержки только во внешних запросах.
+114
View File
@@ -0,0 +1,114 @@
#!/bin/bash
# healthcheck.sh — проверка готовности bj-server после установки на Astra Linux.
# Запускается на самой Astra Linux ВМ, печатает зелёные/жёлтые/красные галочки.
set -uo pipefail
ok() { echo -e " \033[1;32m✓\033[0m $*"; }
warn() { echo -e " \033[1;33m⚠\033[0m $*"; }
fail() { echo -e " \033[1;31m✗\033[0m $*"; }
echo "================================================================"
echo " Bridge-and-Join-s — проверка состояния"
echo "================================================================"
# 1. ОС
echo
echo "[1] Операционная система"
if [ -r /etc/astra_version ]; then
ok "Astra Linux: $(cat /etc/astra_version)"
else
warn "Не Astra Linux — ИШ может не запуститься"
fi
# 2. Пользователь bj
echo
echo "[2] Пользователь и каталоги"
id bj >/dev/null 2>&1 && ok "пользователь bj существует" || fail "пользователь bj не создан"
[ -d /opt/bj ] && ok "/opt/bj существует" || fail "/opt/bj не найден"
[ -x /opt/bj/bj-server ] && ok "/opt/bj/bj-server исполняемый" || fail "/opt/bj/bj-server отсутствует"
[ -d /var/lib/bj/.bj ] && ok "/var/lib/bj/.bj существует" || warn "/var/lib/bj/.bj не создан"
# 3. systemd
echo
echo "[3] systemd сервис"
if systemctl is-enabled --quiet bj-server 2>/dev/null; then
ok "bj-server.service enabled"
else
warn "bj-server.service не enabled"
fi
if systemctl is-active --quiet bj-server 2>/dev/null; then
ok "bj-server.service active"
else
fail "bj-server.service не active — systemctl status bj-server"
fi
# 4. HTTP
echo
echo "[4] HTTP-эндпоинты"
HTTP_OK=0
for path in / /admin/ /admin/wizard /admin/help/architecture; do
code=$(curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:8080$path" 2>/dev/null || echo "—")
if [ "$code" = "200" ] || [ "$code" = "303" ]; then
ok "GET $path$code"
HTTP_OK=$((HTTP_OK+1))
else
fail "GET $path$code"
fi
done
# 5. PostgreSQL
echo
echo "[5] PostgreSQL"
if command -v podman >/dev/null 2>&1; then
if podman ps --format '{{.Names}}' 2>/dev/null | grep -qx bj-postgres; then
ok "контейнер bj-postgres работает"
else
warn "контейнер bj-postgres не запущен"
fi
else
warn "podman не установлен"
fi
if pg_isready -h 127.0.0.1 -p 5432 -U bj >/dev/null 2>&1; then
ok "PostgreSQL отвечает на :5432"
else
warn "PostgreSQL :5432 недоступен"
fi
# 6. Валидата
echo
echo "[6] СКЗИ Валидата (для ИШ)"
VAL_FOUND=0
for path in /opt/Validata /opt/validata-csp /opt/Validata-CSP; do
[ -d "$path" ] && { ok "найдена в $path"; VAL_FOUND=1; break; }
done
[ "$VAL_FOUND" = 0 ] && warn "не установлена (запроси у НРД soed@nsd.ru, потом sudo bash deploy/astra/install-validata.sh)"
# 7. ИШ
echo
echo "[7] Интеграционный шлюз (ИШ)"
if command -v igate >/dev/null 2>&1; then
ok "igate в PATH: $(which igate)"
elif [ -x /opt/igate/igate ]; then
ok "igate в /opt/igate/"
else
warn "ИШ не установлен (sudo bash deploy/astra/install-ish.sh)"
fi
# 8. Сетевые порты
echo
echo "[8] Сетевые порты"
if command -v ss >/dev/null 2>&1; then
PORTS=$(ss -tlnp 2>/dev/null | awk 'NR>1{print $4}')
echo "$PORTS" | grep -q ':8080$' && ok ":8080 (bj-server) слушает" || fail ":8080 не слушает"
echo "$PORTS" | grep -q ':5432$' && ok ":5432 (postgres) слушает" || warn ":5432 не слушает"
echo "$PORTS" | grep -q ':8090$' && ok ":8090 (предполагаемый ИШ) слушает" || warn ":8090 (ИШ) не слушает"
fi
# Итог
echo
echo "================================================================"
echo " Готово. Подробнее:"
echo " journalctl -u bj-server -f"
echo " http://$(hostname -I | awk '{print $1}'):8080/admin/"
echo "================================================================"
+134
View File
@@ -0,0 +1,134 @@
#!/bin/bash
# migrate-from-redos.sh — экспорт состояния со старой ВМ (РЕД ОС 10.10.10.22)
# для переноса на новую Astra Linux ВМ.
#
# Запускать на СТАРОЙ ВМ (РЕД ОС). Создаст архив /tmp/bj-migration-YYYY-MM-DD.tar.gz
# с:
# - дампом БД (pg_dump на оба схема: fansy.* и m2m_core.*)
# - содержимым ~bj/.bj/setup.json (или ~/.bj/setup.json для dev)
# - логами /var/log/bj/ (за последние 7 дней)
# - списком установленных пакетов (для справки)
#
# Архив надо перенести на новую ВМ (scp/rsync), там распаковать и натравить
# на install-astra.sh с флагом --import=/path/to/archive.tar.gz (TODO).
set -euo pipefail
OUT_DIR="/tmp/bj-migration-$(date +%Y-%m-%d-%H%M)"
OUT_TAR="${OUT_DIR}.tar.gz"
mkdir -p "$OUT_DIR"
log() { echo -e "\033[1;34m[migrate-export]\033[0m $*"; }
warn() { echo -e "\033[1;33m[migrate-export WARN]\033[0m $*" >&2; }
# ---- 1. Дамп БД ----
log "1/5: дамп PostgreSQL"
DSN="${BJ_PG_DSN:-postgres://bj:bj_dev@127.0.0.1:5432/bj?sslmode=disable}"
if podman ps --format '{{.Names}}' 2>/dev/null | grep -qx bj-postgres; then
log " через podman exec bj-postgres"
podman exec bj-postgres pg_dump -U bj -d bj --clean --if-exists > "$OUT_DIR/bj.sql" \
|| warn " pg_dump упал — проверь контейнер bj-postgres"
else
log " напрямую pg_dump"
pg_dump "$DSN" --clean --if-exists > "$OUT_DIR/bj.sql" \
|| warn " pg_dump упал — проверь DSN"
fi
[ -f "$OUT_DIR/bj.sql" ] && log " размер дампа: $(du -h "$OUT_DIR/bj.sql" | awk '{print $1}')"
# ---- 2. Конфигурация ----
log "2/5: ~/.bj/setup.json"
for candidate in /var/lib/bj/.bj/setup.json ~/.bj/setup.json /root/.bj/setup.json; do
if [ -f "$candidate" ]; then
cp "$candidate" "$OUT_DIR/setup.json"
log " скопировано из $candidate"
break
fi
done
# ---- 3. Логи ----
log "3/5: логи за 7 дней"
mkdir -p "$OUT_DIR/logs"
if [ -d /var/log/bj ]; then
find /var/log/bj -type f -mtime -7 -exec cp {} "$OUT_DIR/logs/" \; 2>/dev/null || true
fi
journalctl -u bj-server --since "7 days ago" --no-pager > "$OUT_DIR/logs/journal.log" 2>/dev/null || true
# ---- 4. Пакеты, версии (для справки) ----
log "4/5: метаинформация"
{
echo "=== ОС ==="
cat /etc/os-release 2>/dev/null || echo "no os-release"
echo
echo "=== uname ==="
uname -a
echo
echo "=== Установленные RPM (только наши пакеты) ==="
rpm -qa 2>/dev/null | grep -iE "cprocsp|crypto|postgresql|podman|go" || true
echo
echo "=== Версия bj-server ==="
/opt/bj/bj-server --version 2>/dev/null || echo "не определена"
echo
echo "=== Дата создания дампа ==="
date
} > "$OUT_DIR/meta.txt"
# ---- 5. README ----
cat > "$OUT_DIR/README.md" <<EOF
# Миграция bj-server с РЕД ОС на Astra Linux
Дамп создан: $(date)
Источник: $(hostname) ($(hostname -I | awk '{print $1}'))
## Файлы
- \`bj.sql\` — дамп PostgreSQL базы bj (схемы fansy + m2m_core)
- \`setup.json\` — настройки bj-server (DSN, IGW URL, и т.п.)
- \`logs/\` — последние логи bj-server
- \`meta.txt\` — версии ОС, пакетов
## Восстановление на Astra Linux
\`\`\`bash
# 1. На новой ВМ — ставим bj-server
sudo bash deploy/astra/install.sh
# 2. Восстанавливаем БД
podman exec -i bj-postgres psql -U bj -d bj < bj.sql
# 3. Восстанавливаем настройки
sudo cp setup.json /var/lib/bj/.bj/setup.json
sudo chown bj:bj /var/lib/bj/.bj/setup.json
sudo chmod 0600 /var/lib/bj/.bj/setup.json
# 4. Перезапуск
sudo systemctl restart bj-server
\`\`\`
## Что НЕ переносится автоматически
- Сертификаты КриптоПро CSP (\`/var/opt/cprocsp/keys/$USER/\`)
— это нормально, на Astra Linux будет другая СКЗИ (Валидата CSP)
- \`/opt/cprocsp/\` (КриптоПро CSP)
— на Astra нужна Валидата вместо КриптоПро
EOF
# ---- Финал ----
log "5/5: создание архива $OUT_TAR"
tar -czf "$OUT_TAR" -C "$(dirname "$OUT_DIR")" "$(basename "$OUT_DIR")"
rm -rf "$OUT_DIR"
echo
echo "================================================================"
echo " Экспорт готов"
echo "================================================================"
echo " Архив: $OUT_TAR"
echo " Размер: $(du -h "$OUT_TAR" | awk '{print $1}')"
echo
echo " Перенести на новую Astra Linux ВМ:"
echo " scp $OUT_TAR user@<astra-ip>:/tmp/"
echo
echo " На Astra Linux распаковать и читать README.md:"
echo " cd /tmp"
echo " tar -xzf $(basename "$OUT_TAR")"
echo " cat $(basename "$OUT_DIR")/README.md"
echo "================================================================"
+109
View File
@@ -0,0 +1,109 @@
#!/bin/bash
# install-ish.sh — установка ПО «Интеграционный шлюз НРД» (ИШ) на Astra Linux.
#
# Документ-источник: DOC/ruk_install_ish_2025_11_10.pdf (раздел 7.3.2).
#
# Пред-требования:
# 1. ОС: Astra Linux SE 1.6 или 1.7
# 2. УСТАНОВЛЕНА Валидата CSP + АПК Валидата Клиент L (см. install-validata.sh)
# 3. Корневой сертификат УЦ МБ загружен в Справочник сертификатов
# 4. Пользовательский сертификат экспортирован в системное хранилище
#
# Что делает скрипт:
# 1. Проверяет наличие Валидаты
# 2. Устанавливает igate_*.deb через dpkg
# 3. Создаёт каталог настроек ~/igate
# 4. Подсказывает следующие шаги (запуск настройщика каналов)
set -euo pipefail
DEB_PATH="${1:-}"
log() { echo -e "\033[1;34m[ish-install]\033[0m $*"; }
warn() { echo -e "\033[1;33m[ish-install WARN]\033[0m $*" >&2; }
fail() { echo -e "\033[1;31m[ish-install FAIL]\033[0m $*" >&2; exit 1; }
# ---- 1. Поиск .deb ----
if [ -z "$DEB_PATH" ]; then
# Поиск в стандартных местах
for candidate in \
./dist/ish/igate_*_amd64.deb \
/opt/bj/src/dist/ish/igate_*_amd64.deb \
~/Downloads/igate_*_amd64.deb \
/tmp/igate_*_amd64.deb; do
if [ -f "$candidate" ]; then
DEB_PATH="$candidate"
break
fi
done
fi
if [ -z "$DEB_PATH" ] || [ ! -f "$DEB_PATH" ]; then
fail "Не найден .deb пакет ИШ. Скачайте с https://www.nsd.ru/workflow/system/programs/web-service/ и передайте путь:
sudo bash $0 /path/to/igate_100.0-765_amd64.deb"
fi
log "Дистрибутив ИШ: $DEB_PATH"
# ---- 2. Проверка ОС ----
if [ -r /etc/astra_version ]; then
log "Astra Linux: $(cat /etc/astra_version)"
else
warn "Это не Astra Linux. ИШ под Astra Linux может не запуститься на других ОС."
warn "Продолжить? (y/N)"
read -r REPLY < /dev/tty
[ "$REPLY" = "y" ] || exit 1
fi
# ---- 3. Проверка Валидаты ----
log "Проверка СКЗИ Валидата CSP..."
VAL_FOUND=0
for path in /opt/Validata /opt/validata-csp /opt/Validata-CSP /usr/local/Validata; do
[ -d "$path" ] && { log " ✓ Валидата найдена в $path"; VAL_FOUND=1; break; }
done
if [ "$VAL_FOUND" = 0 ]; then
warn "Валидата CSP не найдена. ИШ всё равно поставится, но не запустится без СКЗИ."
warn "Получите дистрибутив Валидаты у НРД (soed@nsd.ru) и поставьте через install-validata.sh."
warn "Продолжить установку ИШ? (y/N)"
read -r REPLY < /dev/tty
[ "$REPLY" = "y" ] || exit 1
fi
# ---- 4. dpkg -i ----
log "Установка ИШ через dpkg..."
[ "$EUID" -eq 0 ] || fail "Запускать от root (sudo bash $0)"
dpkg -i "$DEB_PATH" 2>&1 | tee /tmp/igate-install.log || {
warn "dpkg -i вернул ошибку, пытаюсь починить зависимости через apt-get install -f"
apt-get install -f -y
dpkg -i "$DEB_PATH"
}
# ---- 5. Проверка ----
if command -v igate >/dev/null 2>&1; then
log "✓ ИШ установлен: $(which igate)"
elif [ -x /opt/igate/igate ]; then
log "✓ ИШ установлен в /opt/igate/"
else
warn "Бинарник igate не нашёл в PATH. Возможно установлен в /opt/igate или ~/igate."
warn "Проверьте: dpkg -L igate | grep -E 'bin|igate$'"
fi
# ---- 6. Финал ----
echo
echo "================================================================"
echo " ИШ установлен"
echo "================================================================"
echo
echo " Следующие шаги (по DOC/ruk_install_ish_2025_11_10.pdf раздел 10):"
echo " 1. Запустить ИШ в GUI: igate & (или через меню Пуск/Astra)"
echo " 2. Настройки БД → PostgreSQL (URL/логин/пароль из bj-server)"
echo " 3. Создать канал WSL → URL https://gost.nsd.ru/onyxt3/WslService (TEST3)"
echo " 4. Указать сертификат УЦ МБ из системного хранилища"
echo " 5. Активировать ИШ как сервис:"
echo " sudo systemctl enable --now igate"
echo
echo " REST API ИШ (для bj-server):"
echo " http://localhost:8090 (порт по умолчанию — см. настройки ИШ)"
echo
echo " После настройки канала в ИШ: открыть"
echo " http://<этот-сервер>:8080/admin/setup → раздел «Интеграционный шлюз НРД»"
echo " и указать URL ИШ + имя канала."
echo "================================================================"
+89
View File
@@ -0,0 +1,89 @@
#!/bin/bash
# install-validata.sh — установка СКЗИ «Валидата CSP» + АПК «Валидата Клиент L»
# для работы Интеграционного шлюза НРД на Astra Linux.
#
# ВАЖНО: дистрибутив Валидаты не выложен публично. Получается по запросу:
# - НРД: soed@nsd.ru
# - МБ: pki@moex.com
# В письме указать: «Запрос дистрибутива СКЗИ Валидата CSP для Linux +
# временной лицензии для подключения к ЭДО НРД в рамках MOEX МОСТ M2M.»
#
# Скрипт ожидает что архив с дистрибутивом уже скачан и лежит:
# dist/validata/<любые>.deb
# или передан как первый аргумент.
#
# Запуск:
# sudo bash deploy/astra/install-validata.sh
# sudo bash deploy/astra/install-validata.sh /path/to/validata-csp.deb
set -euo pipefail
log() { echo -e "\033[1;34m[validata-install]\033[0m $*"; }
warn() { echo -e "\033[1;33m[validata-install WARN]\033[0m $*" >&2; }
fail() { echo -e "\033[1;31m[validata-install FAIL]\033[0m $*" >&2; exit 1; }
[ "$EUID" -eq 0 ] || fail "Запускать от root"
SEARCH_PATH="${1:-./dist/validata}"
if [ -f "$SEARCH_PATH" ] && [ "${SEARCH_PATH##*.}" = "deb" ]; then
# Передан конкретный файл
DEBS=( "$SEARCH_PATH" )
elif [ -d "$SEARCH_PATH" ]; then
mapfile -t DEBS < <(find "$SEARCH_PATH" -maxdepth 2 -name '*.deb' 2>/dev/null | sort)
else
fail "Не найден дистрибутив Валидаты. Положи .deb пакеты в dist/validata/ или передай путь аргументом.
Если у тебя ещё нет дистрибутива — запроси у НРД:
Email: soed@nsd.ru (или pki@moex.com)
Тема: Запрос дистрибутива Валидата CSP для Linux
Текст: Просим предоставить дистрибутив СКЗИ Валидата CSP v.6 для Linux
(Astra Linux SE 1.7) + временную лицензию для подключения к
ЭДО НРД через ПО Интеграционный шлюз в рамках сервиса
MOEX МОСТ M2M (см. инструкцию nsd.ru/workflow/system/programs/web-service/).
Реквизиты организации: <ИНН, ОГРН, контактное лицо>.
"
fi
if [ "${#DEBS[@]}" = 0 ]; then
fail "В каталоге $SEARCH_PATH не найдено ни одного .deb пакета"
fi
log "Найдено ${#DEBS[@]} пакетов Валидаты:"
for f in "${DEBS[@]}"; do
echo " $f"
done
log "Установка через dpkg..."
for f in "${DEBS[@]}"; do
log " $f"
dpkg -i "$f" || {
warn " → пытаюсь починить зависимости"
apt-get install -f -y
dpkg -i "$f"
}
done
# Проверка
log "Проверка установки..."
VAL_FOUND=0
for path in /opt/Validata /opt/validata-csp /opt/Validata-CSP; do
[ -d "$path" ] && { log " ✓ Валидата в $path"; VAL_FOUND=1; }
done
if [ "$VAL_FOUND" = 0 ]; then
warn "Каталог Валидаты не нашёл — проверь dpkg -L <имя-пакета>"
fi
echo
echo "================================================================"
echo " Валидата установлена"
echo "================================================================"
echo " Следующие шаги:"
echo " 1. Запустить Справочник сертификатов АПК Валидата Клиент"
echo " (GUI приложение)"
echo " 2. Загрузить корневой сертификат УЦ Московской Биржи"
echo " (взять у УЦ МБ — ca.moex.com — для своей организации)"
echo " 3. Импортировать пользовательский сертификат с приватным ключом"
echo " 4. Меню Сервис → Экспортировать сертификаты в системное хранилище"
echo " 5. Установить ИШ: sudo bash deploy/astra/install-ish.sh"
echo "================================================================"
+384
View File
@@ -0,0 +1,384 @@
#!/bin/bash
# install.sh — установка Bridge-and-Join-s одной командой.
#
# ЦЕЛЕВАЯ АУДИТОРИЯ: оператор без знания Linux/Go. Просто запускает строку:
#
# curl -sSL https://git.zetit.ru/zuevav/Bridge-and-Join-s/raw/main/deploy/astra/install.sh | sudo bash
#
# и всё работает.
#
# Поддерживаемые ОС:
# - Astra Linux Special Edition 1.6 / 1.7 (платная, для прод)
# - Astra Linux Common Edition / 1.8 (бесплатная)
# - Debian 11 / 12
# - Ubuntu 22.04 / 24.04 (с предупреждением)
#
# Что устанавливается АВТОМАТИЧЕСКИ:
# 1. Системные зависимости (apt: curl, git, podman, postgresql-client)
# 2. Go 1.24+ (скачивается с go.dev)
# 3. PostgreSQL 16 в podman-контейнере + миграции
# 4. bj-server (компилируется из исходников, ставится в /opt/bj/)
# 5. Дистрибутив ИШ НРД (скачивается с сайта НРД, ~120 МБ)
# 6. Сам ИШ устанавливается через dpkg -i (но не запускается без Валидаты)
# 7. systemd unit + автозапуск
#
# Что НЕ автоматизируется (только пользователь):
# - СКЗИ Валидата CSP — выдаётся НРД по запросу (soed@nsd.ru)
# - Сертификат подписи УЦ Московской Биржи (ca.moex.com)
# - Регистрация в TEST3 (заявка через nsd.ru)
set -euo pipefail
# ---- параметры ----
REPO_URL="${REPO_URL:-https://git.zetit.ru/zuevav/Bridge-and-Join-s.git}"
BRANCH="${BRANCH:-main}"
BIND_ADDR="${BIND_ADDR:-:8080}"
ISH_DEB_URL="${ISH_DEB_URL:-https://old.nsd.ru/upload/docs/edo/po/igate_100.0-765_amd64.deb}"
SKIP_ISH=0
NON_INTERACTIVE="${NON_INTERACTIVE:-0}"
for arg in "$@"; do
case "$arg" in
--skip-ish) SKIP_ISH=1 ;;
--bind=*) BIND_ADDR="${arg#*=}" ;;
--branch=*) BRANCH="${arg#*=}" ;;
--yes|-y) NON_INTERACTIVE=1 ;;
esac
done
# ---- утилиты вывода ----
NS=$(date +%s)
step() { local n=$(( $(date +%s) - NS )); printf "\033[1;36m[%4ds]\033[0m \033[1;34m▶\033[0m %s\n" "$n" "$*"; }
ok() { printf " \033[1;32m✓\033[0m %s\n" "$*"; }
warn() { printf " \033[1;33m⚠\033[0m %s\n" "$*"; }
fail() { printf " \033[1;31m✗\033[0m %s\n" "$*" >&2; exit 1; }
ask() {
[ "$NON_INTERACTIVE" = "1" ] && return 0
printf " \033[1;35m?\033[0m %s [y/N]: " "$*"
read -r REPLY < /dev/tty || REPLY=n
[ "$REPLY" = "y" ] || [ "$REPLY" = "Y" ]
}
# ---- баннер ----
clear 2>/dev/null || true
cat <<'BANNER'
╔══════════════════════════════════════════════════════════════════╗
║ ║
║ Bridge-and-Join-s — установка с нуля ║
║ сервис M2M-переводов с НКО АО НРД ║
║ ║
║ Установка займёт ~5-10 минут ║
║ Скачается ~150-250 МБ (Go + ИШ + миграции) ║
║ ║
╚══════════════════════════════════════════════════════════════════╝
BANNER
echo
[ "$EUID" -eq 0 ] || fail "Запускать от root: sudo bash $0"
# ============================================================
# ШАГ 1/9. Определение ОС
# ============================================================
step "1/9: определение операционной системы"
OS_KIND=""
OS_NAME="неизвестно"
if [ -r /etc/astra_version ]; then
OS_NAME="Astra Linux $(cat /etc/astra_version)"
OS_KIND="astra"
elif [ -r /etc/os-release ]; then
. /etc/os-release
OS_NAME="$PRETTY_NAME"
case "${ID:-}" in
astra) OS_KIND="astra" ;;
debian) OS_KIND="debian" ;;
ubuntu) OS_KIND="ubuntu" ;;
*)
case "${ID_LIKE:-}" in
*debian*) OS_KIND="debian-like" ;;
esac
;;
esac
fi
ok "Обнаружено: $OS_NAME"
case "$OS_KIND" in
astra)
ok "Astra Linux — полностью поддерживается, ИШ заработает официально"
;;
debian|"debian-like")
warn "Debian-based — bj-server установится, ИШ скорее всего тоже"
warn "(но официально НРД его не поддерживает на Debian; для прод-инфры лучше Astra Linux SE)"
;;
ubuntu)
warn "Ubuntu — bj-server установится, но ИШ может потребовать допилов"
ask "Продолжить?" || exit 1
;;
*)
fail "Неподдерживаемая ОС. Поддерживаются: Astra Linux (SE/CE), Debian, Ubuntu"
;;
esac
# ============================================================
# ШАГ 2/9. Системные пакеты
# ============================================================
step "2/9: установка системных пакетов через apt"
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq >/dev/null
apt-get install -y -qq \
ca-certificates curl wget git tar gzip \
podman postgresql-client \
>/dev/null 2>&1
# podman-compose доступен либо как apt-пакет, либо как pip — пробуем оба
if ! command -v podman-compose >/dev/null 2>&1; then
apt-get install -y -qq podman-compose >/dev/null 2>&1 || \
apt-get install -y -qq python3-pip >/dev/null 2>&1 && pip3 install --quiet podman-compose 2>/dev/null || true
fi
command -v podman >/dev/null && ok "podman: $(podman --version | awk '{print $3}')" || fail "podman не установился"
command -v git >/dev/null && ok "git: $(git --version | awk '{print $3}')" || fail "git не установился"
# ============================================================
# ШАГ 3/9. Go 1.24+
# ============================================================
step "3/9: Go 1.24+"
need_go=1
if command -v go >/dev/null 2>&1; then
GO_HAVE=$(go version | awk '{print $3}' | sed 's/go//')
if printf '%s\n%s' "1.24" "$GO_HAVE" | sort -V | head -1 | grep -q '^1.24$'; then
ok "Go $GO_HAVE — подходит"
need_go=0
else
warn "Go $GO_HAVE — слишком старый, обновляю"
fi
fi
if [ "$need_go" = 1 ]; then
GO_VER="1.24.0"
ok "качаю Go $GO_VER с go.dev (~70 МБ)..."
curl -sSL "https://go.dev/dl/go${GO_VER}.linux-amd64.tar.gz" -o /tmp/go.tar.gz \
|| fail "не получилось скачать Go (нужен интернет)"
rm -rf /usr/local/go
tar -C /usr/local -xzf /tmp/go.tar.gz
ln -sf /usr/local/go/bin/go /usr/local/bin/go
ln -sf /usr/local/go/bin/gofmt /usr/local/bin/gofmt
rm -f /tmp/go.tar.gz
ok "Go $GO_VER установлен в /usr/local/go"
fi
# ============================================================
# ШАГ 4/9. Пользователь bj и каталоги
# ============================================================
step "4/9: системный пользователь bj и каталоги"
if ! id bj >/dev/null 2>&1; then
useradd --system --create-home --home-dir /var/lib/bj --shell /bin/bash bj
ok "создан пользователь bj"
else
ok "пользователь bj уже существует"
fi
install -d -o bj -g bj -m 0755 /opt/bj /var/lib/bj /var/log/bj
install -d -o bj -g bj -m 0700 /var/lib/bj/.bj
ok "каталоги: /opt/bj /var/lib/bj /var/log/bj"
# ============================================================
# ШАГ 5/9. Клон репо и сборка bj-server
# ============================================================
step "5/9: клон репозитория и сборка bj-server"
SRC=/opt/bj/src
if [ -d "$SRC/.git" ]; then
sudo -u bj -H git -C "$SRC" fetch --quiet origin
sudo -u bj -H git -C "$SRC" reset --hard --quiet "origin/$BRANCH"
ok "репо обновлено до $BRANCH"
else
sudo -u bj -H git clone --quiet --branch "$BRANCH" "$REPO_URL" "$SRC" \
|| fail "git clone failed"
ok "репо склонирован"
fi
chown -R bj:bj "$SRC"
ok "компиляция bj-server..."
sudo -u bj -H bash -c "cd $SRC && /usr/local/bin/go build -o /opt/bj/bj-server ./cmd/bj-server" \
|| fail "go build failed"
chown bj:bj /opt/bj/bj-server
chmod 0755 /opt/bj/bj-server
ok "бинарник: /opt/bj/bj-server ($(du -h /opt/bj/bj-server | awk '{print $1}'))"
# ============================================================
# ШАГ 6/9. PostgreSQL в podman + миграции
# ============================================================
step "6/9: PostgreSQL в podman + миграции БД"
cd "$SRC"
if ! podman ps --format '{{.Names}}' 2>/dev/null | grep -qx bj-postgres; then
sudo -u bj -H podman-compose -f deploy/docker-compose/docker-compose.yml up -d postgres \
2>/dev/null || {
warn "podman-compose не сработал, пробую podman run напрямую"
sudo -u bj -H podman run -d --name bj-postgres \
-e POSTGRES_USER=bj -e POSTGRES_PASSWORD=bj_dev -e POSTGRES_DB=bj \
-p 127.0.0.1:5432:5432 \
docker.io/library/postgres:16-alpine
}
sleep 5
fi
# Ждём pg_isready
for i in 1 2 3 4 5 6 7 8 9 10; do
if sudo -u bj -H podman exec bj-postgres pg_isready -U bj -d bj >/dev/null 2>&1; then
ok "PostgreSQL готов"
break
fi
sleep 2
done
# Накат миграций
MIG_COUNT=0
for mig in migrations/fansy-store/*.sql migrations/m2m-core/*.sql; do
if [ -f "$mig" ]; then
sudo -u bj -H podman exec -i bj-postgres psql -U bj -d bj -v ON_ERROR_STOP=0 < "$mig" >/dev/null 2>&1 && \
MIG_COUNT=$((MIG_COUNT+1))
fi
done
ok "миграций накачено: $MIG_COUNT"
# Сохраняем DSN
cat > /var/lib/bj/.bj/setup.json <<EOF
{
"postgres": {"dsn": "postgres://bj:bj_dev@127.0.0.1:5432/bj?sslmode=disable"},
"crypto": {"provider": "stub", "socket_path": "/run/bj/crypto.sock"},
"nsd": {},
"lk": {},
"ca_certs": {},
"news": {},
"updated_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
}
EOF
chown bj:bj /var/lib/bj/.bj/setup.json
chmod 0600 /var/lib/bj/.bj/setup.json
ok "DSN сохранён в /var/lib/bj/.bj/setup.json"
# ============================================================
# ШАГ 7/9. systemd unit
# ============================================================
step "7/9: systemd unit для bj-server"
cat > /etc/systemd/system/bj-server.service <<EOF
[Unit]
Description=Bridge-and-Join-s — единый сервис M2M-переводов
Documentation=$REPO_URL
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=bj
Group=bj
WorkingDirectory=$SRC
ExecStart=/opt/bj/bj-server
Restart=on-failure
RestartSec=5
Environment=BJ_HTTP_ADDR=$BIND_ADDR
Environment=BJ_SETUP_PATH=/var/lib/bj/.bj/setup.json
Environment=BJ_M2M_SENDER=MC0079200000
Environment=BJ_M2M_RECEIVER=MC0010300000
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/bj /var/log/bj
PrivateTmp=true
StandardOutput=append:/var/log/bj/bj-server.log
StandardError=append:/var/log/bj/bj-server.err
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable bj-server >/dev/null 2>&1
systemctl restart bj-server
sleep 2
if systemctl is-active --quiet bj-server; then
ok "bj-server.service active"
else
warn "bj-server не стартанул, см. journalctl -u bj-server -n 30"
fi
# ============================================================
# ШАГ 8/9. ИШ НРД — скачивание и установка
# ============================================================
if [ "$SKIP_ISH" = "1" ]; then
step "8/9: ИШ НРД — пропущено (--skip-ish)"
else
step "8/9: Интеграционный шлюз НРД (ИШ)"
ISH_LOCAL="$SRC/dist/ish/igate_100.0-765_amd64.deb"
if [ -f "$ISH_LOCAL" ]; then
ok "дистрибутив ИШ уже в репо: $ISH_LOCAL"
else
ok "качаю дистрибутив ИШ с НРД (~120 МБ)..."
mkdir -p "$(dirname "$ISH_LOCAL")"
if curl -sSL -A "Mozilla/5.0" "$ISH_DEB_URL" -o "$ISH_LOCAL" --max-time 600; then
ok "скачан: $(du -h "$ISH_LOCAL" | awk '{print $1}')"
else
warn "не получилось скачать ИШ автоматически"
warn "скачайте вручную: $ISH_DEB_URL"
warn "и положите в $ISH_LOCAL, потом перезапустите этот скрипт"
ISH_LOCAL=""
fi
fi
if [ -n "$ISH_LOCAL" ] && [ -f "$ISH_LOCAL" ]; then
ok "установка ИШ через dpkg..."
if dpkg -i "$ISH_LOCAL" >/dev/null 2>&1; then
ok "ИШ установлен"
else
# Часто dpkg падает на зависимостях — пробуем apt-get install -f
apt-get install -f -y >/dev/null 2>&1
if dpkg -i "$ISH_LOCAL" >/dev/null 2>&1; then
ok "ИШ установлен (после починки зависимостей)"
else
warn "ИШ не встал — возможно нет Валидаты или системных пакетов"
warn "это нормально на текущем этапе — продолжаем"
fi
fi
fi
fi
# ============================================================
# ШАГ 9/9. Финальная проверка
# ============================================================
step "9/9: проверка готовности"
sleep 1
CODE=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1${BIND_ADDR}/admin/" 2>/dev/null || echo "—")
[ "$CODE" = "200" ] && ok "веб-админка отвечает: HTTP 200" || warn "веб-админка пока не отвечает (HTTP $CODE) — проверь логи"
IP=$(hostname -I | awk '{print $1}')
echo
echo "╔══════════════════════════════════════════════════════════════════╗"
echo "║ УСТАНОВКА BJ-SERVER ЗАВЕРШЕНА ║"
echo "╚══════════════════════════════════════════════════════════════════╝"
echo
echo " Веб-админка: http://$IP${BIND_ADDR}/admin/"
echo " Мастер настройки: http://$IP${BIND_ADDR}/admin/wizard"
echo " Архитектура: http://$IP${BIND_ADDR}/admin/help/architecture"
echo " Новости: http://$IP${BIND_ADDR}/admin/news"
echo
echo " Логи: tail -f /var/log/bj/bj-server.log"
echo " Сервис: systemctl status bj-server"
echo
echo " ──── ЧТО ОСТАЛОСЬ СДЕЛАТЬ (НЕ АВТОМАТИЧЕСКИ) ───────────────"
echo
echo " 1. Запросить СКЗИ Валидата CSP у НРД:"
echo " Email: soed@nsd.ru"
echo " Текст: «Запрос дистрибутива Валидата CSP для Linux + временной"
echo " лицензии для подключения к ЭДО НРД в рамках MOEX МОСТ M2M.»"
echo
echo " 2. Получить сертификат подписи в УЦ Московской Биржи:"
echo " https://ca.moex.com/"
echo
echo " 3. Подать заявку на тестирование в TEST3:"
echo " https://www.nsd.ru/workflow/zayavka-na-testirovanie/"
echo
echo " 4. Когда придёт Валидата — поставить:"
echo " sudo bash $SRC/deploy/astra/install-validata.sh /path/to/validata-*.deb"
echo
echo " 5. Когда заработает ИШ — указать его URL в /admin/setup → «ИШ НРД»"
echo
echo " ──── ПРОВЕРКА СОСТОЯНИЯ ВСЕГО ──────────────────────────────"
echo " sudo bash $SRC/deploy/astra/healthcheck.sh"
echo
+16 -2
View File
@@ -7,7 +7,7 @@ version: "3.9"
services: services:
postgres: postgres:
image: postgres:16 image: docker.io/library/postgres:16
# В проде заменить на postgrespro/std-16 или registry.postgrespro.ru/pgpro/... # В проде заменить на postgrespro/std-16 или registry.postgrespro.ru/pgpro/...
container_name: bj-postgres container_name: bj-postgres
environment: environment:
@@ -20,7 +20,7 @@ services:
- bj-postgres-data:/var/lib/postgresql/data - bj-postgres-data:/var/lib/postgresql/data
minio: minio:
image: minio/minio:latest image: docker.io/minio/minio:latest
container_name: bj-minio container_name: bj-minio
command: server /data --console-address ":9001" command: server /data --console-address ":9001"
environment: environment:
@@ -32,6 +32,20 @@ services:
volumes: volumes:
- bj-minio-data:/data - bj-minio-data:/data
crypto-service:
build:
context: ../../services/crypto-service
dockerfile: Dockerfile
container_name: bj-crypto-service
environment:
BJ_CRYPTO_SOCKET: /run/bj/crypto.sock
BJ_CRYPTO_PROVIDER: stub
volumes:
# UDS-сокет наружу как named volume, чтобы Go-сервисы
# (m2m-core, lk-gateway, nsd-adapter) могли его mount'ить.
- bj-crypto-sock:/run/bj
volumes: volumes:
bj-postgres-data: bj-postgres-data:
bj-minio-data: bj-minio-data:
bj-crypto-sock:
+1
View File
@@ -0,0 +1 @@
TEST3 GOST|TEST3GOST|WSL|t
+132
View File
@@ -0,0 +1,132 @@
-- configure-ish.sql — автонастройка ИШ НРД без GUI.
--
-- Снято как эталон с рабочей GUI-конфигурации (deploy/ish/params-reference.txt)
-- и параметризовано. Воспроизводит то, что оператор делал бы мышкой в
-- igate.exe (Avalonia): PostgreSQL + Web API + WSL-канал TEST3-GOST.
--
-- Применяется к свежей БД ИШ ПОСЛЕ того как схема создана через
-- `igate-cli --data <dir>` (он накатывает EF-миграции при первом подключении).
--
-- Подстановки (заменяются установщиком через psql -v):
-- :channel_name — отображаемое имя канала, напр. 'TEST3 GOST'
-- :channel_code — локальный код канала, напр. 'TEST3GOST'
-- :wsl_endpoint — URL службы WSL НРД (TEST3-GOST)
-- :crypto_profile — имя профиля Валидаты ('moex')
-- :repository_code— код депонента из письма НРД ('MC0079200000')
-- :exchange_dir — рабочая папка обмена ('/var/lib/igate/exchange')
-- :web_port — порт Web API ('8090')
--
-- Пример:
-- psql -h 127.0.0.1 -U igate -d igate \
-- -v channel_name="'TEST3 GOST'" -v channel_code="'TEST3GOST'" \
-- -v wsl_endpoint="'https://gost-t3.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo'" \
-- -v crypto_profile="'moex'" -v repository_code="'MC0079200000'" \
-- -v exchange_dir="'/var/lib/igate/exchange'" -v web_port="'8090'" \
-- -f configure-ish.sql
BEGIN;
-- Чистим прежнюю конфигурацию (идемпотентность)
DELETE FROM parameters;
DELETE FROM channels;
-- --- Глобальные параметры: Web API (КРИТИЧНО — runEngineOnStartApp=True,
-- иначе движок не стартует в headless-режиме и Kestrel не поднимается) ---
INSERT INTO parameters(name, value, chanel_id) VALUES
('runEngineOnStartApp', 'True', NULL),
('server.useServer', 'True', NULL),
('server.host', 'localhost', NULL),
('server.port', :web_port, NULL),
('server.scheme', 'Http', NULL),
('server.authentication.enable', 'False', NULL),
('server.authentication.userName', '', NULL),
('server.authentication.password', '', NULL),
('server.certificate.storage', 'File', NULL),
('server.certificate.store.location', 'CurrentUser', NULL),
('server.certificate.store.name', 'My', NULL),
('server.certificate.file.path', '', NULL),
('server.certificate.file.password', '', NULL),
('wsl.httpsMode', 'Auto', NULL),
('wsl.maxConnsPerServer', '4', NULL),
('wsl.proxy.mode', 'None', NULL),
('wsl.proxy.address', '', NULL),
('wsl.proxy.port', '0', NULL),
('wsl.proxy.username', '', NULL),
('wsl.proxy.password', '', NULL),
('enableDbLogging', 'False', NULL),
('cleanAutomatically', 'False', NULL),
('cleanAtTime', '00:30:00', NULL),
('cleanWhenLarger', '1024', NULL),
('cleanVacuum', 'False', NULL),
('storePeriod', '15', NULL),
('archiveAutomatically', 'False', NULL);
-- --- WSL-канал ---
-- ВАЖНО: ИШ резолвит канал по СОСТАВНОМУ коду = <код канала> + <код депонента>
-- (так формирует ИШ-GUI: TEST3 + MC0413600000 = TEST3MC0413600000). С коротким
-- кодом ИШ падает 'more than one Channel' и admin API не видит канал.
INSERT INTO channels(name, code, type, enable)
VALUES (:channel_name, :channel_code || :repository_code, 'WSL', true);
-- Параметры канала привязываем к его id (находим по составному коду)
INSERT INTO parameters(name, value, chanel_id)
SELECT n, v, c.id FROM channels c, (VALUES
('enable', 'True'),
('wslEndpoint', :wsl_endpoint),
('cryptography.type', 'GOST'),
('cryptography.profile', :crypto_profile),
('cryptography.pincode', ''),
('cryptography.clientCertificateSerialNumber', ''),
('repositoryCode', :repository_code),
('fetchInterval', '00:01:00'),
('attemptInterval', '30000'),
('sendAttempts', '3'),
('maxPartSize', '500'),
('loadOldMessagesDepth', '3'),
('isIncomingEnabled', 'True'),
('isOutgoingEnabled', 'True'),
('isTransitTerminalChannel', 'False'),
('useDirectories', 'True'),
('dir', :exchange_dir),
('inboxDirName', 'INBOX'),
('outboxDirName', 'OUTBOX'),
('sentDirName', 'SENT'),
('errorDirName', 'ERRORS'),
('archive1042sDirName', :exchange_dir || '/Archives1042S'),
('enableLockFile', 'True'),
('enableAutoResponse', 'True'),
('enable1042ReportProcessing', 'True'),
('RenameOutgoingFiles', 'True'),
('generateReceivedPackageInfo', 'True'),
('generateSentPackageInfo', 'False'),
('moveReceiptsToSentFolder', 'False'),
('applyAddHashOfPackageToFolder', 'False'),
('ignorePackageDirectoryStructure', 'False'),
('checkReceivedPackageNsdSign', 'False'),
('checkReceivedPackageSenderSign', 'False'),
('autoUpdateTransitMember', 'False'),
('automaticcalyLoadCrls', 'False'),
('autoInPkgReportOffload', 'False'),
('monitoringThreshold', '00:00:10'),
-- Пустые параметры для полного соответствия эталону GUI (движок ожидает
-- их наличие; отсутствие части может дать «Invalid value» при старте).
('autoLoadCrlsTime', ''),
('fetchThreadCount', ''),
('forceCryPackageEncryption', ''),
('inPkgReportDirectory', ''),
('inPkgReportOffloadInterval', ''),
('maxPackagesPerJob', ''),
('nsdCertificateSerialNumbers', ''),
('pkiDecryptMode', ''),
('pkiEncryptMode', ''),
('pkiSignMode', ''),
('pkiVerifyMode', ''),
('receiveProcThreadCount', ''),
('sendProcThreadCount', ''),
('updateTransitMemberListTime', '')
) AS p(n, v)
WHERE c.code = :channel_code || :repository_code;
COMMIT;
\echo 'ИШ настроен. Перезапустите igate-svc: systemctl restart igate'
+83
View File
@@ -0,0 +1,83 @@
archiveAtTime||NULL
archiveAutomatically|False|NULL
archiveRecordsOlderThan||NULL
archiveWhenLarger||NULL
cleanAtTime|00:30:00|NULL
cleanAutomatically|False|NULL
cleanVacuum|False|NULL
cleanWhenLarger|1024|NULL
enableDbLogging|False|NULL
httpTimeout||NULL
packageBackupFolder||NULL
runEngineOnStartApp|False|NULL
server.authentication.enable|False|NULL
server.authentication.password||NULL
server.authentication.userName||NULL
server.certificate.file.password||NULL
server.certificate.file.path||NULL
server.certificate.storage|File|NULL
server.certificate.store.location|CurrentUser|NULL
server.certificate.store.name|My|NULL
server.host|localhost|NULL
server.port|8090|NULL
server.scheme|Http|NULL
server.useServer|True|NULL
storePeriod|15|NULL
wsl.httpsMode|Auto|NULL
wsl.maxConnsPerServer|4|NULL
wsl.proxy.address||NULL
wsl.proxy.mode|None|NULL
wsl.proxy.password||NULL
wsl.proxy.port|0|NULL
wsl.proxy.username||NULL
applyAddHashOfPackageToFolder|False|33
archive1042sDirName|/var/lib/igate/exchange/Archives1042S|33
attemptInterval|30000|33
autoInPkgReportOffload|False|33
autoLoadCrlsTime||33
automaticcalyLoadCrls|False|33
autoUpdateTransitMember|False|33
checkReceivedPackageNsdSign|False|33
checkReceivedPackageSenderSign|False|33
cryptography.clientCertificateSerialNumber||33
cryptography.pincode||33
cryptography.profile|My|33
cryptography.type|GOST|33
dir|/var/lib/igate/exchange|33
enable|True|33
enable1042ReportProcessing|True|33
enableAutoResponse|True|33
enableLockFile|True|33
errorDirName|ERRORS|33
fetchInterval|00:01:00|33
fetchThreadCount||33
forceCryPackageEncryption||33
generateReceivedPackageInfo|True|33
generateSentPackageInfo|False|33
ignorePackageDirectoryStructure|False|33
inboxDirName|INBOX|33
inPkgReportDirectory||33
inPkgReportOffloadInterval||33
isIncomingEnabled|True|33
isOutgoingEnabled|True|33
isTransitTerminalChannel|False|33
loadOldMessagesDepth|3|33
maxPackagesPerJob||33
maxPartSize|500|33
monitoringThreshold|00:00:10|33
moveReceiptsToSentFolder|False|33
nsdCertificateSerialNumbers||33
outboxDirName|OUTBOX|33
pkiDecryptMode||33
pkiEncryptMode||33
pkiSignMode||33
pkiVerifyMode||33
receiveProcThreadCount||33
RenameOutgoingFiles|True|33
repositoryCode|MC0079200000|33
sendAttempts|3|33
sendProcThreadCount||33
sentDirName|SENT|33
updateTransitMemberListTime||33
useDirectories|True|33
wslEndpoint|https://gost-t3.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo|33
+71
View File
@@ -0,0 +1,71 @@
# Лицензирование 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/
```
+388
View File
@@ -0,0 +1,388 @@
#!/usr/bin/env bash
# install-validata.sh — установка АПК «Валидата Клиент L» под bj-crypto.
#
# Поддерживаемые ОС:
# - Debian 11 / 12 (основная, бесплатная)
# - Astra Linux SE 1.7 (платная, для регуляторно-обязанных)
# - Astra Linux CE 1.8 (бесплатная)
# - Ubuntu 22.04 / 24.04 (с предупреждением)
#
# Что делает:
# 1. Ставит зависимости (pcscd, libpcsclite, libgtk-3, libldap, p7zip, execstack)
# 2. Ставит zpki + zsdk deb-пакеты Валидаты
# 3. execstack -c libvdcsp.so (исправление GNU_STACK с RWE на RW)
# 4. Создаёт системного пользователя bj (если ещё нет)
# 5. Кладёт 5 systemd drop-ins (pcscd no-autoexit + 3×bj-crypto + 1×bj-server)
# 6. Создаёт /opt/Validata/VDCSP/etc/spki.ini (Валидата с ним капризничает)
# 7. Дописывает заголовочную секцию в pki1.conf
# 8. Включает pcscd в режиме always-on (без socket-активации) — Валидата
# ожидает постоянно живой демон, иначе ловит 0x8010001D
# 9. Ставит udev-rule + systemd-mount unit для авто-mount USB-флешек с .vdk
# в /var/lib/bj/usb/<label>/ с владельцем bj — это убирает необходимость
# пробрасывать /media/<gui-user>/
# 10. systemctl daemon-reload + enable/start bj-crypto + bj-server
#
# Идемпотентный — повторный запуск ничего не ломает.
#
# Запуск:
# sudo bash install-validata.sh [path-to-validata-zpki.deb]
#
# Если путь не передан — ищет:
# ./ClientL_Other/zpki-*.deb
# /opt/bj/src/dist/validata/*.deb
# ~/Загрузки/ClientL_Other/*.deb
# ~/Downloads/ClientL_Other/*.deb
set -euo pipefail
# --------------------------------------------------------------------- #
# Логирование
# --------------------------------------------------------------------- #
log() { echo -e "\033[1;34m[validata-install]\033[0m $*"; }
ok() { echo -e "\033[1;32m[validata-install OK]\033[0m $*"; }
warn() { echo -e "\033[1;33m[validata-install WARN]\033[0m $*" >&2; }
fail() { echo -e "\033[1;31m[validata-install FAIL]\033[0m $*" >&2; exit 1; }
[ "$EUID" -eq 0 ] || fail "Запускать от root (sudo)"
# --------------------------------------------------------------------- #
# 1. Детект ОС
# --------------------------------------------------------------------- #
DISTRO=""
DISTRO_VERSION=""
if [ -r /etc/os-release ]; then
. /etc/os-release
case "$ID" in
astra) DISTRO=astra; DISTRO_VERSION="${VERSION_ID:-unknown}";;
debian) DISTRO=debian; DISTRO_VERSION="${VERSION_ID:-unknown}";;
ubuntu) DISTRO=ubuntu; DISTRO_VERSION="${VERSION_ID:-unknown}";;
*) DISTRO="$ID"; DISTRO_VERSION="${VERSION_ID:-unknown}";;
esac
fi
case "$DISTRO" in
debian|astra) ok "ОС: $PRETTY_NAME ($DISTRO $DISTRO_VERSION) — поддерживается";;
ubuntu) warn "ОС: $PRETTY_NAME — поддерживается на свой страх (Validata в формуляре нет)";;
*) warn "ОС: $PRETTY_NAME — не проверена, продолжаю на свой страх";;
esac
# --------------------------------------------------------------------- #
# 2. Поиск deb-пакетов Валидаты
# --------------------------------------------------------------------- #
ZPKI_DEB=""
ZSDK_DEB=""
if [ -n "${1:-}" ] && [ -f "$1" ]; then
# Конкретный файл передан аргументом
ZPKI_DEB="$1"
# zsdk ищем рядом
ZSDK_DEB="$(dirname "$1")/$(basename "$1" | sed 's/zpki/zsdk/')"
else
for d in \
./ClientL_Other \
./ClientL_ALSE \
/opt/bj/src/dist/validata \
/home/*/Загрузки/ClientL_Other \
/home/*/Загрузки/ClientL_ALSE \
/home/*/Downloads/ClientL_Other \
/home/*/Downloads/ClientL_ALSE \
/tmp/ClientL_Other; do
[ -d "$d" ] || continue
cand_zpki=$(find "$d" -maxdepth 1 -name "zpki-*.amd64.deb" 2>/dev/null | head -1)
cand_zsdk=$(find "$d" -maxdepth 1 -name "zsdk-*.amd64.deb" 2>/dev/null | head -1)
if [ -n "$cand_zpki" ]; then
ZPKI_DEB="$cand_zpki"
ZSDK_DEB="$cand_zsdk"
break
fi
done
fi
[ -n "$ZPKI_DEB" ] || fail "Не найден zpki-*.amd64.deb. Скачайте https://fs.moex.com/cdp/po/ClientL_Other.zip и распакуйте рядом со скриптом или в /opt/bj/src/dist/validata/"
log "Найдено:"
log " zpki: $ZPKI_DEB"
[ -n "$ZSDK_DEB" ] && log " zsdk: $ZSDK_DEB" || warn " zsdk не найден — будет пропущен"
# --------------------------------------------------------------------- #
# 3. Системные зависимости
# --------------------------------------------------------------------- #
log "Обновляю apt-кеш и ставлю зависимости..."
apt-get update -qq
# Базовые зависимости (одинаковые на Debian/Astra)
DEPS=(
libgtk-3-0
libpcsclite1
libccid
pcscd
libcurl4
libkrb5-3
libgssapi-krb5-2
libsasl2-modules
libsasl2-modules-gssapi-mit
execstack
p7zip-full
util-linux
uuid-runtime
)
# libldap-2.4-2 в Bullseye/Astra; в Bookworm уже libldap-2.5-0.
# Зависимость в zpki жёстко на 2.4-2 → используем --force-depends на этом этапе
# и ставим libldap-2.5-0 как замену (ABI совместим в нашем use-case).
if apt-cache show libldap-2.4-2 >/dev/null 2>&1; then
DEPS+=(libldap-2.4-2)
USE_FORCE=0
else
DEPS+=(libldap-2.5-0)
USE_FORCE=1
warn "libldap-2.4-2 недоступен → ставлю libldap-2.5-0 и буду форсить --force-depends при установке zpki"
fi
apt-get install -y --no-install-recommends "${DEPS[@]}"
ok "Зависимости установлены"
# --------------------------------------------------------------------- #
# 4. Установка Валидаты
# --------------------------------------------------------------------- #
log "Ставлю zpki..."
if [ "$USE_FORCE" = "1" ]; then
dpkg --force-depends -i "$ZPKI_DEB"
else
dpkg -i "$ZPKI_DEB" || { apt-get -f install -y; dpkg -i "$ZPKI_DEB"; }
fi
if [ -n "$ZSDK_DEB" ]; then
log "Ставлю zsdk..."
dpkg -i "$ZSDK_DEB" || { apt-get -f install -y; dpkg -i "$ZSDK_DEB"; }
fi
[ -d /opt/Validata/VDCSP/lib/amd64 ] || fail "Валидата не установилась в /opt/Validata — проверьте dpkg -L zpki"
ok "Валидата в /opt/Validata/VDCSP"
# --------------------------------------------------------------------- #
# 5. execstack libvdcsp.so (GNU_STACK RWE → RW)
# --------------------------------------------------------------------- #
log "execstack -c libvdcsp.so (требование Валидаты)..."
if execstack -q /opt/Validata/VDCSP/lib/amd64/libvdcsp.so 2>/dev/null | grep -q '^X'; then
execstack -c /opt/Validata/VDCSP/lib/amd64/libvdcsp.so
ok "executable-stack снят"
else
ok "executable-stack уже снят"
fi
# --------------------------------------------------------------------- #
# 6. Системный пользователь bj
# --------------------------------------------------------------------- #
if ! id bj >/dev/null 2>&1; then
log "Создаю системного пользователя bj..."
useradd --system --create-home --home-dir /var/lib/bj --shell /bin/bash bj
ok "Пользователь bj создан (home=/var/lib/bj)"
else
ok "Пользователь bj уже есть"
fi
install -d -o bj -g bj -m 0755 /var/lib/bj/usb
install -d -o bj -g bj -m 0700 /var/lib/bj/.Validata
install -d -o bj -g bj -m 0700 /var/lib/bj/.Validata/vdkeys
install -d -o bj -g bj -m 0755 /var/lib/bj/profiles
install -d -o bj -g bj -m 0755 /var/log/bj
# --------------------------------------------------------------------- #
# 7. pcscd: убираем --auto-exit и socket-активацию
# --------------------------------------------------------------------- #
log "Настраиваю pcscd как always-on демон..."
install -d /etc/systemd/system/pcscd.service.d
cat >/etc/systemd/system/pcscd.service.d/no-autoexit.conf <<'EOF'
[Unit]
# Отвязываем сервис от сокет-юнита, чтобы можно было держать его постоянно живым
Requires=
After=
Sockets=
[Service]
# Убираем --auto-exit — Валидата ожидает постоянно живой pcscd, иначе
# получает 0x8010001D «Диспетчер ресурсов смарт-карт не выполняется»
# при попытке найти ключевой носитель (.vdk файл выглядит для неё как
# виртуальная смарт-карта)
ExecStart=
ExecStart=/usr/sbin/pcscd --foreground
EOF
ok "pcscd drop-in: /etc/systemd/system/pcscd.service.d/no-autoexit.conf"
# --------------------------------------------------------------------- #
# 8. bj-crypto drop-ins
# --------------------------------------------------------------------- #
log "Кладу drop-ins для bj-crypto..."
install -d /etc/systemd/system/bj-crypto.service.d
cat >/etc/systemd/system/bj-crypto.service.d/validata-paths.conf <<'EOF'
[Service]
# Валидата ищет pki1.conf в текущей рабочей директории — работаем оттуда
WorkingDirectory=/opt/Validata/VDCSP/etc
# Валидата пишет в /opt/Validata/VDCSP/etc/pki1.conf при инициализации
# профиля. ProtectSystem=strict делает /opt read-only — открываем точечно.
ReadWritePaths=/opt/Validata/VDCSP/etc
ReadWritePaths=/var/lib/bj
EOF
cat >/etc/systemd/system/bj-crypto.service.d/usb-access.conf <<'EOF'
[Service]
# Без этого PrivateTmp + ProtectSystem закроет /media и /var/lib/bj/usb,
# а нам нужно туда смотреть в поисках .vdk на флешке.
ReadOnlyPaths=/media
ReadOnlyPaths=/var/lib/bj/usb
EOF
cat >/etc/systemd/system/bj-crypto.service.d/share-crysvc.conf <<'EOF'
[Service]
# Валидата общается с криптодрайвером (vdcrysvc) через Unix-сокет
# /tmp/.crysvc.sock — но PrivateTmp=true даёт нам приватный /tmp.
# Прокидываем именно этот сокет внутрь нашего namespace.
PrivateTmp=true
BindPaths=/tmp/.crysvc.sock:/tmp/.crysvc.sock
EOF
ok "bj-crypto drop-ins: validata-paths.conf, usb-access.conf, share-crysvc.conf"
# --------------------------------------------------------------------- #
# 9. bj-server drop-in
# --------------------------------------------------------------------- #
log "Кладу drop-in для bj-server..."
install -d /etc/systemd/system/bj-server.service.d
cat >/etc/systemd/system/bj-server.service.d/pki1conf.conf <<'EOF'
[Service]
# bj-server при импорте профиля дописывает секцию в pki1.conf.
# ProtectSystem=strict закрывает /opt — открываем точечно.
ReadWritePaths=/opt/Validata/VDCSP/etc
EOF
ok "bj-server drop-in: pki1conf.conf"
# --------------------------------------------------------------------- #
# 10. spki.ini — Валидата требует, при отсутствии mkstores падает
# --------------------------------------------------------------------- #
SPKI=/opt/Validata/VDCSP/etc/spki.ini
if [ ! -f "$SPKI" ]; then
log "Создаю $SPKI (Валидата без него падает в mkstores/zpki1utl)..."
cat >"$SPKI" <<'EOF'
[store]
count = 0
[Parameters]
PkiLdapTimeout = 10
PkiHttpTimeout = 60
EOF
chmod 644 "$SPKI"
ok "spki.ini создан"
else
ok "spki.ini уже есть"
fi
# --------------------------------------------------------------------- #
# 11. pki1.conf — делаем доступным для записи группе bj
# --------------------------------------------------------------------- #
PKI1=/opt/Validata/VDCSP/etc/pki1.conf
if [ -f "$PKI1" ]; then
chgrp bj "$PKI1"
chmod g+w "$PKI1"
ok "pki1.conf: group=bj, g+w"
if ! grep -q "^# --- bj-server: BEGIN ---" "$PKI1"; then
printf '\n# --- bj-server: BEGIN ---\n# Секции профилей дописываются автоматически при импорте через /admin/setup.\n# --- bj-server: END ---\n' >> "$PKI1"
ok "В pki1.conf добавлены маркеры bj-server"
fi
fi
# --------------------------------------------------------------------- #
# 12. udev-rule для авто-mount USB с .vdk
# --------------------------------------------------------------------- #
log "Ставлю udev-rule для авто-mount USB → /var/lib/bj/usb/..."
cat >/etc/udev/rules.d/99-bj-usb.rules <<'EOF'
# Авто-mount USB-флешек в /var/lib/bj/usb/<label> с владельцем bj.
# Применяется только к USB-устройствам (SUBSYSTEMS=="usb") с файловой
# системой. Mountpoint выбирается по метке тома или UUID.
ACTION=="add", KERNEL=="sd?[0-9]", SUBSYSTEMS=="usb", \
ENV{ID_FS_TYPE}!="", \
ENV{SYSTEMD_WANTS}="bj-usb-mount@$env{ID_FS_UUID}.service", \
ENV{SYSTEMD_USER_WANTS}=""
ACTION=="remove", KERNEL=="sd?[0-9]", SUBSYSTEMS=="usb", \
ENV{ID_FS_TYPE}!="", \
ENV{SYSTEMD_WANTS}="bj-usb-umount@$env{ID_FS_UUID}.service"
EOF
# Систэмный template-сервис, который монтирует и umonтирует
cat >/etc/systemd/system/bj-usb-mount@.service <<'EOF'
[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
EOF
cat >/etc/systemd/system/bj-usb-umount@.service <<'EOF'
[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'
EOF
udevadm control --reload-rules
udevadm trigger
ok "udev-rule + systemd-mount шаблон установлены"
# --------------------------------------------------------------------- #
# 13. Установка bj-crypto.service unit (если его ещё нет — берём из репы)
# --------------------------------------------------------------------- #
SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_UNIT="$SELF_DIR/../systemd/bj-crypto.service"
if [ ! -f /etc/systemd/system/bj-crypto.service ] && [ -f "$REPO_UNIT" ]; then
log "Устанавливаю /etc/systemd/system/bj-crypto.service из репы..."
install -m 0644 "$REPO_UNIT" /etc/systemd/system/bj-crypto.service
ok "bj-crypto.service установлен"
fi
# --------------------------------------------------------------------- #
# 14. daemon-reload + старт сервисов
# --------------------------------------------------------------------- #
log "systemctl daemon-reload..."
systemctl daemon-reload
log "Отключаю pcscd.socket (его подменяет наш drop-in always-on)..."
systemctl disable pcscd.socket 2>/dev/null || true
systemctl stop pcscd.socket 2>/dev/null || true
log "Запускаю pcscd..."
systemctl enable pcscd
systemctl restart pcscd
if [ -f /etc/systemd/system/bj-crypto.service ]; then
log "Запускаю bj-crypto..."
systemctl enable bj-crypto
systemctl restart bj-crypto
fi
# --------------------------------------------------------------------- #
# 15. Финальная проверка
# --------------------------------------------------------------------- #
echo
echo "================================================================"
echo " Валидата установлена, окружение настроено"
echo "================================================================"
for svc in pcscd vdcrysvc bj-crypto; do
if systemctl is-active --quiet "$svc" 2>/dev/null; then
echo "$svc — active"
else
echo "$svc — НЕ active (проверьте journalctl -u $svc)"
fi
done
echo
echo " Дальнейшие шаги:"
echo " 1. Подключите USB с .vdk → авто-маунт в /var/lib/bj/usb/<UUID>/"
echo " 2. Откройте /admin/setup в bj-server"
echo " 3. Загрузите .7z с профилем → bj-server сам всё извлечёт и импортирует"
echo " 4. Нажмите «Активировать профиль»"
echo "================================================================"
@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="windows-1251"?>
<!--
АНКЕТА УЧАСТНИКА СЕРВИСА M2M (M2MTransferParticipantForm)
Назначение: регистрация нашего депозитарного кода в справочнике участников
M2M на стороне НРД. Без этой регистрации сервис МОСТ отклоняет запросы с
кодом M2M14 «Код ЭДО НРД отправителя отсутствует в справочнике участников M2M».
ВНИМАНИЕ:
1. Перед отправкой замените все значения ЗАПОЛНИТЬ_* на реальные реквизиты
организации. Это юридические данные — заполняет уполномоченное лицо.
2. Файл должен быть в кодировке windows-1251 (как объявлено в прологе).
Наш редактор хранит его в UTF-8 для удобства — перекодируйте перед
отправкой: iconv -f utf8 -t cp1251 form.xml > form.win1251.xml
3. Известное значение уже подставлено: депозитарный код MC0413600000
(выдан НРД для тестового контура TEST3, период 21.05.202601.09.2026).
4. Схема: DOC/M2MSchemas_260408/M2MTransferParticipantForm.xsd
-->
<pf:M2MTransferParticipantForm
xmlns:m2m="http://nsd.ru/schemas/m2m/types"
xmlns:pf="http://nsd.ru/schemas/m2m/participant/form"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://nsd.ru/schemas/m2m/participant/form M2MTransferParticipantForm.xsd">
<!-- Дата/время формирования анкеты по МСК. T — латиница, МСК — кириллица. -->
<pf:CreationTimestamp>2026-06-17T12:00:00(МСК)</pf:CreationTimestamp>
<pf:Participant>
<!-- ИНН организации (10 цифр для ЮЛ). -->
<m2m:INN>ЗАПОЛНИТЬ_ИНН</m2m:INN>
<m2m:Names>
<m2m:Rus>
<!-- Полное наименование по уставу. -->
<m2m:FullName>ЗАПОЛНИТЬ_ПОЛНОЕ_НАИМЕНОВАНИЕ</m2m:FullName>
<!-- Сокращённое наименование (необязательно). -->
<m2m:ShortName>ЗАПОЛНИТЬ_СОКРАЩЁННОЕ</m2m:ShortName>
<!-- Отображаемое короткое имя (показывается контрагенту). -->
<m2m:DisplayName>ЗАПОЛНИТЬ_ОТОБРАЖАЕМОЕ</m2m:DisplayName>
</m2m:Rus>
<!-- Английский блок необязателен; заполните, если есть. -->
<m2m:Eng>
<m2m:FullName>ЗАПОЛНИТЬ_FULL_NAME_EN</m2m:FullName>
<m2m:DisplayName>ЗАПОЛНИТЬ_DISPLAY_EN</m2m:DisplayName>
</m2m:Eng>
</m2m:Names>
<!-- Депозитарное место: наш код участника в НРД. УЖЕ ЗАПОЛНЕНО. -->
<m2m:DepositoryPlace>
<m2m:ParticipantCode>MC0413600000</m2m:ParticipantCode>
</m2m:DepositoryPlace>
<!-- Брокерское место — только если выступаем брокером. Иначе удалить блок. -->
<!--
<m2m:BrokerPlace>
<m2m:ParticipantCode>ЗАПОЛНИТЬ_БРОКЕРСКИЙ_КОД</m2m:ParticipantCode>
</m2m:BrokerPlace>
-->
</pf:Participant>
</pf:M2MTransferParticipantForm>
+58
View File
@@ -0,0 +1,58 @@
# Регистрация в справочнике участников M2M (НРД)
Закрывает блокер заявок с кодом **M2M14** — «Код ЭДО НРД отправителя
отсутствует в справочнике участников M2M». Технически наш контур (ИШ, СКЗИ,
канал, REST) работает; не хватает только регистрации нашего депозитарного
кода в справочнике сервиса МОСТ на стороне НРД.
## Что известно
| Параметр | Значение |
|-------------------------|---------------------------------------|
| Депозитарный код | `MC0413600000` |
| Тестовый контур | TEST3 (ГОСТ-криптография) |
| Период тестирования | 21.05.2026 — 01.09.2026 |
| Счёт (account_id) | `HL171004001C` |
| Раздел (section_id) | `36MC0413600000F00` |
## Что нужно заполнить (юридические реквизиты организации)
В файле `M2MTransferParticipantForm.example.xml` замените:
- `ЗАПОЛНИТЬ_ИНН` — ИНН организации (10 цифр для ЮЛ);
- `ЗАПОЛНИТЬ_ПОЛНОЕ_НАИМЕНОВАНИЕ` — полное наименование по уставу;
- `ЗАПОЛНИТЬ_СОКРАЩЁННОЕ` / `ЗАПОЛНИТЬ_ОТОБРАЖАЕМОЕ` — сокращённое и
отображаемое имя;
- английский блок `<m2m:Eng>` — при наличии, иначе удалить;
- `<m2m:BrokerPlace>` — только если выступаем брокером, иначе оставить
закомментированным.
Депозитарный код `MC0413600000` уже подставлен.
## Подготовка файла к отправке
Файл хранится в UTF-8 для удобства, а НРД ждёт **windows-1251** (как объявлено
в прологе XML). Перекодируйте перед отправкой:
```bash
iconv -f utf8 -t cp1251 M2MTransferParticipantForm.example.xml \
> M2MTransferParticipantForm.win1251.xml
```
(Опционально) проверьте по схеме, если установлен xmllint:
```bash
xmllint --noout \
--schema ../../DOC/M2MSchemas_260408/M2MTransferParticipantForm.xsd \
M2MTransferParticipantForm.win1251.xml
```
## Куда отправлять
Письмо на **M2MOST@nsd.ru** (служба сервиса МОСТ M2M), приложив заполненную
анкету. Текст письма — в `email-draft.txt`.
После того как НРД внесёт код `MC0413600000` в справочник участников M2M,
тестовый робот начнёт отвечать `M2MTransferDecision` вместо `M2MTransferResponse`
с ошибкой M2M14 — и заявки в bj-server будут доходить до статуса
«Подтверждена/Отклонена» по решению контрагента, а не «Отклонена (M2M14)».
+29
View File
@@ -0,0 +1,29 @@
Кому: M2MOST@nsd.ru
Тема: Регистрация участника сервиса M2M (тестовый контур TEST3, код MC0413600000)
Добрый день!
Просим зарегистрировать нашу организацию в справочнике участников сервиса
M2M (МОСТ) для тестового контура TEST3.
При отправке тестовых запросов M2MTransferRequest роботом возвращается
M2MTransferResponse со статусом ERROR и кодом M2M14 «Код ЭДО НРД отправителя
отсутствует в справочнике участников M2M». Технически интеграция настроена
(Интеграционный шлюз, ГОСТ-криптография, REST-обмен работают), требуется
только внесение нашего депозитарного кода в справочник участников M2M.
Реквизиты для регистрации:
- Депозитарный код участника: MC0413600000
- Тестовый контур: TEST3 (ГОСТ-криптография)
- Период тестирования: 21.05.2026 — 01.09.2026
- Полное наименование организации: ЗАПОЛНИТЬ
- ИНН: ЗАПОЛНИТЬ
Заполненная анкета участника (M2MTransferParticipantForm) во вложении.
После регистрации просим подтвердить — повторим тестовый сценарий с роботом.
С уважением,
ЗАПОЛНИТЬ_ФИО, ЗАПОЛНИТЬ_ДОЛЖНОСТЬ
ЗАПОЛНИТЬ_ОРГАНИЗАЦИЯ
ЗАПОЛНИТЬ_КОНТАКТ (телефон / e-mail)
@@ -0,0 +1,119 @@
<?xml version="1.0" encoding="windows-1251"?>
<M2MTransferRequest xmlns="http://nsd.ru/schemas/m2m/request">
<Header xmlns="http://nsd.ru/schemas/m2m/request">
<GUID xmlns="http://nsd.ru/schemas/m2m/types">b26440be-8a1e-4403-a35e-bc9df0da4a33</GUID>
<CreationTimestamp xmlns="http://nsd.ru/schemas/m2m/types">2026-06-18T13:13:50(МСК)</CreationTimestamp>
<SenderCode xmlns="http://nsd.ru/schemas/m2m/types">MC0413600000</SenderCode>
<ReceiverCode xmlns="http://nsd.ru/schemas/m2m/types">MC0012500000</ReceiverCode>
<CostInfo xmlns="http://nsd.ru/schemas/m2m/types">
<Yes xmlns="http://nsd.ru/schemas/m2m/types">
<Code xmlns="http://nsd.ru/schemas/m2m/types">MC0413600000</Code>
</Yes>
</CostInfo>
</Header>
<Data xmlns="http://nsd.ru/schemas/m2m/request">
<IsM2M xmlns="http://nsd.ru/schemas/m2m/types">true</IsM2M>
<InvestorInformation xmlns="http://nsd.ru/schemas/m2m/types">
<LastName xmlns="http://nsd.ru/schemas/m2m/types">Петров</LastName>
<FirstName xmlns="http://nsd.ru/schemas/m2m/types">Пётр</FirstName>
<MiddleName xmlns="http://nsd.ru/schemas/m2m/types">Петрович</MiddleName>
<IdentityDocument xmlns="http://nsd.ru/schemas/m2m/types">
<DocumentType xmlns="http://nsd.ru/schemas/m2m/types">21</DocumentType>
<DocumentSeries xmlns="http://nsd.ru/schemas/m2m/types">2001</DocumentSeries>
<DocumentNumber xmlns="http://nsd.ru/schemas/m2m/types">111111</DocumentNumber>
</IdentityDocument>
</InvestorInformation>
<TransferringDepository xmlns="http://nsd.ru/schemas/m2m/types">
<INN xmlns="http://nsd.ru/schemas/m2m/types">7702165310</INN>
</TransferringDepository>
<ReceivingDepository xmlns="http://nsd.ru/schemas/m2m/types">
<INN xmlns="http://nsd.ru/schemas/m2m/types">7722061076</INN>
</ReceivingDepository>
<TransferredSecurities xmlns="http://nsd.ru/schemas/m2m/types">
<Security xmlns="http://nsd.ru/schemas/m2m/types">
<ReferenceId xmlns="http://nsd.ru/schemas/m2m/types">M2M20260618RGVNK</ReferenceId>
<SecurityCode xmlns="http://nsd.ru/schemas/m2m/types">RU0007661625</SecurityCode>
<SecurityDetails xmlns="http://nsd.ru/schemas/m2m/types">
<ISIN xmlns="http://nsd.ru/schemas/m2m/types">RU0007661625</ISIN>
</SecurityDetails>
<Quantity xmlns="http://nsd.ru/schemas/m2m/types">
<Whole xmlns="http://nsd.ru/schemas/m2m/types">1</Whole>
</Quantity>
<SettlementAccount xmlns="http://nsd.ru/schemas/m2m/types">
<SettlementRequisites xmlns="http://nsd.ru/schemas/m2m/types">
<INN xmlns="http://nsd.ru/schemas/m2m/types">7702070139</INN>
</SettlementRequisites>
<SettlementLocation xmlns="http://nsd.ru/schemas/m2m/types">
<DeponentCode xmlns="http://nsd.ru/schemas/m2m/types">DP100200</DeponentCode>
<AccountId xmlns="http://nsd.ru/schemas/m2m/types">31MC0010000000A01</AccountId>
<SectionId xmlns="http://nsd.ru/schemas/m2m/types">A001</SectionId>
</SettlementLocation>
</SettlementAccount>
<IsolationStatus xmlns="http://nsd.ru/schemas/m2m/types">SGDN</IsolationStatus>
</Security>
<Security xmlns="http://nsd.ru/schemas/m2m/types">
<ReferenceId xmlns="http://nsd.ru/schemas/m2m/types">M2M20260618G5DW6</ReferenceId>
<SecurityCode xmlns="http://nsd.ru/schemas/m2m/types">RU000A0JP5V6</SecurityCode>
<SecurityDetails xmlns="http://nsd.ru/schemas/m2m/types">
<ISIN xmlns="http://nsd.ru/schemas/m2m/types">RU000A0JP5V6</ISIN>
</SecurityDetails>
<Quantity xmlns="http://nsd.ru/schemas/m2m/types">
<Whole xmlns="http://nsd.ru/schemas/m2m/types">1</Whole>
</Quantity>
<SettlementAccount xmlns="http://nsd.ru/schemas/m2m/types">
<SettlementRequisites xmlns="http://nsd.ru/schemas/m2m/types">
<INN xmlns="http://nsd.ru/schemas/m2m/types">7702070139</INN>
</SettlementRequisites>
<SettlementLocation xmlns="http://nsd.ru/schemas/m2m/types">
<DeponentCode xmlns="http://nsd.ru/schemas/m2m/types">DP100200</DeponentCode>
<AccountId xmlns="http://nsd.ru/schemas/m2m/types">31MC0010000000A01</AccountId>
<SectionId xmlns="http://nsd.ru/schemas/m2m/types">A001</SectionId>
</SettlementLocation>
</SettlementAccount>
<IsolationStatus xmlns="http://nsd.ru/schemas/m2m/types">SGDN</IsolationStatus>
</Security>
<Security xmlns="http://nsd.ru/schemas/m2m/types">
<ReferenceId xmlns="http://nsd.ru/schemas/m2m/types">M2M20260618CTDHY</ReferenceId>
<SecurityCode xmlns="http://nsd.ru/schemas/m2m/types">RU000A0JPKH7</SecurityCode>
<SecurityDetails xmlns="http://nsd.ru/schemas/m2m/types">
<ISIN xmlns="http://nsd.ru/schemas/m2m/types">RU000A0JPKH7</ISIN>
</SecurityDetails>
<Quantity xmlns="http://nsd.ru/schemas/m2m/types">
<Whole xmlns="http://nsd.ru/schemas/m2m/types">1</Whole>
</Quantity>
<SettlementAccount xmlns="http://nsd.ru/schemas/m2m/types">
<SettlementRequisites xmlns="http://nsd.ru/schemas/m2m/types">
<INN xmlns="http://nsd.ru/schemas/m2m/types">7702070139</INN>
</SettlementRequisites>
<SettlementLocation xmlns="http://nsd.ru/schemas/m2m/types">
<DeponentCode xmlns="http://nsd.ru/schemas/m2m/types">DP100200</DeponentCode>
<AccountId xmlns="http://nsd.ru/schemas/m2m/types">31MC0010000000A01</AccountId>
<SectionId xmlns="http://nsd.ru/schemas/m2m/types">A001</SectionId>
</SettlementLocation>
</SettlementAccount>
<IsolationStatus xmlns="http://nsd.ru/schemas/m2m/types">SGDN</IsolationStatus>
</Security>
<Security xmlns="http://nsd.ru/schemas/m2m/types">
<ReferenceId xmlns="http://nsd.ru/schemas/m2m/types">M2M20260618HQZ1Q</ReferenceId>
<SecurityCode xmlns="http://nsd.ru/schemas/m2m/types">RU000A0JPGP8</SecurityCode>
<SecurityDetails xmlns="http://nsd.ru/schemas/m2m/types">
<ISIN xmlns="http://nsd.ru/schemas/m2m/types">RU000A0JPGP8</ISIN>
</SecurityDetails>
<Quantity xmlns="http://nsd.ru/schemas/m2m/types">
<Whole xmlns="http://nsd.ru/schemas/m2m/types">1</Whole>
</Quantity>
<SettlementAccount xmlns="http://nsd.ru/schemas/m2m/types">
<SettlementRequisites xmlns="http://nsd.ru/schemas/m2m/types">
<INN xmlns="http://nsd.ru/schemas/m2m/types">7702070139</INN>
</SettlementRequisites>
<SettlementLocation xmlns="http://nsd.ru/schemas/m2m/types">
<DeponentCode xmlns="http://nsd.ru/schemas/m2m/types">DP100200</DeponentCode>
<AccountId xmlns="http://nsd.ru/schemas/m2m/types">31MC0010000000A01</AccountId>
<SectionId xmlns="http://nsd.ru/schemas/m2m/types">A001</SectionId>
</SettlementLocation>
</SettlementAccount>
<IsolationStatus xmlns="http://nsd.ru/schemas/m2m/types">SGDN</IsolationStatus>
</Security>
</TransferredSecurities>
</Data>
</M2MTransferRequest>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="windows-1251" standalone="yes"?>
<M2MTransferResponse xmlns:ns2="http://nsd.ru/schemas/m2m/types" xmlns:ns3="http://nsd.ru/schemas/m2m/response">
<ns3:GUID>00000000-0000-0000-0000-000000000000</ns3:GUID>
<ns3:StatusCode>ERROR</ns3:StatusCode>
<ns3:Response>
<ns2:Code>M2M14</ns2:Code>
<ns2:Text>Код ЭДО НРД отправителя сообщения отсутствует в справочнике участников M2M</ns2:Text>
</ns3:Response>
</M2MTransferResponse>
@@ -0,0 +1,55 @@
ПАКЕТ ДЛЯ ТЕХПОДДЕРЖКИ НРД (сервис MOEX МОСТ / M2M)
====================================================
Цель: показать НРД, ЧТО мы отправляем роботу и КАКОЙ ответ получаем, чтобы
поддержка однозначно поняла суть обращения и подтвердила/выполнила регистрацию
нашего кода в справочнике участников M2M тестового контура.
НАШИ РЕКВИЗИТЫ
-------------
Депозитарный код (= Код ЭДО НРД отправителя): MC0413600000
Тестовый контур: TEST3 (ГОСТ-криптография)
Канал ИШ: TEST3MC0413600000
Период тестирования: 21.05.2026 — 01.09.2026
ЧТО МЫ ОТПРАВЛЯЕМ → файл 01_outgoing_M2MTransferRequest.xml
--------------------------------------------------------------
Тип документа: M2MTransferRequest (#M2MTR)
GUID запроса: b26440be-8a1e-4403-a35e-bc9df0da4a33
CreationTimestamp: 2026-06-18T13:13:50(МСК)
SenderCode: MC0413600000 (наш код ЭДО НРД)
ReceiverCode: MC0012500000 (тестовый робот НРД)
Сценарий робота: 2001 (DocumentSeries), «принять все бумаги»
Бумаг в пакете: 4
Пакет подписан Интеграционным шлюзом (ИШ) сертификатом УЦ МБ.
ЧТО ОТВЕЧАЕТ НРД → файл 02_incoming_M2MTransferResponse.xml
--------------------------------------------------------------
Тип документа: M2MTransferResponse (#M2MER), подпись НРД — VALID
GUID: 00000000-0000-0000-0000-000000000000 (нулевой)
StatusCode: ERROR
Код: M2M14
Текст: «Код ЭДО НРД отправителя сообщения отсутствует в
справочнике участников M2M»
СУТЬ ОБРАЩЕНИЯ И ВОПРОС К НРД
----------------------------
Технически обмен работает: запрос доходит до сервиса МОСТ, НРД его принимает,
проверяет нашу подпись и отвечает. Отказ — на уровне справочника участников:
наш код ЭДО MC0413600000 в справочнике участников M2M (тестовый контур) пока
отсутствует.
Просим:
1) подтвердить, что отправителем M2M для нашего контура должен выступать
именно депозитарный код MC0413600000 (либо сообщить корректный код);
2) зарегистрировать наш код в справочнике участников M2M тестового контура,
чтобы робот (MC0012500000) распознавал отправителя и возвращал
M2MTransferDecision вместо ошибки M2M14.
Для поиска нашего запроса на стороне НРД: GUID b26440be-8a1e-4403-a35e-bc9df0da4a33
(ответ M2M14 приходит с нулевым GUID, поэтому указываем GUID исходного запроса).
ВЛОЖЕНИЯ
--------
01_outgoing_M2MTransferRequest.xml — наш запрос (то, что мы отправляем)
02_incoming_M2MTransferResponse.xml — ответ НРД (M2M14)
@@ -0,0 +1,44 @@
Кому: M2MOST@nsd.ru
Копия: soed@nsd.ru
Тема: M2M Автотестирование (МОСТ): ошибка M2M14 — регистрация кода MC0413600000 в справочнике участников
Добрый день!
Проводим автотестирование сервиса MOEX МОСТ (перевод M2M) с роботом в тестовом
контуре TEST3. Технически обмен настроен и работает: запрос доходит до сервиса,
НРД проверяет подпись и отвечает. Однако робот возвращает отказ:
StatusCode: ERROR
Код: M2M14
Текст: «Код ЭДО НРД отправителя сообщения отсутствует в справочнике
участников M2M»
Наши реквизиты:
- Депозитарный код (Код ЭДО НРД отправителя): MC0413600000
- Тестовый контур: TEST3, ГОСТ-криптография
- Период тестирования: 21.05.2026 — 01.09.2026
- GUID нашего запроса для поиска на вашей стороне:
b26440be-8a1e-4403-a35e-bc9df0da4a33
(ответ M2M14 приходит с нулевым GUID, поэтому указываем GUID запроса)
Во вложении — фактический обмен, чтобы предметно видеть, что мы отправляем и
что получаем в ответ:
1) 01_outgoing_M2MTransferRequest.xml — наш запрос роботу (MC0012500000),
SenderCode=MC0413600000, сценарий 2001;
2) 02_incoming_M2MTransferResponse.xml — ваш ответ с кодом M2M14.
Просим:
1) подтвердить, что отправителем M2M для нашего контура должен выступать
именно депозитарный код MC0413600000 (либо сообщить корректный код);
2) зарегистрировать наш код в справочнике участников M2M тестового контура,
чтобы робот распознавал отправителя и возвращал M2MTransferDecision
вместо ошибки M2M14.
Если для этого требуется уточнить/дополнить онлайн-заявку на участие в
тестировании систем НРД (система МОСТ, тип «M2M Автотестирование») — подскажите,
пожалуйста, что именно поправить.
С уважением,
ЗАПОЛНИТЬ_ФИО, ЗАПОЛНИТЬ_ДОЛЖНОСТЬ
ЗАПОЛНИТЬ_ОРГАНИЗАЦИЯ
ЗАПОЛНИТЬ_КОНТАКТ (телефон / e-mail)
+33
View File
@@ -0,0 +1,33 @@
# deploy/systemd — юниты для деплоя
Минимальный production-деплой Bridge-and-Join-s — два бинарника + два
systemd-юнита.
## Состав
- `bj-server.service` — основной сервис: lk-gateway BFF + admin UI +
m2m-core FSM + nsd-adapter поллер + notify. HTTP `:8080`.
- `bj-emulator.service` — имитация ЛК (QA-инструмент). HTTP `:8083`.
## Установка
```bash
sudo useradd --system --no-create-home --shell /usr/sbin/nologin bj
sudo mkdir -p /opt/bj /var/lib/bj /var/log/bj /run/bj
sudo chown bj:bj /var/lib/bj /var/log/bj /run/bj
# собрать бинарники на dev-ВМ и положить в /opt/bj/
sudo cp bin/bj-server bin/lk-emulator /opt/bj/
# юниты
sudo cp deploy/systemd/bj-server.service deploy/systemd/bj-emulator.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now bj-server bj-emulator
# проверка
systemctl status bj-server bj-emulator
journalctl -u bj-server -f
```
Веб-интерфейс: `http://<host>:8080/admin/setup` — настройка PostgreSQL,
КриптоПро CSP, ИШ НРД, callback ЛК.
+35
View File
@@ -0,0 +1,35 @@
[Unit]
Description=Bridge-and-Join-s — Crypto sidecar (Java + Валидата Клиент L)
Documentation=https://git.zetit.ru/zuevav/Bridge-and-Join-s
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
+30
View File
@@ -0,0 +1,30 @@
[Unit]
Description=Bridge-and-Join-s — эмулятор ЛК ESIA Finance (QA)
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
WorkingDirectory=/opt/bj
ExecStart=/opt/bj/lk-emulator
Restart=on-failure
RestartSec=5
Environment=BJ_HTTP_ADDR=:8083
Environment=BJ_GATEWAY_URL=http://127.0.0.1:8080
Environment=BJ_EMULATOR_PUBLIC_URL=http://127.0.0.1:8083
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ProtectKernelTunables=true
LimitNOFILE=65536
TasksMax=128
[Install]
WantedBy=multi-user.target
+46
View File
@@ -0,0 +1,46 @@
[Unit]
Description=Bridge-and-Join-s — единый сервис M2M-переводов
Documentation=https://git.zetit.ru/zuevav/Bridge-and-Join-s
After=network-online.target postgresql.service
Wants=network-online.target
[Service]
Type=simple
User=bj
Group=bj
WorkingDirectory=/opt/bj
ExecStart=/opt/bj/bj-server
Restart=on-failure
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_M2M_SENDER=MC0079200000
Environment=BJ_M2M_RECEIVER=MC0010300000
# КриптоПро CSP кладёт .so в /opt/cprocsp/lib/amd64 без записи в
# /etc/ld.so.conf.d. Чтобы Go-PKCS#11 клиент (cryptocli) нашёл
# libcppkcs11.so и его зависимости (libcapi20, libcpext, liburlretrieve),
# подмешиваем путь через LD_LIBRARY_PATH. Без этого Initialize() падает
# с CKR_FUNCTION_FAILED или 'cannot open shared object file'.
Environment=LD_LIBRARY_PATH=/opt/cprocsp/lib/amd64
# Безопасность.
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/lib/bj /var/log/bj /run/bj
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
LockPersonality=true
# Лимиты.
LimitNOFILE=65536
TasksMax=512
[Install]
WantedBy=multi-user.target
+62
View File
@@ -0,0 +1,62 @@
# Дистрибутив Интеграционного шлюза НРД (ИШ)
**Скачано с сайта НРД** (`https://www.nsd.ru/workflow/system/programs/web-service/`) 14.05.2026.
Через git не коммитим — файлы большие, ставятся отдельно.
## Файлы
| Файл | Размер | Описание |
|---|---:|---|
| `igate_100.0-765_amd64.deb` | 117 МБ | Дистрибутив ИШ для **Astra-Linux** (.deb пакет) |
| `igate_95.0-716_amd64.SGN` | 491 байт | Электронная подпись к дистрибутиву ИШ |
## Где скачать заново
- ИШ Linux: `https://old.nsd.ru/upload/docs/edo/po/igate_100.0-765_amd64.deb`
- ИШ Windows (рус): `https://old.nsd.ru/upload/docs/edo/po/igate-ru-100.0.0.764.zip`
- ИШ Windows (eng): `https://old.nsd.ru/upload/docs/edo/po/igate-en-100.0.0.764.zip`
- Все версии: `https://www.nsd.ru/workflow/system/programs/web-service/`
## Что ещё нужно (НЕ в этой папке)
### 1. СКЗИ «Валидата CSP» + АПК «Валидата Клиент L»
**Не выложено публично** — даётся НРД по запросу:
- Email НРД: `soed@nsd.ru`
- Email Московской Биржи: `pki@moex.com`
В письме указать: «Запрос дистрибутива СКЗИ Валидата CSP для Linux + временной лицензии для подключения к ЭДО НРД в рамках сервиса MOEX МОСТ M2M».
### 2. Сертификат подписи
Только от **УЦ Московской Биржи** (`https://ca.moex.com/`). Получает организация-депонент.
### 3. PostgreSQL
Если используется REST API ИШ — **обязательно** PostgreSQL (SQLite не подходит для API).
У нас PostgreSQL 16 уже работает в podman-контейнере → готово.
## Поддерживаемые ОС (из руководства по установке)
- **Astra Linux Special Edition x64** редакций 1.6, 1.7, исполнение 1 (РУСБ.10015-01/16)
- **Windows 10 / Server 2016/2019**
**РЕД ОС в списке не упомянута.** Варианты для нашей инфраструктуры:
1. Поднять отдельную Astra Linux ВМ для ИШ (рекомендуется)
2. Попробовать `dpkg -i` на РЕД ОС с `alien` (рискованно)
3. Использовать Debian/Ubuntu ВМ (близко к Astra, возможно сработает)
4. Контейнер с базовым образом `astralinux/astra-linux-edu:1.7.5` (если такой есть)
5. Запросить у НРД RPM-версию
## Контакты НРД
- Email по СЭД и дистрибутивам: `soed@nsd.ru`
- Email по форматам M2M: `M2MOST@nsd.ru`
- Сайт ИШ: `https://www.nsd.ru/workflow/system/programs/web-service/`
## Документация
Все PDF лежат в `../../DOC/`:
- `ruk_install_ish_2025_11_10.pdf` — Руководство по установке ИШ (от 10.11.2025)
- `ruk_pol_ish.pdf` — Руководство пользователя ИШ
- `QA_ish.pdf` — Часто задаваемые вопросы
- `test-case_ish.pdf` — Тест-кейсы для проверки работоспособности ИШ
- `instr_int_sh_01072025.pdf` — Инструкция по созданию заявки на тестирование
- `web_service_nrd_standard_soap_rest.pdf` — Технические рекомендации Web-сервиса ONYX
+48 -25
View File
@@ -1,33 +1,56 @@
# docs/fansy-contract/v1 — контракт данных с командой Fansy # docs/fansy-contract/v1 — контракт данных с командой Fansy
ETL Fansy → принимающая БД (`fansy-store`) реализует **другая команда ETL Fansy → принимающая БД (`fansy-store`) реализует **другая команда
разработки**. С нашей стороны: разработки**. С нашей стороны зафиксирован контракт: схема таблиц,
индексы, миграции, требования к выгрузке и тестовые данные.
1. Спроектировать таблицы по требованиям документации НРД к данным M2M. ## Состав каталога
2. Передать команде Fansy DDL и контракт данных.
3. Согласовать тип load (UPSERT в staging), окна обновления, SLA на
свежесть данных.
4. Не давать ETL-роли DDL-прав в принимающей схеме.
Состав каталога (создаём в M1, отправляем в начале M2): - **`ddl/`** — SQL-миграции PostgreSQL:
- `000__roles.sql` — роли `fansy_etl` (ETL Fansy), `bj_reader`
(наши сервисы), `bj_migrator` (миграции).
- `001__schemas.sql` — две схемы: `fansy_staging` (куда пишет ETL) и
`fansy` (рабочая, для нашего чтения). Гранты по ролям.
- `002__working.sql` — рабочие таблицы: `participants`, `securities`,
`clients`, `client_documents`, `iia_contracts`,
`settlement_requisites`, `depo_accounts`, `portfolios`,
`etl_errors`.
- `003__staging.sql` — staging-зеркало рабочих таблиц с полем
`loaded_at` и сниженными ограничениями.
- `004__seed_participants.sql` — предзаполнение справочника
участников: НРД, БКС (5406121446), Ренессанс (7709258228),
Альфа-Банк (7728168971).
- **`data-dictionary.md`** — семантика каждого поля.
- **`etl-requirements.md`** — требования к процессу выгрузки от
команды Fansy: подключение, тип load (UPSERT в staging),
SLA свежести по таблицам, обработка ошибок, окна простоя, ПДн.
- **`examples/`**:
- `example-claim.md` — какие данные `m2m-core` тянет из БД для
одной типовой M2M-заявки (с конкретными SQL).
- `seed-data.sql` — 5 тестовых клиентов, портфели, договоры —
основа для приёмочного теста.
- `ddl/``*.sql` миграции PostgreSQL для всех таблиц. ## Рабочие копии миграций
- `data-dictionary.md` — семантика каждого поля (источник в Fansy,
nullable, единицы, примеры).
- `etl-requirements.md` — требования к процессу выгрузки: тип load,
расписание, способ записи, окна простоя, обработка ошибок,
конфиденциальность.
- `examples/` — пример заявки M2M «end-to-end», 5–10 тестовых клиентов
и заявок для совместного приёмочного теста.
Минимальный набор таблиц (см. план): Те же файлы лежат в `migrations/fansy-store/` — оттуда они
применяются при инициализации БД сервиса.
- Депоненты / клиенты. ## Порядок согласования
- Документы инвестора (`IdentityDocumentCodeEnum`).
- ИИС-договоры (`IIAContractTypeEnum ∈ {T12, T03}`). 1. Передать команде Fansy ссылку на эту папку (тег `fansy-contract-v1`).
- Депо-счета и разделы (`AccountId`, `SectionId`, `DeponentCode`). 2. Обсудить с ними SLA, окна простоя, тип load.
- Реквизиты расчётов (ИНН депозитария). 3. По согласовании — дать им учётку с ролью `fansy_etl` и подсеть для
- Портфели и остатки (Whole / Fractional, `IsolationStatus = SGDN`). доступа.
- Справочник ЦБ (`SecurityCode`, `ISIN`, `Classification`, `Category`). 4. Запустить совместный приёмочный тест на `seed-data.sql`.
- Контрагенты-участники сервиса MOST (Справочник пользователей). 5. Изменения контракта — через новую папку `v2/` с changelog'ом, без
- Audit / staging-таблицы для каждой основной. правки `v1/`.
## Принципы
- Имена таблиц/колонок — `snake_case` английский.
- Комментарии к таблицам и важным колонкам — на русском
через `COMMENT ON ... IS '...'`.
- Все timestamp — `timestamptz` в UTC.
- DDL-права только у `bj_migrator`, у `fansy_etl` нет.
- ETL пишет ТОЛЬКО в `fansy_staging.*`. Перелив в `fansy.*` — на нашей
стороне после валидации.
+123
View File
@@ -0,0 +1,123 @@
# Data Dictionary — fansy-store v1
Семантика полей рабочей схемы `fansy`. Структура staging-схемы
`fansy_staging` повторяет её один-к-одному, плюс поле `loaded_at` и
отсутствие части ограничений (валидация — при переливе).
Обозначения: `?` — nullable; `!` — обязательное.
## participants — справочник контрагентов M2M
| Поле | Тип | Обяз. | Описание | Источник Fansy | Пример |
|---|---|---|---|---|---|
| inn | varchar(10) | ! | ИНН юрлица, PK | `client_master.inn` | `7702165310` |
| ogrn | varchar(15) | ? | ОГРН | `client_master.ogrn` | `1027739132563` |
| full_name_rus | text | ! | Полное наименование на русском | `client_master.full_name` | `НКО АО НРД` |
| short_name_rus | text | ? | Короткое наименование | `client_master.short_name` | `НРД` |
| display_name_rus | text | ! | Отображаемое имя для UI | `client_master.display_name` | `НРД` |
| full_name_eng | text | ? | Полное наименование на английском | `client_master.full_name_en` | `National Settlement Depository` |
| short_name_eng | text | ? | Короткое английское | `client_master.short_name_en` | `NSD` |
| display_name_eng | text | ? | Английское display | `client_master.display_name_en` | `NSD` |
| depository_participant_code | varchar(12) | ? | Код участника M2M (депозитарий) | `m2m_codes.dep_code` | `MC0010300000` |
| broker_participant_code | varchar(12) | ? | Код участника M2M (брокер) | `m2m_codes.brk_code` | `MC0079200001` |
| is_available_for_m2m | boolean | ! | Готовность к приёму M2M | `m2m_codes.is_active` | `true` |
| comment | text | ? | Свободный комментарий | — | — |
| created_at, updated_at | timestamptz | ! | Авто | — | — |
## securities — справочник ЦБ
| Поле | Тип | Обяз. | Описание | Источник Fansy | Пример |
|---|---|---|---|---|---|
| security_code | char(12) | ! | Идентификатор ЦБ в системе НРД, PK | `security_master.nsd_code` | `MM0766162534` |
| isin | char(12) | ? | ISIN | `security_master.isin` | `RU0007661625` |
| classification | varchar(4) | ? | `BOND` (облигация), `SHAR` (акция), `MFUN` (ПИФ) | `security_master.type_code` | `SHAR` |
| category | varchar(4) | ? | `ORDN`/`PREF`/`UKWN` | `security_master.category` | `ORDN` |
| security_type | varchar(256) | ? | Текстовое описание типа | `security_master.type_text` | `Акция обыкновенная` |
| security_series | text | ? | Серия выпуска (для облигаций) | `security_master.series` | `01` |
| reg_number | varchar(256) | ? | Регистрационный номер выпуска / правил ДУ ПИФ | `security_master.reg_number` | `1-01-00010-A` |
| fund_class | varchar(120) | ? | Класс паёв ПИФ | `security_master.fund_class` | `A` |
| display_name | text | ! | Отображаемое имя для UI | `security_master.display` | `Сбербанк ао` |
## clients — депоненты-физлица
| Поле | Тип | Обяз. | Описание | Источник Fansy | Пример |
|---|---|---|---|---|---|
| id | uuid | ! | PK, генерируется БД | `customer.uuid` | — |
| inn | varchar(12) | ? | ИНН (10 цифр юрлицо, 12 цифр физлицо) | `customer.inn` | `771234567890` |
| last_name | varchar(50) | ! | Фамилия | `customer.last_name` | `Иванов` |
| first_name | varchar(50) | ! | Имя | `customer.first_name` | `Иван` |
| middle_name | varchar(50) | ? | Отчество | `customer.middle_name` | `Иванович` |
| birth_date | date | ? | Дата рождения | `customer.birth_date` | `1980-01-15` |
## client_documents — документы инвестора
| Поле | Тип | Обяз. | Описание | Источник Fansy | Пример |
|---|---|---|---|---|---|
| id | uuid | ! | PK | — | — |
| client_id | uuid | ! | FK на `clients.id` | `customer_doc.customer_uuid` | — |
| document_type | varchar(2) | ! | Код документа по справочнику НРД (01..91) | `customer_doc.type_code` | `21` |
| series | text | ? | Серия (без пробелов) | `customer_doc.series` | `4512` |
| number | text | ! | Номер (без пробелов) | `customer_doc.number` | `654321` |
| issued_at | date | ? | Дата выдачи | `customer_doc.issued_at` | `2010-05-12` |
| issuer | text | ? | Кем выдан | `customer_doc.issuer` | `ОУФМС России` |
## iia_contracts — договоры ИИС
| Поле | Тип | Обяз. | Описание | Источник Fansy | Пример |
|---|---|---|---|---|---|
| id | uuid | ! | PK | — | — |
| client_id | uuid | ! | FK на `clients.id` | — | — |
| agreement_type | varchar(3) | ! | `T12` (ИИС-1/ИИС-2) или `T03` (ИИС-3) | `iia.type` | `T03` |
| agreement_number | varchar(128) | ! | Номер договора | `iia.number` | `ИИС78/2024` |
| agreement_date | date | ! | Дата заключения | `iia.signed_at` | `2026-01-15` |
| broker_inn | varchar(10) | ! | ИНН брокера, ведущего ИИС | `iia.broker_inn` | `0707083893` |
## settlement_requisites — реквизиты депозитариев
| Поле | Тип | Обяз. | Описание |
|---|---|---|---|
| id | uuid | ! | PK |
| inn | varchar(10) | ! | ИНН депозитария, UNIQUE |
| display_name | text | ! | Отображаемое имя |
## depo_accounts — счета депо
| Поле | Тип | Обяз. | Описание | Источник Fansy | Пример |
|---|---|---|---|---|---|
| id | uuid | ! | PK | — | — |
| client_id | uuid | ! | FK на `clients.id` | — | — |
| deponent_code | varchar(50) | ! | Код депонента у депозитария | `depo.deponent_code` | `DP789456` |
| account_id | varchar(50) | ! | Номер счёта депо | `depo.account_id` | `31MC0021900000F01` |
| section_id | varchar(50) | ! | Номер раздела счёта | `depo.section_id` | `P001` |
| depository_inn | varchar(10) | ! | ИНН депозитария | `depo.depository_inn` | `7702070139` |
| is_active | boolean | ! | Активен ли счёт | `depo.is_active` | `true` |
| is_trading | boolean | ! | Торговый раздел | `depo.is_trading` | `true` |
Уникальность по тройке `(deponent_code, account_id, section_id)`.
## portfolios — портфели и остатки ЦБ
| Поле | Тип | Обяз. | Описание | Источник Fansy | Пример |
|---|---|---|---|---|---|
| id | uuid | ! | PK | — | — |
| client_id | uuid | ! | FK на `clients.id` | — | — |
| depo_account_id | uuid | ! | FK на `depo_accounts.id` | — | — |
| security_code | char(12) | ! | FK на `securities.security_code` | — | `MM0766162534` |
| isin | char(12) | ? | Кэш ISIN из securities | — | `RU0007661625` |
| quantity_whole | numeric(38,0) | ? | Целое количество (для акций/облигаций) | `position.qty_whole` | `1500` |
| quantity_fractional | numeric(38,16) | ? | Дробное (для паёв) | `position.qty_fract` | `2500.7500000000000000` |
| isolation_status | varchar(4) | ! | Всегда `SGDN` | — | `SGDN` |
| valued_at | timestamptz | ! | На какой момент актуально | `position.valued_at` | `2026-03-02T11:30:00Z` |
Должно быть заполнено ровно одно из (`quantity_whole`, `quantity_fractional`).
## etl_errors — журнал ошибок ETL
| Поле | Тип | Обяз. | Описание |
|---|---|---|---|
| id | bigserial | ! | PK |
| source_table | text | ! | Таблица в Fansy |
| source_pk | text | ? | PK записи в Fansy |
| payload | jsonb | ? | Сама запись для ретрая |
| error_message | text | ! | Сообщение об ошибке |
| created_at | timestamptz | ! | Когда зафиксирована |
+26
View File
@@ -0,0 +1,26 @@
-- 000__roles.sql
-- Роли для принимающей БД fansy-store.
-- Запускать первым, отдельно от структурных миграций.
-- Пароли проставляются администратором БД через ALTER ROLE.
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fansy_etl') THEN
CREATE ROLE fansy_etl LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE INHERIT;
COMMENT ON ROLE fansy_etl IS
'Роль команды Fansy для ETL: INSERT/UPDATE/SELECT в схему fansy_staging. DDL-прав нет.';
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'bj_reader') THEN
CREATE ROLE bj_reader LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE INHERIT;
COMMENT ON ROLE bj_reader IS
'Роль сервисов Bridge-and-Join-s (m2m-core, lk-gateway) для чтения схемы fansy.';
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'bj_migrator') THEN
CREATE ROLE bj_migrator LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE INHERIT;
COMMENT ON ROLE bj_migrator IS
'Роль с DDL-правами для миграций. Только эта роль может CREATE/ALTER/DROP.';
END IF;
END
$$;
@@ -0,0 +1,23 @@
-- 001__schemas.sql
-- Две схемы: fansy_staging (куда пишет ETL Fansy) и fansy (рабочая,
-- куда переливаются данные после валидации).
CREATE SCHEMA IF NOT EXISTS fansy_staging AUTHORIZATION bj_migrator;
COMMENT ON SCHEMA fansy_staging IS
'Staging-схема. ETL Fansy делает UPSERT в эти таблицы. Сюда же пишутся ошибки выгрузки.';
CREATE SCHEMA IF NOT EXISTS fansy AUTHORIZATION bj_migrator;
COMMENT ON SCHEMA fansy IS
'Рабочая схема. Сюда переливаются актуальные данные триггерами или процедурами после валидации staging.';
-- Права по ролям. DDL-права остаются только у владельца bj_migrator.
GRANT USAGE ON SCHEMA fansy_staging TO fansy_etl;
GRANT USAGE ON SCHEMA fansy TO bj_reader;
ALTER DEFAULT PRIVILEGES IN SCHEMA fansy_staging
GRANT SELECT, INSERT, UPDATE ON TABLES TO fansy_etl;
ALTER DEFAULT PRIVILEGES IN SCHEMA fansy_staging
GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO fansy_etl;
ALTER DEFAULT PRIVILEGES IN SCHEMA fansy
GRANT SELECT ON TABLES TO bj_reader;
+231
View File
@@ -0,0 +1,231 @@
-- 002__working.sql
-- Рабочая схема fansy. Данные сюда переливаются из fansy_staging после
-- валидации. Сервисы Bridge-and-Join-s читают только эту схему.
SET search_path TO fansy, public;
-- ---------------------------------------------------------------------
-- participants — справочник участников сервиса MOST (контрагенты M2M)
-- ---------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS participants (
inn varchar(10) PRIMARY KEY,
ogrn varchar(15),
full_name_rus text NOT NULL,
short_name_rus text,
display_name_rus text NOT NULL,
full_name_eng text,
short_name_eng text,
display_name_eng text,
depository_participant_code varchar(12),
broker_participant_code varchar(12),
is_available_for_m2m boolean NOT NULL DEFAULT false,
comment text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CHECK (inn ~ '^[0-9]{10}$'),
CHECK (depository_participant_code IS NULL OR depository_participant_code ~ '^[A-Z0-9]+$'),
CHECK (broker_participant_code IS NULL OR broker_participant_code ~ '^[A-Z0-9]+$')
);
COMMENT ON TABLE participants IS 'Справочник участников сервиса MOST: депозитарии и брокеры, между которыми идут M2M-переводы.';
COMMENT ON COLUMN participants.inn IS 'ИНН юрлица (10 цифр), первичный ключ.';
COMMENT ON COLUMN participants.depository_participant_code IS 'Код участника M2M на стороне депозитария (для DepositoryPlace в M2MTransferHandbook).';
COMMENT ON COLUMN participants.broker_participant_code IS 'Код участника M2M на стороне брокера (для BrokerPlace).';
COMMENT ON COLUMN participants.is_available_for_m2m IS 'Готовность участника принимать/отправлять M2M-сообщения (включается после подписания НРД-договора).';
CREATE INDEX IF NOT EXISTS idx_participants_dep_code ON participants(depository_participant_code) WHERE depository_participant_code IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_participants_brk_code ON participants(broker_participant_code) WHERE broker_participant_code IS NOT NULL;
-- ---------------------------------------------------------------------
-- securities — справочник ценных бумаг
-- ---------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS securities (
security_code char(12) PRIMARY KEY,
isin char(12),
classification varchar(4),
category varchar(4),
security_type varchar(256),
security_series text,
reg_number varchar(256),
fund_class varchar(120),
display_name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CHECK (security_code ~ '^[0-9A-Z_/-]+$'),
CHECK (isin IS NULL OR isin ~ '^[A-Z]{2}[A-Z0-9]{9}[0-9]$'),
CHECK (classification IS NULL OR classification IN ('BOND', 'SHAR', 'MFUN')),
CHECK (category IS NULL OR category IN ('ORDN', 'PREF', 'UKWN'))
);
COMMENT ON TABLE securities IS 'Справочник ценных бумаг с их идентификаторами и классификацией.';
COMMENT ON COLUMN securities.security_code IS 'Идентификатор ценной бумаги в системе НРД (XSD SecurityCodeType).';
COMMENT ON COLUMN securities.classification IS 'Тип ценной бумаги: BOND (облигация), SHAR (акция), MFUN (ПИФ).';
COMMENT ON COLUMN securities.category IS 'Категория акций: ORDN (обыкновенные), PREF (привилегированные), UKWN (неизвестно).';
COMMENT ON COLUMN securities.reg_number IS 'Регистрационный номер выпуска (для акций и облигаций) или регномер правил доверительного управления ПИФ.';
COMMENT ON COLUMN securities.fund_class IS 'Класс паёв ПИФа (если применимо).';
CREATE INDEX IF NOT EXISTS idx_securities_isin ON securities(isin) WHERE isin IS NOT NULL;
-- ---------------------------------------------------------------------
-- clients — депоненты / инвесторы
-- ---------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS clients (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
inn varchar(12),
last_name varchar(50) NOT NULL,
first_name varchar(50) NOT NULL,
middle_name varchar(50),
birth_date date,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CHECK (inn IS NULL OR inn ~ '^[0-9]{10,12}$')
);
COMMENT ON TABLE clients IS 'Депоненты-физлица. Привязка к документам и счетам — через FK из дочерних таблиц.';
COMMENT ON COLUMN clients.inn IS 'ИНН физлица (12 цифр) или организации (10 цифр), опционально.';
COMMENT ON COLUMN clients.last_name IS 'Фамилия (XSD String50, обязательно).';
COMMENT ON COLUMN clients.first_name IS 'Имя (XSD String50, обязательно).';
COMMENT ON COLUMN clients.middle_name IS 'Отчество (XSD String50, опционально).';
CREATE INDEX IF NOT EXISTS idx_clients_inn ON clients(inn) WHERE inn IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_clients_lastname ON clients(last_name, first_name);
-- ---------------------------------------------------------------------
-- client_documents — документы инвестора
-- ---------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS client_documents (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
client_id uuid NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
document_type varchar(2) NOT NULL,
series text,
number text NOT NULL,
issued_at date,
issuer text,
created_at timestamptz NOT NULL DEFAULT now(),
CHECK (document_type IN (
'01','02','03','04','05','06','07','09','10','11','12','13','14',
'21','22','23','26','27','91'
)),
CHECK (series IS NULL OR series ~ '^\S+$'),
CHECK (number ~ '^\S+$')
);
COMMENT ON TABLE client_documents IS 'Документы, удостоверяющие личность инвестора. Коды по справочнику НРД (XSD IdentityDocumentCodeEnum).';
COMMENT ON COLUMN client_documents.document_type IS 'Код вида документа (01..91, см. XSD НРД).';
COMMENT ON COLUMN client_documents.series IS 'Серия документа (без пробелов).';
COMMENT ON COLUMN client_documents.number IS 'Номер документа (без пробелов, обязательно).';
CREATE INDEX IF NOT EXISTS idx_client_documents_client ON client_documents(client_id);
-- ---------------------------------------------------------------------
-- iia_contracts — договоры ИИС
-- ---------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS iia_contracts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
client_id uuid NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
agreement_type varchar(3) NOT NULL,
agreement_number varchar(128) NOT NULL,
agreement_date date NOT NULL,
broker_inn varchar(10) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
CHECK (agreement_type IN ('T12', 'T03')),
CHECK (broker_inn ~ '^[0-9]{10}$')
);
COMMENT ON TABLE iia_contracts IS 'Договоры на ведение ИИС инвестора.';
COMMENT ON COLUMN iia_contracts.agreement_type IS 'Тип договора: T12 — ИИС-1/ИИС-2 (старый формат); T03 — ИИС-3 (новый).';
COMMENT ON COLUMN iia_contracts.broker_inn IS 'ИНН брокера, с которым заключён договор ИИС.';
CREATE INDEX IF NOT EXISTS idx_iia_contracts_client ON iia_contracts(client_id);
CREATE INDEX IF NOT EXISTS idx_iia_contracts_broker ON iia_contracts(broker_inn);
-- ---------------------------------------------------------------------
-- settlement_requisites — реквизиты расчётов (депозитарии)
-- ---------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS settlement_requisites (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
inn varchar(10) NOT NULL UNIQUE,
display_name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
CHECK (inn ~ '^[0-9]{10}$')
);
COMMENT ON TABLE settlement_requisites IS 'Реквизиты передающего и принимающего депозитариев (XSD SettlementRequisitesType — содержит только ИНН).';
-- ---------------------------------------------------------------------
-- depo_accounts — депо-счета и разделы
-- ---------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS depo_accounts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
client_id uuid NOT NULL REFERENCES clients(id) ON DELETE RESTRICT,
deponent_code varchar(50) NOT NULL,
account_id varchar(50) NOT NULL,
section_id varchar(50) NOT NULL,
depository_inn varchar(10) NOT NULL,
is_active boolean NOT NULL DEFAULT true,
is_trading boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CHECK (depository_inn ~ '^[0-9]{10}$'),
UNIQUE (deponent_code, account_id, section_id)
);
COMMENT ON TABLE depo_accounts IS 'Счета депо инвестора и их разделы у различных депозитариев.';
COMMENT ON COLUMN depo_accounts.deponent_code IS 'Код депонента у конкретного депозитария (XSD SettlementDepositoryLocationType.DeponentCode).';
COMMENT ON COLUMN depo_accounts.account_id IS 'Номер счёта депо (XSD AccountIdType).';
COMMENT ON COLUMN depo_accounts.section_id IS 'Номер раздела счёта депо.';
COMMENT ON COLUMN depo_accounts.is_trading IS 'Признак торгового раздела (для отделения от изолированных).';
CREATE INDEX IF NOT EXISTS idx_depo_accounts_client ON depo_accounts(client_id);
CREATE INDEX IF NOT EXISTS idx_depo_accounts_deponent ON depo_accounts(deponent_code);
-- ---------------------------------------------------------------------
-- portfolios — портфели и остатки ЦБ
-- ---------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS portfolios (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
client_id uuid NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
depo_account_id uuid NOT NULL REFERENCES depo_accounts(id) ON DELETE CASCADE,
security_code char(12) NOT NULL REFERENCES securities(security_code),
isin char(12),
quantity_whole numeric(38, 0),
quantity_fractional numeric(38, 16),
isolation_status varchar(4) NOT NULL DEFAULT 'SGDN',
valued_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CHECK (isolation_status IN ('SGDN')),
CHECK ((quantity_whole IS NOT NULL) OR (quantity_fractional IS NOT NULL)),
CHECK (isin IS NULL OR isin ~ '^[A-Z]{2}[A-Z0-9]{9}[0-9]$')
);
COMMENT ON TABLE portfolios IS 'Остатки ценных бумаг на счетах депо. Whole/Fractional — choice по XSD QuantityType (заполняется ровно одно).';
COMMENT ON COLUMN portfolios.quantity_whole IS 'Целое количество (для акций, облигаций).';
COMMENT ON COLUMN portfolios.quantity_fractional IS 'Дробное количество (для паёв ПИФ, до 16 знаков после точки).';
COMMENT ON COLUMN portfolios.isolation_status IS 'Статус обособления по XSD НРД, всегда SGDN.';
COMMENT ON COLUMN portfolios.valued_at IS 'Дата/время оценки (на какой момент актуален остаток).';
CREATE INDEX IF NOT EXISTS idx_portfolios_client ON portfolios(client_id);
CREATE INDEX IF NOT EXISTS idx_portfolios_depo ON portfolios(depo_account_id);
CREATE INDEX IF NOT EXISTS idx_portfolios_security ON portfolios(security_code);
CREATE INDEX IF NOT EXISTS idx_portfolios_valued_at ON portfolios(valued_at DESC);
-- ---------------------------------------------------------------------
-- etl_errors — ошибки выгрузки Fansy
-- ---------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS etl_errors (
id bigserial PRIMARY KEY,
source_table text NOT NULL,
source_pk text,
payload jsonb,
error_message text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE etl_errors IS 'Журнал ошибок выгрузки Fansy: что не смогли записать в staging и почему.';
COMMENT ON COLUMN etl_errors.source_table IS 'Название таблицы в источнике (Fansy).';
COMMENT ON COLUMN etl_errors.source_pk IS 'Первичный ключ записи в источнике (для повторной попытки).';
COMMENT ON COLUMN etl_errors.payload IS 'Сама запись, которую не удалось загрузить (для диагностики).';
CREATE INDEX IF NOT EXISTS idx_etl_errors_created ON etl_errors(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_etl_errors_table ON etl_errors(source_table);
+109
View File
@@ -0,0 +1,109 @@
-- 003__staging.sql
-- Staging-схема. Структура повторяет fansy.*, плюс loaded_at и
-- допущения на промежуточные NULL'ы (валидация будет в процессе
-- перелива в fansy.*).
SET search_path TO fansy_staging, public;
CREATE TABLE IF NOT EXISTS participants (
inn varchar(10) PRIMARY KEY,
ogrn varchar(15),
full_name_rus text,
short_name_rus text,
display_name_rus text,
full_name_eng text,
short_name_eng text,
display_name_eng text,
depository_participant_code varchar(12),
broker_participant_code varchar(12),
is_available_for_m2m boolean,
comment text,
loaded_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE participants IS 'Staging для справочника участников. Перезаливка целиком, не чаще раза в сутки.';
CREATE TABLE IF NOT EXISTS securities (
security_code char(12) PRIMARY KEY,
isin char(12),
classification varchar(4),
category varchar(4),
security_type varchar(256),
security_series text,
reg_number varchar(256),
fund_class varchar(120),
display_name text,
loaded_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE securities IS 'Staging для справочника ЦБ. Перезаливка целиком.';
CREATE TABLE IF NOT EXISTS clients (
id uuid PRIMARY KEY,
inn varchar(12),
last_name varchar(50),
first_name varchar(50),
middle_name varchar(50),
birth_date date,
loaded_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE clients IS 'Staging для клиентов. Инкрементный UPSERT по id.';
CREATE TABLE IF NOT EXISTS client_documents (
id uuid PRIMARY KEY,
client_id uuid NOT NULL,
document_type varchar(2),
series text,
number text,
issued_at date,
issuer text,
loaded_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE client_documents IS 'Staging для документов клиента. UPSERT по id.';
CREATE TABLE IF NOT EXISTS iia_contracts (
id uuid PRIMARY KEY,
client_id uuid NOT NULL,
agreement_type varchar(3),
agreement_number varchar(128),
agreement_date date,
broker_inn varchar(10),
loaded_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE iia_contracts IS 'Staging для договоров ИИС.';
CREATE TABLE IF NOT EXISTS settlement_requisites (
id uuid PRIMARY KEY,
inn varchar(10) NOT NULL,
display_name text,
loaded_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE settlement_requisites IS 'Staging для реквизитов расчётов.';
CREATE TABLE IF NOT EXISTS depo_accounts (
id uuid PRIMARY KEY,
client_id uuid NOT NULL,
deponent_code varchar(50),
account_id varchar(50),
section_id varchar(50),
depository_inn varchar(10),
is_active boolean,
is_trading boolean,
loaded_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE depo_accounts IS 'Staging для депо-счетов.';
CREATE TABLE IF NOT EXISTS portfolios (
id uuid PRIMARY KEY,
client_id uuid NOT NULL,
depo_account_id uuid NOT NULL,
security_code char(12) NOT NULL,
isin char(12),
quantity_whole numeric(38, 0),
quantity_fractional numeric(38, 16),
isolation_status varchar(4),
valued_at timestamptz,
loaded_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE portfolios IS 'Staging для портфелей. UPSERT по id; SLA свежести — 1 мин.';
CREATE INDEX IF NOT EXISTS idx_stg_portfolios_loaded ON portfolios(loaded_at DESC);
CREATE INDEX IF NOT EXISTS idx_stg_clients_loaded ON clients(loaded_at DESC);
@@ -0,0 +1,56 @@
-- 004__seed_participants.sql
-- Предзаполнение справочника участников по DOC/Справочник пользователей.pdf
-- НРД и тестовые контрагенты Регламента M2M.
SET search_path TO fansy, public;
INSERT INTO participants (
inn, ogrn, full_name_rus, short_name_rus, display_name_rus,
full_name_eng, short_name_eng, display_name_eng,
depository_participant_code, broker_participant_code,
is_available_for_m2m, comment
) VALUES
(
'7702165310', '1027739132563',
'Небанковская кредитная организация акционерное общество "Национальный расчётный депозитарий"',
'НКО АО НРД', 'НРД',
'National Settlement Depository', 'NSD', 'NSD',
'MC0010300000', NULL, true,
'Центральный депозитарий, держатель реестра M2M-сделок.'
),
(
'5406121446', '1025402459334',
'Общество с ограниченной ответственностью "Компания БКС"',
'ООО "Компания БКС"', 'БКС',
'BCS Company Ltd', 'BCS', 'BCS',
NULL, 'MC0079200001', true,
'Брокер БКС, контрагент M2M.'
),
(
'7709258228', '1027739675260',
'Общество с ограниченной ответственностью "Ренессанс Брокер"',
'ООО "Ренессанс Брокер"', 'Ренессанс Брокер',
'Renaissance Broker Ltd', 'Renaissance', 'Renaissance',
NULL, 'MC0010300032', true,
'Брокер Ренессанс, контрагент M2M.'
),
(
'7728168971', '1027700067328',
'Акционерное общество "Альфа-Банк"',
'АО "Альфа-Банк"', 'Альфа-Банк',
'Alfa-Bank JSC', 'Alfa-Bank', 'Alfa-Bank',
NULL, 'MC0079200033', true,
'Брокер Альфа-Банк, контрагент M2M.'
)
ON CONFLICT (inn) DO UPDATE SET
full_name_rus = EXCLUDED.full_name_rus,
short_name_rus = EXCLUDED.short_name_rus,
display_name_rus = EXCLUDED.display_name_rus,
full_name_eng = EXCLUDED.full_name_eng,
short_name_eng = EXCLUDED.short_name_eng,
display_name_eng = EXCLUDED.display_name_eng,
depository_participant_code = EXCLUDED.depository_participant_code,
broker_participant_code = EXCLUDED.broker_participant_code,
is_available_for_m2m = EXCLUDED.is_available_for_m2m,
comment = EXCLUDED.comment,
updated_at = now();
@@ -0,0 +1,87 @@
# Требования к ETL Fansy → fansy-store v1
## Подключение
- СУБД: PostgreSQL 16 / PostgreSQL Pro Certified (по согласованию).
- Хост, порт, имя БД, IP-allowlist — выдаются администратором ВМ
Bridge-and-Join-s отдельно.
- Учётная запись: роль **`fansy_etl`** (создаётся миграцией
`000__roles.sql`). Пароль выдаётся через защищённый канал, не в
репозиторий.
- TLS: обязательно (`sslmode=verify-full` со стороны клиента ETL).
## Куда писать
- Только в схему `fansy_staging`. Прав на DDL нет, на схему `fansy`
тоже нет. INSERT/UPDATE/SELECT на таблицы staging.
- Запись в `fansy.*` происходит на нашей стороне после валидации.
## Тип load
- **Инкрементный UPSERT** в staging по PK (`id`):
```sql
INSERT INTO fansy_staging.clients (id, ...) VALUES (...)
ON CONFLICT (id) DO UPDATE SET ..., loaded_at = now();
```
- Справочники с относительно небольшим размером и редкой сменой
(`securities`, `participants`) разрешена **полная перезаливка** не
чаще одного раза в сутки. Полная перезаливка реализуется через
транзакцию: `TRUNCATE` + `COPY` + `COMMIT`.
## SLA на свежесть данных
| Таблица | SLA свежести |
|---|---|
| `portfolios` | ≤ 1 минута после фактического изменения в Fansy |
| `clients`, `depo_accounts`, `client_documents`, `iia_contracts` | ≤ 5 минут |
| `securities`, `participants`, `settlement_requisites` | ≤ 24 часа (по событию или по расписанию) |
## Форматы и кодировки
- Все timestamp — `timestamptz` в **UTC** (явная зона `+00`).
- Все строковые поля — UTF-8.
- ИНН, коды депонентов, ISIN, SecurityCode — в верхнем регистре.
- Числа с дробной частью (`numeric(38,16)`) — точка как разделитель,
без разделителей тысяч.
## Обработка ошибок
При нарушении CHECK-ограничений, FK или типов команда Fansy:
1. Пишет запись в `fansy_staging.etl_errors`:
```sql
INSERT INTO fansy_staging.etl_errors (source_table, source_pk, payload, error_message)
VALUES ('fansy.position', '<pk>', '<json>', '<text>');
```
2. Логирует у себя и продолжает работу.
3. Не блокирует загрузку остальных записей.
Мы (Bridge-and-Join-s) еженедельно просматриваем `etl_errors`,
поднимаем инциденты с командой Fansy.
## Окна и расписание
- Регламентное окно простоя — **с 23:00 до 23:30 МСК**, по средам.
В это время ETL может приостанавливаться для обновлений.
- Внеплановые работы — анонсируются за 2 часа в общем чате.
## Конфиденциальность
- ПДн (ФИО, документ, дата рождения) — только по нужным таблицам.
- Журналирование SQL-запросов ETL **не должно** включать значения ПДн.
- Соединения только с IP-allowlist'а.
## Контроль и наблюдаемость
Мы предоставим команде Fansy `read-only` доступ к двум представлениям:
- `fansy_staging.v_load_lag` — задержка свежести по таблицам.
- `fansy_staging.v_load_stats` — счётчики INSERT/UPDATE за сутки.
(Создаются в более позднем PR — `M3`.)
## Точка контакта
- Технический контакт со стороны Bridge-and-Join-s — указан в
`docs/architecture/plan.md`, раздел «Контакты».
- Эскалация — в общий канал интеграции, тред «fansy-store ETL».
@@ -0,0 +1,118 @@
# Пример заявки M2M end-to-end
Типовой сценарий: инвестор Иванов И.И. подаёт через ЛК заявку на
перевод 3 ценных бумаг с депо-счёта у БКС в депо-счёт у Ренессанс
Брокера. Один из переводов — паи ПИФ с дробным количеством. ИИС
тип T03.
## Какие данные нужны m2m-core для формирования M2MTransferRequest
Сервис `m2m-core` достаёт следующее из `fansy-store` (рабочая схема
`fansy`) по идентификатору клиента и набору ЦБ:
### 1. Анкета клиента (для `InvestorInformation`)
```sql
SELECT
c.last_name,
c.first_name,
c.middle_name,
d.document_type,
d.series AS document_series,
d.number AS document_number
FROM fansy.clients c
JOIN fansy.client_documents d ON d.client_id = c.id
WHERE c.id = :client_id
ORDER BY d.created_at DESC
LIMIT 1;
```
### 2. ИИС-договор (для `IIAAgreementDetails`)
```sql
SELECT agreement_type, agreement_number, agreement_date, broker_inn
FROM fansy.iia_contracts
WHERE client_id = :client_id
ORDER BY agreement_date DESC
LIMIT 1;
```
### 3. Реквизиты передающего/принимающего депозитариев
```sql
SELECT inn
FROM fansy.settlement_requisites
WHERE inn IN (:transferring_inn, :receiving_inn);
```
### 4. Депо-счета и разделы инвестора (для `SettlementAccount`)
```sql
SELECT
da.deponent_code,
da.account_id,
da.section_id,
da.depository_inn
FROM fansy.depo_accounts da
WHERE da.client_id = :client_id
AND da.depository_inn = :depository_inn
AND da.is_active = true;
```
### 5. Информация о ценных бумагах и их остатках
```sql
SELECT
p.security_code,
s.isin,
s.classification,
s.category,
s.security_type,
s.reg_number,
s.fund_class,
p.quantity_whole,
p.quantity_fractional,
p.isolation_status
FROM fansy.portfolios p
JOIN fansy.securities s USING (security_code)
WHERE p.client_id = :client_id
AND p.security_code = ANY(:requested_codes)
AND p.valued_at >= now() - interval '5 minutes';
```
### 6. Проверка достаточности остатков
```sql
SELECT
p.security_code,
COALESCE(p.quantity_whole, 0) + COALESCE(p.quantity_fractional, 0) AS available
FROM fansy.portfolios p
WHERE p.client_id = :client_id
AND p.security_code = ANY(:requested_codes);
```
Сравниваем `available` с запрошенным количеством. Если меньше — отказ
от формирования M2MTransferRequest, ошибка в ЛК.
## Какие данные команда Fansy обязана положить в staging
Из примера выше:
- `clients`: запись на инвестора Иванова И.И.
- `client_documents`: документ с DocumentType `21`.
- `iia_contracts`: договор T03 с брокером (БКС, ИНН 5406121446).
- `depo_accounts`: счёт у БКС с разделом для перевода и счёт у
Ренессанс Брокера.
- `securities`: 3 записи (SHAR/ORDN, SHAR/PREF, MFUN/UKWN с
fund_class='A').
- `portfolios`: остатки по этим 3 ЦБ на 1500 / 300 / 2500.75
соответственно.
- `participants`: НРД, БКС (5406121446), Ренессанс (7709258228) — из
начального seed.
## Результат
`m2m-core` собирает данные → формирует `M2MTransferRequest`
валидирует → подписывает (через `crypto-service`) → отправляет в НРД
через `nsd-adapter`. Получает `M2MTransferDecision` от принимающей
стороны, обновляет статус сделки и шлёт callback в ЛК.
@@ -0,0 +1,90 @@
-- seed-data.sql
-- Тестовые данные для совместного приёмочного тестирования
-- Bridge-and-Join-s ↔ команда Fansy. Запускать поверх 002__working.sql.
SET search_path TO fansy, public;
BEGIN;
-- ---------------------------------------------------------------------
-- Реквизиты депозитариев
-- ---------------------------------------------------------------------
INSERT INTO settlement_requisites (id, inn, display_name) VALUES
('00000000-0000-0000-0000-000000000001', '7702070139', 'Депозитарий Сбербанк'),
('00000000-0000-0000-0000-000000000002', '7802031669', 'Депозитарий СПб Банк'),
('00000000-0000-0000-0000-000000000003', '0702345678', 'Депозитарий БКС'),
('00000000-0000-0000-0000-000000000004', '0710987654', 'Депозитарий Ренессанс')
ON CONFLICT (inn) DO NOTHING;
-- ---------------------------------------------------------------------
-- Справочник ЦБ (минимальный)
-- ---------------------------------------------------------------------
INSERT INTO securities (security_code, isin, classification, category, security_type, reg_number, display_name) VALUES
('MM0766162534', 'RU0007661625', 'SHAR', 'ORDN', 'Акция обыкновенная', '1-01-00077-A', 'Газпром ао'),
('MM0907654321', 'RU0009029540', 'SHAR', 'PREF', 'Акция привилегированная', '2-02-00009-A', 'Сбербанк ап'),
('MM2300100100', NULL, 'MFUN', 'UKWN', 'Пай ПИФ', '23-001', 'ПИФ Альфа Капитал')
ON CONFLICT (security_code) DO NOTHING;
UPDATE securities SET fund_class = 'A' WHERE security_code = 'MM2300100100';
-- ---------------------------------------------------------------------
-- 5 тестовых клиентов
-- ---------------------------------------------------------------------
INSERT INTO clients (id, last_name, first_name, middle_name, birth_date) VALUES
('11111111-1111-1111-1111-111111111111', 'Иванов', 'Иван', 'Иванович', '1980-01-15'),
('22222222-2222-2222-2222-222222222222', 'Петров', 'Пётр', 'Петрович', '1985-06-20'),
('33333333-3333-3333-3333-333333333333', 'Сидоров', 'Сидор', 'Сидорович', '1990-11-30'),
('44444444-4444-4444-4444-444444444444', 'Кузнецов','Сергей','Михайлович','1975-03-10'),
('55555555-5555-5555-5555-555555555555', 'Соколова','Анна', 'Викторовна','1988-09-25')
ON CONFLICT (id) DO NOTHING;
-- ---------------------------------------------------------------------
-- Документы клиентов
-- ---------------------------------------------------------------------
INSERT INTO client_documents (id, client_id, document_type, series, number, issued_at, issuer) VALUES
('a0000000-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111', '21', '4512', '654321', '2010-05-12', 'ОУФМС России по Москве'),
('a0000000-0000-0000-0000-000000000002', '22222222-2222-2222-2222-222222222222', '21', '4513', '654322', '2011-06-13', 'ОУФМС России по Москве'),
('a0000000-0000-0000-0000-000000000003', '33333333-3333-3333-3333-333333333333', '21', '4514', '654323', '2012-07-14', 'ОУФМС России по СПб'),
('a0000000-0000-0000-0000-000000000004', '44444444-4444-4444-4444-444444444444', '03', '111', '222333', '1995-08-15', 'Свидетельство о рождении'),
('a0000000-0000-0000-0000-000000000005', '55555555-5555-5555-5555-555555555555', '21', '4516', '654325', '2014-09-16', 'ОУФМС России по СПб')
ON CONFLICT (id) DO NOTHING;
-- ---------------------------------------------------------------------
-- ИИС-договоры (для 3 клиентов)
-- ---------------------------------------------------------------------
INSERT INTO iia_contracts (id, client_id, agreement_type, agreement_number, agreement_date, broker_inn) VALUES
('b0000000-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111', 'T03', 'ИИС78/2024', '2026-01-15', '5406121446'),
('b0000000-0000-0000-0000-000000000002', '22222222-2222-2222-2222-222222222222', 'T12', 'ИИС79/2023', '2025-12-01', '7709258228'),
('b0000000-0000-0000-0000-000000000003', '55555555-5555-5555-5555-555555555555', 'T03', 'ИИС80/2024', '2026-02-10', '7728168971')
ON CONFLICT (id) DO NOTHING;
-- ---------------------------------------------------------------------
-- Депо-счета
-- ---------------------------------------------------------------------
INSERT INTO depo_accounts (id, client_id, deponent_code, account_id, section_id, depository_inn, is_active, is_trading) VALUES
('c0000000-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111', 'DP789456', '31MC0021900000F01', 'P001', '7702070139', true, true),
('c0000000-0000-0000-0000-000000000002', '11111111-1111-1111-1111-111111111111', 'AA789451', '33MC0021900000F02', 'F002', '7802031669', true, true),
('c0000000-0000-0000-0000-000000000003', '22222222-2222-2222-2222-222222222222', 'DP100200', '31MC0010000000A01', 'A001', '7702070139', true, true),
('c0000000-0000-0000-0000-000000000004', '33333333-3333-3333-3333-333333333333', 'DP300400', '31MC0030000000B01', 'B001', '0702345678', true, true),
('c0000000-0000-0000-0000-000000000005', '55555555-5555-5555-5555-555555555555', 'DP500600', '31MC0050000000C01', 'C001', '0710987654', true, true)
ON CONFLICT (deponent_code, account_id, section_id) DO NOTHING;
-- ---------------------------------------------------------------------
-- Портфели (остатки ЦБ)
-- ---------------------------------------------------------------------
INSERT INTO portfolios (id, client_id, depo_account_id, security_code, isin, quantity_whole, quantity_fractional, valued_at) VALUES
('d0000000-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111', 'c0000000-0000-0000-0000-000000000001', 'MM0766162534', 'RU0007661625', 1500, NULL, now()),
('d0000000-0000-0000-0000-000000000002', '11111111-1111-1111-1111-111111111111', 'c0000000-0000-0000-0000-000000000001', 'MM0907654321', 'RU0009029540', 300, NULL, now()),
('d0000000-0000-0000-0000-000000000003', '11111111-1111-1111-1111-111111111111', 'c0000000-0000-0000-0000-000000000001', 'MM2300100100', NULL, NULL, 2500.75, now()),
('d0000000-0000-0000-0000-000000000004', '22222222-2222-2222-2222-222222222222', 'c0000000-0000-0000-0000-000000000003', 'MM0766162534', 'RU0007661625', 5000, NULL, now()),
('d0000000-0000-0000-0000-000000000005', '55555555-5555-5555-5555-555555555555', 'c0000000-0000-0000-0000-000000000005', 'MM2300100100', NULL, NULL, 100.00, now())
ON CONFLICT (id) DO NOTHING;
COMMIT;
+52 -12
View File
@@ -1,18 +1,58 @@
# docs/lk-contract/v1 — контракт с ЛК клиента (ESIA Finance) # docs/lk-contract/v1 — контракт с ЛК клиента (ESIA Finance API V1)
ЛК клиента работает на платформе **ESIA Finance**, контракт описан ЛК клиента работает на платформе **ESIA Finance**, контракт описан в
в `DOC/API ЛК ЕСИА.pdf` (`/api/v1/back_office/...`, Basic HTTP, JSON, `DOC/API ЛК ЕСИА.pdf` (`/api/v1/back_office/...`, Basic HTTP, JSON,
UTF-8). UTF-8).
На этапе M1 в `lk-emulator` мы воспроизводим этот контракт для запуска На этапе M1 в `lk-emulator` (отдельный PR) мы реализуем этот контракт
сквозного потока. Реальный ЛК подключится по тому же контракту, без как «как-будто-ЛК» для запуска сквозного потока. Реальный ЛК
правок на нашей стороне. подключится по тому же контракту без правок на нашей стороне.
В этом каталоге будут: ## Состав каталога
- `openapi.yaml` наш OpenAPI-контракт `lk-gateway`, согласованный - **`openapi.yaml`** — OpenAPI 3.0 контракт lk-gateway. Описывает
с командой ЛК. четыре операции: создание, чтение, callback статуса и список заявок.
- `examples/` — примеры заявлений и ответов. Модель `Claim` включает все поля, нужные m2m-core для формирования
- `changelog.md` — версионирование контракта. `M2MTransferRequest`.
- **`examples/`**:
- `claim-request.json` — пример заявки на перевод (3 ЦБ, ИИС T03).
- `claim-response.json` — пример ответа на создание.
- `callback-confirmed.json` — callback подтверждения (status_code
INFO, 3 коды 01).
- `callback-rejected.json` — callback отказа (status_code ERROR).
- `error-422.json` — ошибка валидации подписи.
- **`changelog.md`** — версионирование контракта.
Реализация — задача M1. ## Что входит в модель заявки
- Идентификация инвестора (UUID в ЛК, ФИО, документ).
- Реквизиты передающего и принимающего депозитариев (ИНН).
- Информация об учёте стоимости (`cost_info: yes | no`).
- Опциональный блок ИИС (тип T12/T03, номер договора, дата, ИНН брокера).
- Массив ценных бумаг (1..N), каждая с:
- `security_code` (НРД-код, 12 символов),
- идентификацией (`isin` или развёрнутый `security_info`),
- количеством (целое `whole` или дробное `fractional` до 16 знаков),
- списком счетов депо (`settlement_accounts[]`).
- Подписанный XML заявления (base64) и формат подписи
(XMLDSig-GOST или XMLDSig-RSA).
## Что входит в callback статуса
- `claim_id`, `new_status`, `updated_at`.
- Для `rejected`/`timed_out`: код и текст причины из ответа НРД.
- Полное `nsd_response` (опц., для аудита).
## Порядок согласования
1. Передать команде ЛК ссылку на эту папку (тег `lk-contract-v1`).
2. Обсудить базовый URL, авторизацию (Basic, через VPN), окна.
3. Запустить `lk-emulator` на нашей стороне как опорную реализацию.
4. После приёмки — поднимать реальную интеграцию.
## Принципы
- OpenAPI 3.0, валидный по spectral / openapi-cli.
- Operation IDs в snake_case.
- Описания на русском, имена полей на английском.
- Enum'ы значений M2M — буквально как в XSD НРД (T12/T03, BOND/SHAR/MFUN, ...).
+34
View File
@@ -0,0 +1,34 @@
# Changelog контракта lk-gateway
## v1.0.0 (2026-05-14)
Первая опубликованная версия контракта. Соответствует ESIA Finance
API V1 (`DOC/API ЛК ЕСИА.pdf`).
Поддерживаемые операции:
- `POST /api/v1/back_office/claims/` — создание заявки.
- `GET /api/v1/back_office/claims` — список с фильтрами.
- `GET /api/v1/back_office/claims/{id}` — деталь.
- `PATCH /api/v1/back_office/claims/{id}` — callback статуса.
Модели:
- `Claim` — заявка с массивом `securities[]` (1..N ЦБ).
- `CreateClaimRequest` — входное тело создания.
- `StatusCallback` — обновление статуса с `nsd_response` для аудита.
- `ErrorResponse` — формат идентичен ESIA Finance V1.
Совместимость:
- HTTP Basic-auth.
- UTF-8, JSON.
- Поля enum — буквально как в XSD M2M (T12/T03, BOND/SHAR/MFUN,
ORDN/PREF/UKWN, INFO/ERROR).
## Принципы версионирования
- Несовместимые изменения — `v2/`, `v3/` (новая папка, отдельный
changelog).
- Совместимые добавления — minor-версия в этом файле.
- Документация исправлений — patch-версия в этом файле.
@@ -0,0 +1,26 @@
{
"claim_id": "c02a1d5e-c2af-4799-bab4-953f133c5133",
"new_status": "confirmed",
"updated_at": "2026-03-02T14:38:12Z",
"nsd_response": {
"guid": "c02a1d5e-c2af-4799-bab4-953f133c5133",
"status_code": "INFO",
"responses": [
{
"reference_id": "M2M2026030200001",
"code": "01",
"text": "Запрос на перевод принят и подтверждён принимающей стороной."
},
{
"reference_id": "M2M2026030200002",
"code": "01",
"text": "Запрос на перевод принят и подтверждён принимающей стороной."
},
{
"reference_id": "M2M2026030200003",
"code": "01",
"text": "Запрос на перевод принят и подтверждён принимающей стороной."
}
]
}
}
@@ -0,0 +1,17 @@
{
"claim_id": "c02a1d5e-c2af-4799-bab4-953f133c5133",
"new_status": "rejected",
"reason_code": "07",
"reason_text": "Не найдена сделка с таким GUID на стороне принимающего депозитария.",
"updated_at": "2026-03-02T14:40:00Z",
"nsd_response": {
"guid": "c02a1d5e-c2af-4799-bab4-953f133c5133",
"status_code": "ERROR",
"responses": [
{
"code": "07",
"text": "Не найдена сделка с таким GUID."
}
]
}
}
@@ -0,0 +1,104 @@
{
"investor": {
"id": "11111111-1111-1111-1111-111111111111",
"last_name": "Иванов",
"first_name": "Иван",
"middle_name": "Иванович",
"document": {
"document_type": "21",
"series": "4512",
"number": "654321"
}
},
"transferring_depository_inn": "0702345678",
"receiving_depository_inn": "0710987654",
"cost_info": {
"yes": {
"code": "MC0010300032"
}
},
"iia_agreement": {
"agreement_type": "T03",
"agreement_number": "ИИС78/2024",
"agreement_date": "2026-01-15",
"broker_inn": "0707083893"
},
"securities": [
{
"security_code": "MM0766162534",
"security_details": {
"isin": "RU0007661625"
},
"quantity": {
"whole": 1500
},
"settlement_accounts": [
{
"settlement_requisites_inn": "7702070139",
"settlement_location": {
"deponent_code": "DP789456",
"account_id": "31MC0021900000F01",
"section_id": "P001"
}
},
{
"settlement_requisites_inn": "7802031669",
"settlement_location": {
"deponent_code": "AA789451",
"account_id": "33MC0021900000F02",
"section_id": "F002"
}
}
]
},
{
"security_code": "MM0907654321",
"security_details": {
"isin": "RU0009029540"
},
"quantity": {
"whole": 300
},
"settlement_accounts": [
{
"settlement_requisites_inn": "7702070139",
"settlement_location": {
"deponent_code": "DP789456",
"account_id": "31MC0021900000F01",
"section_id": "P001"
}
}
]
},
{
"security_code": "MM2300100100",
"security_details": {
"security_info": {
"classification": "MFUN",
"category": "UKWN",
"identification_details": {
"fund_shares": {
"reg_number": "23-001",
"class": "A"
}
}
}
},
"quantity": {
"fractional": "2500.75"
},
"settlement_accounts": [
{
"settlement_requisites_inn": "7702070139",
"settlement_location": {
"deponent_code": "DP789456",
"account_id": "31MC0021900000F01",
"section_id": "P001"
}
}
]
}
],
"signed_document": "PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0id2luZG93cy0xMjUxIj8+...base64-XML...",
"signature_format": "XMLDSig-GOST"
}
@@ -0,0 +1,6 @@
{
"id": "c02a1d5e-c2af-4799-bab4-953f133c5133",
"status": "submitted",
"created_at": "2026-03-02T14:30:45Z",
"success": true
}
@@ -0,0 +1,15 @@
{
"error": true,
"status": 422,
"code": "invalid_signature",
"title": "Подпись заявления не прошла проверку",
"meta": {
"message": "Сертификат подписанта недействителен или цепочка доверия не построена.",
"errors": [
{
"field": "signed_document",
"message": "XMLDSig: certificate chain not trusted (signer CN = ИВАНОВ И.И.)."
}
]
}
}
+656
View File
@@ -0,0 +1,656 @@
openapi: 3.0.3
info:
title: lk-gateway API
version: 1.0.0
description: |
REST-контракт между сервисом `lk-gateway` (Bridge-and-Join-s) и ЛК
инвестора на платформе ESIA Finance. Версия V1 совместима с
официальным API ESIA Finance (`DOC/API ЛК ЕСИА.pdf`).
Контракт обслуживает жизненный цикл заявки M2M-перевода: создание,
получение, обновление статуса и список заявок.
Аутентификация — HTTP Basic. Кодировка — UTF-8. Тело запросов и
ответов — JSON.
servers:
- url: https://lk-gateway.bridge-and-joins.local
description: Production lk-gateway
- url: http://localhost:8080
description: Локальный эмулятор (lk-emulator)
security:
- basicAuth: []
paths:
/api/v1/back_office/claims/:
post:
operationId: create_claim
summary: Создать заявку на M2M-перевод
description: |
Принимает подписанное (XMLDSig) заявление инвестора. Сервис
проверяет подпись через crypto-service, валидирует данные,
создаёт сделку и инициирует отправку в НРД.
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateClaimRequest'
examples:
full_claim:
summary: Заявка с тремя ЦБ, ИИС T03
externalValue: ./examples/claim-request.json
responses:
'201':
description: Заявка создана
content:
application/json:
schema:
$ref: '#/components/schemas/CreateClaimResponse'
'400':
description: Невалидные входные данные
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'401':
description: Не авторизован
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'422':
description: Подпись неверна или данные не прошли бизнес-валидацию
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/v1/back_office/claims:
get:
operationId: list_claims
summary: Список заявок
description: Возвращает список заявок с фильтрацией по статусу, периоду и инвестору.
parameters:
- name: status
in: query
description: Фильтр по статусу.
required: false
schema:
$ref: '#/components/schemas/ClaimStatus'
- name: investor_id
in: query
description: UUID инвестора в ЛК.
required: false
schema:
type: string
format: uuid
- name: created_from
in: query
description: Нижняя граница периода создания (ISO 8601, UTC).
required: false
schema:
type: string
format: date-time
- name: created_to
in: query
description: Верхняя граница периода создания (ISO 8601, UTC).
required: false
schema:
type: string
format: date-time
- name: limit
in: query
required: false
schema:
type: integer
minimum: 1
maximum: 200
default: 50
- name: offset
in: query
required: false
schema:
type: integer
minimum: 0
default: 0
responses:
'200':
description: Страница списка заявок
content:
application/json:
schema:
$ref: '#/components/schemas/ClaimsPage'
/api/v1/back_office/claims/{id}:
get:
operationId: get_claim
summary: Получить заявку и её статус
parameters:
- name: id
in: path
required: true
description: UUID заявки.
schema:
type: string
format: uuid
responses:
'200':
description: Заявка
content:
application/json:
schema:
$ref: '#/components/schemas/Claim'
'404':
description: Заявка не найдена
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
patch:
operationId: update_claim_status
summary: Callback обновления статуса (от lk-gateway к ЛК)
description: |
Используется лгатвей-ом для уведомления ЛК о смене статуса
сделки на стороне НРД. Подтверждение, отказ или таймаут.
parameters:
- name: id
in: path
required: true
description: UUID заявки.
schema:
type: string
format: uuid
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/StatusCallback'
examples:
confirmed:
summary: Подтверждение
externalValue: ./examples/callback-confirmed.json
rejected:
summary: Отказ
externalValue: ./examples/callback-rejected.json
responses:
'200':
description: Callback принят
content:
application/json:
schema:
$ref: '#/components/schemas/CallbackResponse'
'404':
description: Заявка не найдена
components:
securitySchemes:
basicAuth:
type: http
scheme: basic
schemas:
ClaimStatus:
type: string
description: |
Жизненный цикл заявки на M2M-перевод.
- `draft` — черновик, ещё не подписан.
- `signed` — подписан, но не отправлен.
- `submitted` — отправлен в НРД.
- `in_progress` — НРД принял, ждём решение от принимающей стороны.
- `confirmed` — подтверждён, перевод исполнен.
- `rejected` — отклонён.
- `timed_out` — превышен SLA, ручной разбор.
enum:
- draft
- signed
- submitted
- in_progress
- confirmed
- rejected
- timed_out
SignatureFormat:
type: string
description: Тип цифровой подписи заявления.
enum:
- XMLDSig-GOST
- XMLDSig-RSA
AgreementType:
type: string
description: |
Тип договора ИИС.
- `T12` — ИИС-1 или ИИС-2 (старый формат).
- `T03` — ИИС-3 (новый).
enum:
- T12
- T03
SecurityClassification:
type: string
description: Тип ценной бумаги.
enum:
- BOND
- SHAR
- MFUN
SecurityCategory:
type: string
description: Категория акций.
enum:
- ORDN
- PREF
- UKWN
Investor:
type: object
description: Анкета инвестора.
required:
- last_name
- first_name
- document
properties:
id:
type: string
format: uuid
description: UUID инвестора в ЛК (если уже известен).
last_name:
type: string
maxLength: 50
example: Иванов
first_name:
type: string
maxLength: 50
example: Иван
middle_name:
type: string
maxLength: 50
example: Иванович
document:
$ref: '#/components/schemas/IdentityDocument'
IdentityDocument:
type: object
description: Документ, удостоверяющий личность.
required:
- document_type
- number
properties:
document_type:
type: string
pattern: '^(0[1-7]|09|1[0-4]|2[1-37]|26|91)$'
description: Код документа по справочнику НРД.
example: '21'
series:
type: string
pattern: '^\S+$'
example: '4512'
number:
type: string
pattern: '^\S+$'
example: '654321'
Quantity:
type: object
description: Количество ценных бумаг — choice (ровно одно поле).
properties:
whole:
type: integer
format: int64
minimum: 1
example: 1500
fractional:
type: string
description: Десятичная строка с не более 16 знаками после точки.
pattern: '^[0-9]+(\.[0-9]{1,16})?$'
example: '2500.75'
FundShares:
type: object
required:
- reg_number
properties:
reg_number:
type: string
maxLength: 256
example: '23-001'
class:
type: string
maxLength: 120
example: A
IdentificationDetails:
type: object
description: Идентификация ЦБ — choice (ровно одно поле).
properties:
reg_number:
type: string
maxLength: 20
fund_shares:
$ref: '#/components/schemas/FundShares'
SecurityInfo:
type: object
description: Описание ЦБ при отсутствии ISIN.
required:
- classification
- category
- identification_details
properties:
classification:
$ref: '#/components/schemas/SecurityClassification'
category:
$ref: '#/components/schemas/SecurityCategory'
security_type:
type: string
maxLength: 256
security_series:
type: string
identification_details:
$ref: '#/components/schemas/IdentificationDetails'
SecurityDetails:
type: object
description: Идентификация ЦБ — choice (ровно одно поле).
properties:
isin:
type: string
pattern: '^[A-Z]{2}[A-Z0-9]{9}[0-9]$'
example: RU0007661625
security_info:
$ref: '#/components/schemas/SecurityInfo'
SettlementLocation:
type: object
required:
- deponent_code
- account_id
- section_id
properties:
deponent_code:
type: string
maxLength: 50
example: DP789456
account_id:
type: string
maxLength: 50
example: 31MC0021900000F01
section_id:
type: string
maxLength: 50
example: P001
SettlementAccount:
type: object
required:
- settlement_requisites_inn
- settlement_location
properties:
settlement_requisites_inn:
type: string
pattern: '^[0-9]{10}$'
example: '7702070139'
settlement_location:
$ref: '#/components/schemas/SettlementLocation'
ClaimSecurity:
type: object
required:
- security_code
- security_details
- quantity
- settlement_accounts
properties:
security_code:
type: string
pattern: '^[0-9A-Z_/-]{12}$'
example: MM0766162534
security_details:
$ref: '#/components/schemas/SecurityDetails'
quantity:
$ref: '#/components/schemas/Quantity'
settlement_accounts:
type: array
minItems: 1
items:
$ref: '#/components/schemas/SettlementAccount'
CostInfo:
type: object
description: |
Информация об учёте стоимости приобретения. Choice: либо
`yes` (с кодом депонента-источника), либо `no` (учёт не ведётся).
properties:
yes:
type: object
required: [code]
properties:
code:
type: string
pattern: '^[A-Z0-9]+$'
maxLength: 12
example: MC0010300032
no:
type: object
description: Пустой объект — учёт не ведётся.
IIAAgreement:
type: object
description: Реквизиты договора ИИС (нужно, если перевод идёт по ИИС).
required:
- agreement_type
- agreement_number
- agreement_date
- broker_inn
properties:
agreement_type:
$ref: '#/components/schemas/AgreementType'
agreement_number:
type: string
maxLength: 128
example: ИИС78/2024
agreement_date:
type: string
format: date
example: '2026-01-15'
broker_inn:
type: string
pattern: '^[0-9]{10}$'
example: '0707083893'
CreateClaimRequest:
type: object
required:
- investor
- transferring_depository_inn
- receiving_depository_inn
- cost_info
- securities
- signed_document
- signature_format
properties:
investor:
$ref: '#/components/schemas/Investor'
transferring_depository_inn:
type: string
pattern: '^[0-9]{10}$'
receiving_depository_inn:
type: string
pattern: '^[0-9]{10}$'
cost_info:
$ref: '#/components/schemas/CostInfo'
iia_agreement:
$ref: '#/components/schemas/IIAAgreement'
securities:
type: array
minItems: 1
items:
$ref: '#/components/schemas/ClaimSecurity'
signed_document:
type: string
format: byte
description: Подписанный XML заявления в base64.
signature_format:
$ref: '#/components/schemas/SignatureFormat'
CreateClaimResponse:
type: object
required: [id, status, created_at, success]
properties:
id:
type: string
format: uuid
status:
$ref: '#/components/schemas/ClaimStatus'
created_at:
type: string
format: date-time
success:
type: boolean
example: true
Claim:
type: object
description: Полная сущность заявки.
required:
- id
- status
- created_at
- updated_at
- investor
- transferring_depository_inn
- receiving_depository_inn
- securities
properties:
id:
type: string
format: uuid
status:
$ref: '#/components/schemas/ClaimStatus'
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
investor:
$ref: '#/components/schemas/Investor'
transferring_depository_inn:
type: string
pattern: '^[0-9]{10}$'
receiving_depository_inn:
type: string
pattern: '^[0-9]{10}$'
cost_info:
$ref: '#/components/schemas/CostInfo'
iia_agreement:
$ref: '#/components/schemas/IIAAgreement'
securities:
type: array
items:
$ref: '#/components/schemas/ClaimSecurity'
last_callback:
$ref: '#/components/schemas/StatusCallback'
ClaimsPage:
type: object
required: [items, total, limit, offset]
properties:
items:
type: array
items:
$ref: '#/components/schemas/Claim'
total:
type: integer
minimum: 0
limit:
type: integer
offset:
type: integer
StatusCallback:
type: object
description: Callback обновления статуса от lk-gateway к ЛК.
required:
- claim_id
- new_status
- updated_at
properties:
claim_id:
type: string
format: uuid
new_status:
$ref: '#/components/schemas/ClaimStatus'
reason_code:
type: string
maxLength: 6
description: Код причины (для rejected/timed_out) из M2MTransferResponse или M2MTransferDecision.
example: '01'
reason_text:
type: string
maxLength: 1024
updated_at:
type: string
format: date-time
nsd_response:
type: object
description: Оригинал ответа НРД (необязательно, для аудита).
properties:
guid:
type: string
format: uuid
status_code:
type: string
enum: [INFO, ERROR]
responses:
type: array
items:
type: object
properties:
reference_id:
type: string
pattern: '^M2M[A-Z0-9]{13}$'
code:
type: string
text:
type: string
CallbackResponse:
type: object
required: [success]
properties:
success:
type: boolean
ErrorResponse:
type: object
description: Формат ошибки, идентичный API ESIA Finance V1.
required: [error, status]
properties:
error:
type: boolean
example: true
status:
type: integer
example: 422
code:
type: string
example: invalid_signature
title:
type: string
example: Подпись не прошла проверку
meta:
type: object
properties:
message:
type: string
errors:
type: array
items:
type: object
properties:
field:
type: string
message:
type: string
+6 -5
View File
@@ -12,11 +12,12 @@ PR-1 → PR-N. Каждая задача — самостоятельный ос
| PR | Файл | Статус | Зависит от | | PR | Файл | Статус | Зависит от |
|----|------|--------|-----------| |----|------|--------|-----------|
| PR-1 | `PR-1-go-models-m2m.md` | выполнено | — | | PR-1 | `PR-1-go-models-m2m.md` | выполнено | — |
| PR-2 | `PR-2-fansy-ddl.md` | готово к запуску | — (параллельно с PR-1) | | PR-2 | `PR-2-fansy-ddl.md` | выполнено | — (параллельно с PR-1) |
| PR-3 | `PR-3-lk-openapi.md` | готово к запуску | — (параллельно с PR-1) | | PR-3 | `PR-3-lk-openapi.md` | выполнено | — (параллельно с PR-1) |
| PR-4 | `PR-4-m2m-core-skeleton.md` | готово к запуску | PR-1 | | PR-4 | `PR-4-m2m-core-skeleton.md` | выполнено | PR-1 |
| PR-5 | `PR-5-nsd-adapter-skeleton.md` | ждёт ИШ НРД и сертификаты | PR-1, PR-4 | | PR-5 | `PR-5-nsd-adapter-skeleton.md` | выполнено (каркас) | PR-1, PR-4 |
| PR-6 | `PR-6-crypto-service-skeleton.md` | ждёт КриптоПро JCP | PR-1 | | PR-6 | `PR-6-crypto-service-skeleton.md` | выполнено (скелет) | PR-1 |
| M2-шаг-1 | сквозной поток: lk-gateway BFF + admin web + lk-emulator + mock NSD | выполнено | PR-1, PR-3, PR-4 |
## Как запустить задачу ## Как запустить задачу
+19 -1
View File
@@ -1,3 +1,21 @@
module git.zetit.ru/zuevav/Bridge-and-Join-s module git.zetit.ru/zuevav/Bridge-and-Join-s
go 1.23 go 1.25.0
require (
github.com/jackc/pgx/v5 v5.7.4
golang.org/x/text v0.34.0
google.golang.org/grpc v1.81.1
google.golang.org/protobuf v1.36.11
)
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
)
+64
View File
@@ -0,0 +1,64 @@
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=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
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/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=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
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=
+29
View File
@@ -0,0 +1,29 @@
# internal/cryptocli — Go-клиент crypto-service
Реализация `m2mcore.CryptoVerifier` поверх gRPC по Unix Domain Socket.
## Состояние
На M1 — заглушка. Подключается к UDS-сокету crypto-service, проверяет
доступность и возвращает `ErrNotImplemented`. Этого достаточно, чтобы:
- m2m-core и другие сервисы могли инжектить клиент без условных веток;
- логи различали «сокета нет» (например, контейнер crypto-service не
запущен) от «сокет есть, но криптография не подключена» (нет лицензии
КриптоПро JCP).
## Когда станет полноценным
После генерации gRPC-стабов из `services/crypto-service/proto/crypto.proto`
(требует `protoc` + плагины), что в свою очередь требует доступа к
Maven Central / Go module proxy через прокси zetit.
## API
```go
cli := cryptocli.NewClient("/run/bj/crypto.sock")
info, err := cli.VerifyXMLDSig(ctx, signedXML)
if errors.Is(err, cryptocli.ErrNotImplemented) {
// M1: запасной путь (ручная проверка / откладывание).
}
```
+305
View File
@@ -0,0 +1,305 @@
// Package cryptocli — gRPC-клиент к crypto-service по Unix Domain
// Socket. Сам Go-процесс не выполняет криптографию — всё делает
// Java-сайдкар (services/crypto-service) поверх АПК «Валидата
// Клиент L».
//
// На дев-стендах без поднятого сайдкара (стандартный путь
// /run/bj/crypto.sock не существует) клиент возвращает понятную
// ошибку «провайдер недоступен» и lk-gateway работает в stub-режиме:
// XMLDSig-подписи проходят без проверки (только для демо).
package cryptocli
import (
"context"
"errors"
"fmt"
"sync"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/cryptocli/cryptopb"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
)
// Provider — тип СКЗИ-провайдера (информативный — реальный выбор
// делает crypto-service через переменную BJ_CRYPTO_PROVIDER).
type Provider string
const (
ProviderStub Provider = "stub"
ProviderValidata Provider = "validata"
)
// DefaultModulePath сохранена для обратной совместимости с UI;
// текущий путь интеграции — не PKCS#11-модуль, а UDS-сокет
// crypto-service. Возвращаемое значение информативное.
func DefaultModulePath(p Provider) string {
if p == ProviderValidata {
return "/opt/Validata/VDCSP/lib/amd64 (через сайдкар, не PKCS#11)"
}
return ""
}
// Config — конфигурация клиента.
type Config struct {
// SocketPath — путь к UDS-сокету crypto-service.
// Пустое значение = /run/bj/crypto.sock.
SocketPath string
// Provider — желаемый провайдер; информативно (см. выше).
Provider Provider
// ModulePath — сохраняется для UI; в gRPC-режиме не используется.
ModulePath string
// Timeout — таймаут одной gRPC-операции.
Timeout time.Duration
}
// Client — gRPC-клиент к crypto-service.
type Client struct {
cfg Config
mu sync.Mutex
conn *grpc.ClientConn
api cryptopb.CryptoServiceClient
}
// New создаёт клиент. Само соединение поднимается лениво при первом
// вызове.
func New(cfg Config) *Client {
if cfg.Timeout == 0 {
cfg.Timeout = 5 * time.Second
}
if cfg.SocketPath == "" {
cfg.SocketPath = "/run/bj/crypto.sock"
}
return &Client{cfg: cfg}
}
// Close закрывает gRPC-соединение.
func (c *Client) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn != nil {
err := c.conn.Close()
c.conn = nil
c.api = nil
return err
}
return nil
}
// ensureConn устанавливает gRPC-канал к UDS-сокету при первом
// использовании. Используем встроенный в grpc-go резолвер unix:.
func (c *Client) ensureConn() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.api != nil {
return nil
}
target := "unix:" + c.cfg.SocketPath
conn, err := grpc.NewClient(target,
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
return fmt.Errorf("cryptocli: dial %s: %w", c.cfg.SocketPath, err)
}
c.conn = conn
c.api = cryptopb.NewCryptoServiceClient(conn)
return nil
}
// Health — gRPC Health-вызов. Если сокет недоступен (сайдкар не
// поднят) — вернёт «провайдер недоступен» с явной ошибкой.
func (c *Client) Health(ctx context.Context) (HealthInfo, error) {
if c.cfg.Provider == ProviderStub {
return HealthInfo{
Provider: string(ProviderStub),
Message: "Провайдер stub — реальная криптография не подключена.",
}, nil
}
if err := c.ensureConn(); err != nil {
return HealthInfo{}, err
}
cctx, cancel := context.WithTimeout(ctx, c.cfg.Timeout)
defer cancel()
resp, err := c.api.Health(cctx, &cryptopb.HealthRequest{})
if err != nil {
return HealthInfo{}, fmt.Errorf("cryptocli: Health: %w", err)
}
return HealthInfo{
Provider: resp.GetProvider(),
Message: resp.GetVersion(),
ModulePath: c.cfg.SocketPath,
}, nil
}
// Certificate — упрощённое описание сертификата (для совместимости с
// прежним UI). В gRPC-режиме crypto-service возвращает информацию о
// подписанте через VerifyResponse; полный список сертификатов
// (FindCertificates) пока не реализован — для UI возвращаем пустой
// список.
type Certificate struct {
SlotID uint
TokenLabel string
Label string
SubjectCN string
IssuerCN string
Serial string
NotBefore time.Time
NotAfter time.Time
INN string
DER []byte
HasPrivateKey bool
}
// FindCertificates пока возвращает пустой список — список ключей
// управляется самой Валидатой через её собственный справочник (zcs),
// а bj-server о конкретных сертификатах узнаёт по результатам
// Verify/Sign-операций. Эту функцию переопределим позже отдельным
// gRPC-методом ListCertificates если потребуется.
func (c *Client) FindCertificates(_ context.Context) ([]Certificate, error) {
return nil, nil
}
// Shutdown — отправляет команду «выйти с exit-code 2» сайдкару.
// systemd с Restart=on-failure поднимет его обратно. Возвращает
// ошибку если соединение разорвалось (что нормально и означает что
// сайдкар уже завершается).
func (c *Client) Shutdown(ctx context.Context) error {
if c.cfg.Provider == ProviderStub {
return errors.New("provider=stub: некуда отправлять Shutdown")
}
if err := c.ensureConn(); err != nil {
return err
}
cctx, cancel := context.WithTimeout(ctx, c.cfg.Timeout)
defer cancel()
_, err := c.api.Shutdown(cctx, &cryptopb.ShutdownRequest{})
// Закрываем соединение, чтобы не держать ссылку на падающий процесс.
_ = c.Close()
return err
}
// ActivateResult — результат переключения профиля Валидаты.
type ActivateResult struct {
OK bool
Provider string
Profile string
Message string
}
// Activate переключает crypto-service на указанный профиль pki1.conf.
// Пустая строка = minimal mode (без профиля).
func (c *Client) Activate(ctx context.Context, profile string) (ActivateResult, error) {
if c.cfg.Provider == ProviderStub {
return ActivateResult{
OK: false,
Provider: string(ProviderStub),
Message: "Провайдер stub — переключение профиля недоступно.",
}, nil
}
if err := c.ensureConn(); err != nil {
return ActivateResult{}, err
}
cctx, cancel := context.WithTimeout(ctx, c.cfg.Timeout)
defer cancel()
resp, err := c.api.Activate(cctx, &cryptopb.ActivateRequest{Profile: profile})
if err != nil {
return ActivateResult{}, fmt.Errorf("cryptocli: Activate: %w", err)
}
return ActivateResult{
OK: resp.GetOk(),
Provider: resp.GetProvider(),
Profile: resp.GetProfile(),
Message: resp.GetMessage(),
}, nil
}
// VerifyXMLDSig — проксирует в crypto-service.VerifyXMLDSig.
// Реализует m2mcore.CryptoVerifier — поэтому возвращает CertInfo,
// заполненный из gRPC-ответа.
func (c *Client) VerifyXMLDSig(ctx context.Context, payload []byte) (m2mcore.CertInfo, error) {
if c.cfg.Provider == ProviderStub {
return m2mcore.CertInfo{
SignerCN: "stub-verifier",
}, nil
}
if err := c.ensureConn(); err != nil {
return m2mcore.CertInfo{}, err
}
cctx, cancel := context.WithTimeout(ctx, c.cfg.Timeout)
defer cancel()
resp, err := c.api.VerifyXMLDSig(cctx, &cryptopb.VerifyRequest{
Payload: payload,
})
if err != nil {
return m2mcore.CertInfo{}, fmt.Errorf("cryptocli: VerifyXMLDSig: %w", err)
}
if !resp.GetValid() {
var msg string
if errs := resp.GetErrors(); len(errs) > 0 {
msg = errs[0]
} else {
msg = "подпись недействительна"
}
return m2mcore.CertInfo{}, errors.New("cryptocli: " + msg)
}
return m2mcore.CertInfo{
SignerCN: resp.GetSignerCn(),
SignerINN: resp.GetSignerInn(),
Serial: resp.GetSerial(),
NotBefore: time.Unix(resp.GetNotBefore(), 0),
NotAfter: time.Unix(resp.GetNotAfter(), 0),
}, nil
}
// SignXMLDSig — проксирует в crypto-service.SignXMLDSig. Возвращает
// DER-байты CMS detached signature (готовы к включению в XMLDSig-обёртку
// или к самостоятельной отправке как .p7s).
//
// keyAlias — alias ключа из ПСП Валидаты (пустой = ключ по умолчанию
// активного профиля). profile — имя профиля в pki1.conf, пустой = тот
// что инициализирован.
func (c *Client) SignXMLDSig(ctx context.Context, payload []byte, keyAlias, profile string) ([]byte, error) {
if c.cfg.Provider == ProviderStub {
return nil, errors.New("provider=stub: подпись недоступна")
}
if err := c.ensureConn(); err != nil {
return nil, err
}
cctx, cancel := context.WithTimeout(ctx, c.cfg.Timeout)
defer cancel()
resp, err := c.api.SignXMLDSig(cctx, &cryptopb.SignRequest{
Payload: payload,
KeyAlias: keyAlias,
Profile: profile,
})
if err != nil {
return nil, fmt.Errorf("cryptocli: SignXMLDSig: %w", err)
}
return resp.GetSignedXml(), nil
}
// HealthInfo — что показывает /admin/setup → СКЗИ.
type HealthInfo struct {
Provider string
ModulePath string // в gRPC-режиме — UDS-сокет
CryptokiVersion string // не используется
ManufacturerID string // не используется
LibraryVersion string // не используется
Tokens []TokenInfo
Message string
}
// TokenInfo — для совместимости с UI; в gRPC-режиме пустой.
type TokenInfo struct {
SlotID uint
Label string
Manufacturer string
Model string
SerialNumber string
Error string
}
// Ensure Client реализует m2mcore.CryptoVerifier.
var _ m2mcore.CryptoVerifier = (*Client)(nil)
+52
View File
@@ -0,0 +1,52 @@
package cryptocli_test
import (
"context"
"strings"
"testing"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/cryptocli"
)
// TestStubProviderHealthOK — провайдер stub не лезет в gRPC,
// возвращает информативный Health без ошибки.
func TestStubProviderHealthOK(t *testing.T) {
cli := cryptocli.New(cryptocli.Config{Provider: cryptocli.ProviderStub})
defer cli.Close()
h, err := cli.Health(context.Background())
if err != nil {
t.Fatalf("Health: %v", err)
}
if h.Provider != string(cryptocli.ProviderStub) {
t.Errorf("Provider = %q, ожидался stub", h.Provider)
}
if !strings.Contains(h.Message, "stub") {
t.Errorf("сообщение не содержит 'stub': %q", h.Message)
}
}
// TestValidataProviderNoSocket — провайдер validata пытается дойти до
// сайдкара, но в тестах сокета нет. gRPC-клиент создаётся лениво
// (NewClient не возвращает ошибку), а ошибка приходит при первом RPC.
func TestValidataProviderNoSocket(t *testing.T) {
cli := cryptocli.New(cryptocli.Config{
Provider: cryptocli.ProviderValidata,
SocketPath: "/nonexistent/crypto.sock",
})
defer cli.Close()
_, err := cli.Health(context.Background())
if err == nil {
t.Fatal("ожидалась ошибка о недоступном сокете")
}
}
// TestDefaultModulePath — информативный текст для UI.
func TestDefaultModulePath(t *testing.T) {
if cryptocli.DefaultModulePath(cryptocli.ProviderStub) != "" {
t.Error("DefaultModulePath(stub) должен быть пустым")
}
v := cryptocli.DefaultModulePath(cryptocli.ProviderValidata)
if v == "" {
t.Error("DefaultModulePath(validata) не должен быть пустым")
}
}

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