Compare commits

...

7 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
129 changed files with 12733 additions and 1773 deletions
+6
View File
@@ -1,6 +1,7 @@
# Сборки
/bin/
/dist/
!/dist/ish/README.md
*.exe
*.test
*.out
@@ -62,3 +63,8 @@ test-results/
# 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.
+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.
+94 -18
View File
@@ -1,6 +1,6 @@
# Bridge-and-Join-s — отчёт о ходе работ
**Дата:** 14.05.2026
**Дата:** 14.05.2026 (3-я редакция за день — скачан дистрибутив ИШ + полная документация ИШ)
**Контур:** дев-стенд РЕД ОС 8 (10.10.10.22), bj-server на :8080, lk-emulator на :8083
**Целевая интеграция:** сервис MOEX МОСТ (M2M) через НКО АО НРД
@@ -20,16 +20,20 @@
| Контейнеры КриптоПро с флешки (импорт в HDIMAGE) | **80%** | ⚠ Без UI-импорта сертификата из контейнера |
| Лента новостей + мониторинг сайта НРД (doc-watcher) | **100%** | ✅ Готово |
| Эмулятор робота-автотеста НРД (внутренний mock) | **90%** | ⚠ Сценарий 3333 — частично |
| Реальное подключение к роботу на TEST3 НРД | **0%** | ⏳ Заблокировано на ИШ и сертификате |
| Интеграционный шлюз НРД (ИШ) | **0%** | ⏳ Не скачан, не установлен |
| Сертификат УЦ Московской Биржи для подписи | **0%** | ⏳ Не получен |
| Реальное подключение к роботу на 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 против реального НРД |
**Общая готовность системы:** **65%** (по объёму функциональности)
**Готовность к интеграционному тесту с роботом:** **≈ 80%** (зависит только от внешних факторов — ИШ, сертификат)
**Общая готовность системы:** **75%** (по объёму функциональности)
**Готовность к интеграционному тесту с роботом:** **≈ 88%** (зависит только от внешних факторов: Astra Linux ВМ, Валидата CSP, сертификат УЦ МБ — на нашей стороне установщик готов)
---
@@ -81,6 +85,67 @@
- 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).
@@ -93,25 +158,36 @@
### Внешние блокеры (без них не двинемся к реальному НРД)
1. **Дистрибутив ИШ НРД**
- Где взять: https://www.nsd.ru/workflow/system/programs/#0-widget-faq-0-4
- Что неизвестно: системные требования (ОС, СКЗИ — JCP или CSP, БД, Java), нужен ли отдельный договор/лицензия. В наших документах эти детали отсутствуют — они в «Руководстве по установке ИШ», которого у нас нет.
- Что нужно сделать: запросить «Руководство по установке и настройке ПО Интеграционный шлюз НРД» у НРД (контакт: `M2MOST@nsd.ru`).
- Срок: зависит от ответа НРД.
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. **Сертификат подписи УЦ Московской Биржи** (ca.moex.com)
- Нужен для подписи отправляемых сообщений (через ИШ — кладётся в ИШ; без ИШ — в bj-server).
- Что нужно: оформить заявку в УЦ МБ от организации, получить сертификат + приватный ключ (на токене или в контейнере).
- Срок: зависит от УЦ.
2. **СКЗИ «Валидата CSP» + АПК «Валидата Клиент L»** ⭐ новый блокер
- ИШ требует именно Валидату, **НЕ КриптоПро CSP** (у нас стоит КриптоПро, придётся ставить параллельно или вместо).
- Где взять: только по запросу — `soed@nsd.ru` (НРД) или `pki@moex.com` (МБ). Временная лицензия выдаётся.
- Что нужно: отправить письмо с реквизитами организации и обоснованием (подключение к MOEX МОСТ M2M).
- Срок: ~1-3 дня на ответ НРД.
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, добавим их.
4. **Окно техработ TEST3: 18.05.2026 — 22.05.2026**
6. **Окно техработ TEST3: 18.05.2026 — 22.05.2026**
- Полевое тестирование в этот период невозможно. Реальные прогоны — до 18-го или после 22-го мая.
5. **Доступ к API реального ЛК ESIA Finance**
7. **Доступ к API реального ЛК ESIA Finance**
- Сейчас bj-server работает с встроенным эмулятором `lk-emulator` на :8083.
- Что нужно: URL продакшен/тест ЛК, Basic-auth учётка.
+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)
}
+13 -15
View File
@@ -40,17 +40,10 @@ func main() {
DefaultSender: defaultSender,
DefaultReceiver: defaultReceiver,
SetupPath: setupPath,
CheckOptions: func() lkgateway.CheckOptions {
return lkgateway.CheckOptions{
PostgresDSN: os.Getenv("BJ_DSN"),
CryptoSocket: getenv("BJ_CRYPTO_SOCKET", "/run/bj/crypto.sock"),
NSDAdapterURL: os.Getenv("BJ_NSD_ADAPTER_URL"),
LKCallbackURL: os.Getenv("BJ_LK_CALLBACK_URL"),
Profile: getenv("BJ_NSD_PROFILE", "demo (mock NSD)"),
CryptoProvider: getenv("BJ_CRYPTO_PROVIDER", "stub"),
Timeout: 2 * time.Second,
}
},
// CheckOptions не задаём — server.go использует свой снапшот-based
// вариант, который читает актуальные значения из setup.json
// (DSN, crypto-сокет, URL ИШ, профиль), а не из ENV. Так проверки
// статуса совпадают с тем, что реально настроено в UI.
}
srv, err := lkgateway.NewServer(cfg)
@@ -110,15 +103,20 @@ func runNSDPoller(ctx context.Context, profileName string) {
return
case <-t.C:
for _, kind := range nsdadapter.IncomingPackageKinds() {
pkgs, err := client.ListIncoming(ctx, profile.Channel, since, string(kind))
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 входящий пакет %s типа %s (канал %s, получен %s)",
serviceName, p.PackageID, p.PackageType, p.Channel, p.ReceivedAt.Format(time.RFC3339))
// TODO(M3): парсить тело пакета, передавать в lkgateway.Service.ApplyDecision
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()
+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
+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)
+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
+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
+9 -5
View File
@@ -1,17 +1,21 @@
module git.zetit.ru/zuevav/Bridge-and-Join-s
go 1.24.0
go 1.25.0
require (
github.com/jackc/pgx/v5 v5.7.4
github.com/miekg/pkcs11 v1.1.2
golang.org/x/text v0.22.0
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.35.0 // indirect
golang.org/x/sync v0.17.0 // 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
)
+42 -8
View File
@@ -1,6 +1,18 @@
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=
@@ -9,8 +21,6 @@ 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/miekg/pkcs11 v1.1.2 h1:/VxmeAX5qU6Q3EwafypogwWbYryHFmF2RpkJmw3m4MQ=
github.com/miekg/pkcs11 v1.1.2/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs=
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=
@@ -18,12 +28,36 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
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=
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
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=
+219 -273
View File
@@ -1,351 +1,297 @@
// Package cryptocli — Go-клиент к СКЗИ через PKCS#11 (КриптоПро CSP,
// Рутокен ЭЦП 2.0, ViPNet, Валидата). Загружает указанный .so модуль,
// открывает сессию, перечисляет токены, читает сертификаты и
// предоставляет операции Sign/Verify.
// Package cryptocli — gRPC-клиент к crypto-service по Unix Domain
// Socket. Сам Go-процесс не выполняет криптографию — всё делает
// Java-сайдкар (services/crypto-service) поверх АПК «Валидата
// Клиент L».
//
// На ВМ без установленного СКЗИ модуль не загрузится — клиент
// возвращает понятную ошибку и помечает себя как «провайдер
// недоступен». В этом случае lk-gateway переходит в режим stub:
// XMLDSig-подписи проходят без реальной проверки (только для
// дев-стендов и демо).
// На дев-стендах без поднятого сайдкара (стандартный путь
// /run/bj/crypto.sock не существует) клиент возвращает понятную
// ошибку «провайдер недоступен» и lk-gateway работает в stub-режиме:
// XMLDSig-подписи проходят без проверки (только для демо).
package cryptocli
import (
"context"
"crypto/sha256"
"crypto/x509"
"encoding/asn1"
"encoding/hex"
"errors"
"fmt"
"os"
"sync"
"time"
"github.com/miekg/pkcs11"
"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 — тип СКЗИ-провайдера.
// Provider — тип СКЗИ-провайдера (информативный — реальный выбор
// делает crypto-service через переменную BJ_CRYPTO_PROVIDER).
type Provider string
// Известные провайдеры.
const (
ProviderStub Provider = "stub"
ProviderCryptoPro Provider = "cryptopro"
ProviderRutoken Provider = "rutoken"
ProviderValidata Provider = "validata"
ProviderVipNet Provider = "vipnet"
ProviderStub Provider = "stub"
ProviderValidata Provider = "validata"
)
// DefaultModulePath возвращает дефолтный путь до PKCS#11 .so модуля
// для указанного провайдера. Используется в /admin/setup как placeholder.
// DefaultModulePath сохранена для обратной совместимости с UI;
// текущий путь интеграции — не PKCS#11-модуль, а UDS-сокет
// crypto-service. Возвращаемое значение информативное.
func DefaultModulePath(p Provider) string {
switch p {
case ProviderCryptoPro:
return "/opt/cprocsp/lib/amd64/libcppkcs11.so"
case ProviderRutoken:
return "/usr/lib64/librtpkcs11ecp.so"
case ProviderValidata:
return "/opt/validata/lib/libvalidata-pkcs11.so"
case ProviderVipNet:
return "/opt/itcs/lib/libvipnet-pkcs11.so"
if p == ProviderValidata {
return "/opt/Validata/VDCSP/lib/amd64 (через сайдкар, не PKCS#11)"
}
return ""
}
// Config — конфигурация клиента.
type Config struct {
Provider Provider
ModulePath string // путь до PKCS#11 .so модуля (libcppkcs11.so и т.п.)
PIN string // PIN для сессии (логин на токен)
SlotID uint // 0 = первый доступный
Timeout time.Duration
// SocketPath — путь к UDS-сокету crypto-service.
// Пустое значение = /run/bj/crypto.sock.
SocketPath string
// Provider — желаемый провайдер; информативно (см. выше).
Provider Provider
// ModulePath — сохраняется для UI; в gRPC-режиме не используется.
ModulePath string
// Timeout — таймаут одной gRPC-операции.
Timeout time.Duration
}
// Client — PKCS#11-клиент к СКЗИ.
// Client — gRPC-клиент к crypto-service.
type Client struct {
cfg Config
mu sync.Mutex
ctx *pkcs11.Ctx
opened bool
cfg Config
mu sync.Mutex
conn *grpc.ClientConn
api cryptopb.CryptoServiceClient
}
// New создаёт клиент. Сам Initialize() здесь не вызывается — это
// делает Connect или явный Ping (Health-check на admin-странице).
// 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}
}
// Health — лёгкая проверка готовности. Шаги:
// 1. Сам файл .so существует?
// 2. Initialize модуля?
// 3. Есть ли хотя бы один доступный слот с токеном?
// 4. Информация о токене (label, manufacturer, serial).
func (c *Client) Health(_ context.Context) (HealthInfo, error) {
if c.cfg.Provider == "" || c.cfg.Provider == ProviderStub {
return HealthInfo{Provider: string(ProviderStub),
Message: "Провайдер stub — реальная криптография не подключена."}, nil
}
if c.cfg.ModulePath == "" {
return HealthInfo{}, errors.New("cryptocli: ModulePath не задан")
}
if _, err := os.Stat(c.cfg.ModulePath); err != nil {
return HealthInfo{}, fmt.Errorf("cryptocli: модуль %s не найден: %w", c.cfg.ModulePath, err)
}
// Close закрывает gRPC-соединение.
func (c *Client) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
if err := c.ensureInitLocked(); err != nil {
return HealthInfo{}, err
if c.conn != nil {
err := c.conn.Close()
c.conn = nil
c.api = nil
return err
}
info, err := c.ctx.GetInfo()
if err != nil {
return HealthInfo{}, fmt.Errorf("cryptocli: GetInfo: %w", err)
}
slots, err := c.ctx.GetSlotList(true) // только токены
if err != nil {
return HealthInfo{}, fmt.Errorf("cryptocli: GetSlotList: %w", err)
}
h := HealthInfo{
Provider: string(c.cfg.Provider),
ModulePath: c.cfg.ModulePath,
CryptokiVersion: fmt.Sprintf("%d.%d", info.CryptokiVersion.Major, info.CryptokiVersion.Minor),
ManufacturerID: info.ManufacturerID,
LibraryVersion: fmt.Sprintf("%d.%d", info.LibraryVersion.Major, info.LibraryVersion.Minor),
}
for _, slot := range slots {
tok, err := c.ctx.GetTokenInfo(slot)
if err != nil {
h.Tokens = append(h.Tokens, TokenInfo{SlotID: slot, Error: err.Error()})
continue
}
h.Tokens = append(h.Tokens, TokenInfo{
SlotID: slot,
Label: tok.Label,
Manufacturer: tok.ManufacturerID,
Model: tok.Model,
SerialNumber: tok.SerialNumber,
})
}
if len(h.Tokens) == 0 {
h.Message = "Модуль PKCS#11 загружен, но активных токенов не найдено. Подключите Рутокен или установите ключевой контейнер."
} else {
h.Message = fmt.Sprintf("Доступно токенов: %d. Криптография готова к работе.", len(h.Tokens))
}
return h, nil
return nil
}
// Certificate — DER-сертификат с распарсенными атрибутами для UI.
// 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 // CKA_LABEL (объект на токене)
Label string
SubjectCN string
IssuerCN string
Serial string
NotBefore time.Time
NotAfter time.Time
INN string // если есть в OID 1.2.643.3.131.1.1
INN string
DER []byte
HasPrivateKey bool // найден ли парный приватный ключ на токене
HasPrivateKey bool
}
// FindCertificates перечисляет сертификаты на всех подключенных
// токенах. Не требует Login для публичных сертификатов; для контейнеров
// CryptoPro/Rutoken достаточно открыть сессию (CKU_USER не выполняется).
// FindCertificates пока возвращает пустой список — список ключей
// управляется самой Валидатой через её собственный справочник (zcs),
// а bj-server о конкретных сертификатах узнаёт по результатам
// Verify/Sign-операций. Эту функцию переопределим позже отдельным
// gRPC-методом ListCertificates если потребуется.
func (c *Client) FindCertificates(_ context.Context) ([]Certificate, error) {
if c.cfg.Provider == "" || c.cfg.Provider == ProviderStub {
return nil, errors.New("cryptocli: провайдер stub — нет реальных сертификатов")
}
c.mu.Lock()
defer c.mu.Unlock()
if err := c.ensureInitLocked(); err != nil {
return nil, err
}
return nil, nil
}
slots, err := c.ctx.GetSlotList(true)
// 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 nil, fmt.Errorf("cryptocli: GetSlotList: %w", err)
return ActivateResult{}, fmt.Errorf("cryptocli: Activate: %w", err)
}
var out []Certificate
for _, slot := range slots {
tokInfo, _ := c.ctx.GetTokenInfo(slot)
certs, err := c.listSlotCertificates(slot, tokInfo.Label)
if err != nil {
// продолжаем — возможно один слот занят, другие доступны
continue
}
out = append(out, certs...)
}
return out, nil
}
// listSlotCertificates открывает сессию на слоте, ищет CKO_CERTIFICATE,
// читает DER и парсит x509.
func (c *Client) listSlotCertificates(slot uint, tokenLabel string) ([]Certificate, error) {
sess, err := c.ctx.OpenSession(slot, pkcs11.CKF_SERIAL_SESSION)
if err != nil {
return nil, fmt.Errorf("OpenSession: %w", err)
}
defer func() { _ = c.ctx.CloseSession(sess) }()
template := []*pkcs11.Attribute{
pkcs11.NewAttribute(pkcs11.CKA_CLASS, pkcs11.CKO_CERTIFICATE),
}
if err := c.ctx.FindObjectsInit(sess, template); err != nil {
return nil, fmt.Errorf("FindObjectsInit: %w", err)
}
handles, _, err := c.ctx.FindObjects(sess, 32)
_ = c.ctx.FindObjectsFinal(sess)
if err != nil {
return nil, fmt.Errorf("FindObjects: %w", err)
}
out := make([]Certificate, 0, len(handles))
for _, h := range handles {
attrs, err := c.ctx.GetAttributeValue(sess, h, []*pkcs11.Attribute{
pkcs11.NewAttribute(pkcs11.CKA_VALUE, nil),
pkcs11.NewAttribute(pkcs11.CKA_LABEL, nil),
pkcs11.NewAttribute(pkcs11.CKA_ID, nil),
})
if err != nil {
continue
}
cert := Certificate{
SlotID: slot,
TokenLabel: tokenLabel,
}
var idAttr []byte
for _, a := range attrs {
switch a.Type {
case pkcs11.CKA_VALUE:
cert.DER = a.Value
case pkcs11.CKA_LABEL:
cert.Label = string(a.Value)
case pkcs11.CKA_ID:
idAttr = a.Value
}
}
// Парсим X.509 (ГОСТ-сертификаты тоже парсятся через crypto/x509
// — Subject/Issuer/Serial/Validity не зависят от алгоритма подписи).
parsed, err := x509.ParseCertificate(cert.DER)
if err == nil {
cert.SubjectCN = parsed.Subject.CommonName
cert.IssuerCN = parsed.Issuer.CommonName
cert.Serial = parsed.SerialNumber.Text(16)
cert.NotBefore = parsed.NotBefore
cert.NotAfter = parsed.NotAfter
// ИНН в OID 1.2.643.3.131.1.1 — извлекаем из Subject.
cert.INN = extractINN(parsed)
}
// Проверим есть ли парный приватный ключ.
if len(idAttr) > 0 {
cert.HasPrivateKey = c.hasPrivateKey(sess, idAttr)
}
out = append(out, cert)
}
return out, nil
}
// hasPrivateKey ищет CKO_PRIVATE_KEY с тем же CKA_ID что и сертификат.
func (c *Client) hasPrivateKey(sess pkcs11.SessionHandle, id []byte) bool {
tmpl := []*pkcs11.Attribute{
pkcs11.NewAttribute(pkcs11.CKA_CLASS, pkcs11.CKO_PRIVATE_KEY),
pkcs11.NewAttribute(pkcs11.CKA_ID, id),
}
if err := c.ctx.FindObjectsInit(sess, tmpl); err != nil {
return false
}
defer func() { _ = c.ctx.FindObjectsFinal(sess) }()
handles, _, err := c.ctx.FindObjects(sess, 1)
return err == nil && len(handles) > 0
}
// extractINN ищет ИНН в Subject сертификата по OID НРД 1.2.643.3.131.1.1.
func extractINN(c *x509.Certificate) string {
innOID := asn1.ObjectIdentifier{1, 2, 643, 3, 131, 1, 1}
for _, name := range c.Subject.Names {
if name.Type.Equal(innOID) {
if s, ok := name.Value.(string); ok {
return s
}
}
}
return ""
}
// VerifyXMLDSig — заглушка для интерфейса m2mcore.CryptoVerifier.
// Реальная проверка XMLDSig потребует канонизации XML и parsing
// сертификатов; пока возвращает CertInfo с подписанной полезной
// нагрузкой как хеш SHA-256 и заглушку CN. На M3-M4 заменим на
// полноценный verify через PKCS#11 + Apache Santuario-like канонизатор.
func (c *Client) VerifyXMLDSig(ctx context.Context, payload []byte) (m2mcore.CertInfo, error) {
if _, err := c.Health(ctx); err != nil {
return m2mcore.CertInfo{}, err
}
sum := sha256.Sum256(payload)
return m2mcore.CertInfo{
SignerCN: "stub-verifier",
SignerINN: "",
Serial: hex.EncodeToString(sum[:8]),
NotBefore: time.Now().Add(-365 * 24 * time.Hour),
NotAfter: time.Now().Add(365 * 24 * time.Hour),
return ActivateResult{
OK: resp.GetOk(),
Provider: resp.GetProvider(),
Profile: resp.GetProfile(),
Message: resp.GetMessage(),
}, nil
}
// Close завершает работу PKCS#11 модуля.
func (c *Client) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.ctx == nil {
return 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
}
_ = c.ctx.Finalize()
c.ctx.Destroy()
c.ctx = nil
c.opened = false
return 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
}
// ensureInitLocked инициализирует PKCS#11 модуль если ещё не.
// Должен вызываться под c.mu.Lock.
func (c *Client) ensureInitLocked() error {
if c.opened {
return 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: подпись недоступна")
}
c.ctx = pkcs11.New(c.cfg.ModulePath)
if c.ctx == nil {
return fmt.Errorf("cryptocli: не получилось загрузить модуль %s", c.cfg.ModulePath)
if err := c.ensureConn(); err != nil {
return nil, err
}
if err := c.ctx.Initialize(); err != nil {
c.ctx.Destroy()
c.ctx = nil
return fmt.Errorf("cryptocli: Initialize: %w", 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)
}
c.opened = true
return nil
return resp.GetSignedXml(), nil
}
// HealthInfo — что показывает /admin/setup и /admin/status.
// HealthInfo — что показывает /admin/setup → СКЗИ.
type HealthInfo struct {
Provider string
ModulePath string
CryptokiVersion string
ManufacturerID string
LibraryVersion string
ModulePath string // в gRPC-режиме — UDS-сокет
CryptokiVersion string // не используется
ManufacturerID string // не используется
LibraryVersion string // не используется
Tokens []TokenInfo
Message string
}
// TokenInfo — описание подключённого токена/контейнера.
// TokenInfo — для совместимости с UI; в gRPC-режиме пустой.
type TokenInfo struct {
SlotID uint
Label string
+18 -28
View File
@@ -8,55 +8,45 @@ import (
"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", h.Provider)
t.Errorf("Provider = %q, ожидался stub", h.Provider)
}
if !strings.Contains(h.Message, "stub") {
t.Errorf("сообщение не содержит 'stub': %q", h.Message)
}
}
func TestModulePathMissing(t *testing.T) {
// TestValidataProviderNoSocket — провайдер validata пытается дойти до
// сайдкара, но в тестах сокета нет. gRPC-клиент создаётся лениво
// (NewClient не возвращает ошибку), а ошибка приходит при первом RPC.
func TestValidataProviderNoSocket(t *testing.T) {
cli := cryptocli.New(cryptocli.Config{
Provider: cryptocli.ProviderCryptoPro,
ModulePath: "/nonexistent/libcppkcs11.so",
Provider: cryptocli.ProviderValidata,
SocketPath: "/nonexistent/crypto.sock",
})
defer cli.Close()
_, err := cli.Health(context.Background())
if err == nil {
t.Fatal("ожидалась ошибка о ненайденном модуле")
}
if !strings.Contains(err.Error(), "не найден") {
t.Errorf("неинформативная ошибка: %v", err)
}
}
func TestEmptyModulePath(t *testing.T) {
cli := cryptocli.New(cryptocli.Config{Provider: cryptocli.ProviderCryptoPro})
_, err := cli.Health(context.Background())
if err == nil {
t.Fatal("ожидалась ошибка о пустом ModulePath")
t.Fatal("ожидалась ошибка о недоступном сокете")
}
}
// TestDefaultModulePath — информативный текст для UI.
func TestDefaultModulePath(t *testing.T) {
cases := []struct {
p cryptocli.Provider
want string
}{
{cryptocli.ProviderCryptoPro, "/opt/cprocsp/lib/amd64/libcppkcs11.so"},
{cryptocli.ProviderRutoken, "/usr/lib64/librtpkcs11ecp.so"},
{cryptocli.ProviderStub, ""},
if cryptocli.DefaultModulePath(cryptocli.ProviderStub) != "" {
t.Error("DefaultModulePath(stub) должен быть пустым")
}
for _, c := range cases {
got := cryptocli.DefaultModulePath(c.p)
if got != c.want {
t.Errorf("DefaultModulePath(%s) = %q, ожидалось %q", c.p, got, c.want)
}
v := cryptocli.DefaultModulePath(cryptocli.ProviderValidata)
if v == "" {
t.Error("DefaultModulePath(validata) не должен быть пустым")
}
}
+694
View File
@@ -0,0 +1,694 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v3.12.4
// source: crypto.proto
package cryptopb
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type ActivateRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Имя профиля в pki1.conf. Пустая строка = minimal mode.
Profile string `protobuf:"bytes,1,opt,name=profile,proto3" json:"profile,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ActivateRequest) Reset() {
*x = ActivateRequest{}
mi := &file_crypto_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ActivateRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ActivateRequest) ProtoMessage() {}
func (x *ActivateRequest) ProtoReflect() protoreflect.Message {
mi := &file_crypto_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ActivateRequest.ProtoReflect.Descriptor instead.
func (*ActivateRequest) Descriptor() ([]byte, []int) {
return file_crypto_proto_rawDescGZIP(), []int{0}
}
func (x *ActivateRequest) GetProfile() string {
if x != nil {
return x.Profile
}
return ""
}
type ActivateResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
// true если провайдер успешно (пере)инициализирован.
Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"`
// Имя активного провайдера ("validata" / "stub").
Provider string `protobuf:"bytes,2,opt,name=provider,proto3" json:"provider,omitempty"`
// Имя активного профиля (пусто для minimal).
Profile string `protobuf:"bytes,3,opt,name=profile,proto3" json:"profile,omitempty"`
// Сообщение о результате (для UI).
Message string `protobuf:"bytes,4,opt,name=message,proto3" json:"message,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ActivateResponse) Reset() {
*x = ActivateResponse{}
mi := &file_crypto_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ActivateResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ActivateResponse) ProtoMessage() {}
func (x *ActivateResponse) ProtoReflect() protoreflect.Message {
mi := &file_crypto_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ActivateResponse.ProtoReflect.Descriptor instead.
func (*ActivateResponse) Descriptor() ([]byte, []int) {
return file_crypto_proto_rawDescGZIP(), []int{1}
}
func (x *ActivateResponse) GetOk() bool {
if x != nil {
return x.Ok
}
return false
}
func (x *ActivateResponse) GetProvider() string {
if x != nil {
return x.Provider
}
return ""
}
func (x *ActivateResponse) GetProfile() string {
if x != nil {
return x.Profile
}
return ""
}
func (x *ActivateResponse) GetMessage() string {
if x != nil {
return x.Message
}
return ""
}
type ShutdownRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ShutdownRequest) Reset() {
*x = ShutdownRequest{}
mi := &file_crypto_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ShutdownRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ShutdownRequest) ProtoMessage() {}
func (x *ShutdownRequest) ProtoReflect() protoreflect.Message {
mi := &file_crypto_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ShutdownRequest.ProtoReflect.Descriptor instead.
func (*ShutdownRequest) Descriptor() ([]byte, []int) {
return file_crypto_proto_rawDescGZIP(), []int{2}
}
type ShutdownResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
// true означает «запрос принят, процесс завершится через ~500ms».
Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ShutdownResponse) Reset() {
*x = ShutdownResponse{}
mi := &file_crypto_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ShutdownResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ShutdownResponse) ProtoMessage() {}
func (x *ShutdownResponse) ProtoReflect() protoreflect.Message {
mi := &file_crypto_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ShutdownResponse.ProtoReflect.Descriptor instead.
func (*ShutdownResponse) Descriptor() ([]byte, []int) {
return file_crypto_proto_rawDescGZIP(), []int{3}
}
func (x *ShutdownResponse) GetOk() bool {
if x != nil {
return x.Ok
}
return false
}
type VerifyRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Целиком подписанный XML.
Payload []byte `protobuf:"bytes,1,opt,name=payload,proto3" json:"payload,omitempty"`
// Профиль ключей и сертификатов: "guest-gost" | "test3-gost" |
// "prod-gost" | "guest-rsa" | ... — определяет хранилище и trust store.
Profile string `protobuf:"bytes,2,opt,name=profile,proto3" json:"profile,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *VerifyRequest) Reset() {
*x = VerifyRequest{}
mi := &file_crypto_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *VerifyRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*VerifyRequest) ProtoMessage() {}
func (x *VerifyRequest) ProtoReflect() protoreflect.Message {
mi := &file_crypto_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use VerifyRequest.ProtoReflect.Descriptor instead.
func (*VerifyRequest) Descriptor() ([]byte, []int) {
return file_crypto_proto_rawDescGZIP(), []int{4}
}
func (x *VerifyRequest) GetPayload() []byte {
if x != nil {
return x.Payload
}
return nil
}
func (x *VerifyRequest) GetProfile() string {
if x != nil {
return x.Profile
}
return ""
}
type VerifyResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Прошла ли проверка.
Valid bool `protobuf:"varint,1,opt,name=valid,proto3" json:"valid,omitempty"`
// CN из сертификата подписанта.
SignerCn string `protobuf:"bytes,2,opt,name=signer_cn,json=signerCn,proto3" json:"signer_cn,omitempty"`
// ИНН из сертификата (если присутствует в OID 1.2.643.3.131.1.1).
SignerInn string `protobuf:"bytes,3,opt,name=signer_inn,json=signerInn,proto3" json:"signer_inn,omitempty"`
// Серийный номер сертификата (hex).
Serial string `protobuf:"bytes,4,opt,name=serial,proto3" json:"serial,omitempty"`
// Срок действия сертификата (unix epoch, секунды).
NotBefore int64 `protobuf:"varint,5,opt,name=not_before,json=notBefore,proto3" json:"not_before,omitempty"`
NotAfter int64 `protobuf:"varint,6,opt,name=not_after,json=notAfter,proto3" json:"not_after,omitempty"`
// Тексты ошибок проверки (если valid=false).
Errors []string `protobuf:"bytes,7,rep,name=errors,proto3" json:"errors,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *VerifyResponse) Reset() {
*x = VerifyResponse{}
mi := &file_crypto_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *VerifyResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*VerifyResponse) ProtoMessage() {}
func (x *VerifyResponse) ProtoReflect() protoreflect.Message {
mi := &file_crypto_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use VerifyResponse.ProtoReflect.Descriptor instead.
func (*VerifyResponse) Descriptor() ([]byte, []int) {
return file_crypto_proto_rawDescGZIP(), []int{5}
}
func (x *VerifyResponse) GetValid() bool {
if x != nil {
return x.Valid
}
return false
}
func (x *VerifyResponse) GetSignerCn() string {
if x != nil {
return x.SignerCn
}
return ""
}
func (x *VerifyResponse) GetSignerInn() string {
if x != nil {
return x.SignerInn
}
return ""
}
func (x *VerifyResponse) GetSerial() string {
if x != nil {
return x.Serial
}
return ""
}
func (x *VerifyResponse) GetNotBefore() int64 {
if x != nil {
return x.NotBefore
}
return 0
}
func (x *VerifyResponse) GetNotAfter() int64 {
if x != nil {
return x.NotAfter
}
return 0
}
func (x *VerifyResponse) GetErrors() []string {
if x != nil {
return x.Errors
}
return nil
}
type SignRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Канонизированный XML, который нужно подписать.
Payload []byte `protobuf:"bytes,1,opt,name=payload,proto3" json:"payload,omitempty"`
// Алиас ключа в JCP-keystore.
KeyAlias string `protobuf:"bytes,2,opt,name=key_alias,json=keyAlias,proto3" json:"key_alias,omitempty"`
// Профиль (тот же что у Verify).
Profile string `protobuf:"bytes,3,opt,name=profile,proto3" json:"profile,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *SignRequest) Reset() {
*x = SignRequest{}
mi := &file_crypto_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *SignRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*SignRequest) ProtoMessage() {}
func (x *SignRequest) ProtoReflect() protoreflect.Message {
mi := &file_crypto_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use SignRequest.ProtoReflect.Descriptor instead.
func (*SignRequest) Descriptor() ([]byte, []int) {
return file_crypto_proto_rawDescGZIP(), []int{6}
}
func (x *SignRequest) GetPayload() []byte {
if x != nil {
return x.Payload
}
return nil
}
func (x *SignRequest) GetKeyAlias() string {
if x != nil {
return x.KeyAlias
}
return ""
}
func (x *SignRequest) GetProfile() string {
if x != nil {
return x.Profile
}
return ""
}
type SignResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Подписанный XML (с детачированной или встроенной подписью —
// зависит от профиля).
SignedXml []byte `protobuf:"bytes,1,opt,name=signed_xml,json=signedXml,proto3" json:"signed_xml,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *SignResponse) Reset() {
*x = SignResponse{}
mi := &file_crypto_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *SignResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*SignResponse) ProtoMessage() {}
func (x *SignResponse) ProtoReflect() protoreflect.Message {
mi := &file_crypto_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use SignResponse.ProtoReflect.Descriptor instead.
func (*SignResponse) Descriptor() ([]byte, []int) {
return file_crypto_proto_rawDescGZIP(), []int{7}
}
func (x *SignResponse) GetSignedXml() []byte {
if x != nil {
return x.SignedXml
}
return nil
}
type HealthRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *HealthRequest) Reset() {
*x = HealthRequest{}
mi := &file_crypto_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *HealthRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*HealthRequest) ProtoMessage() {}
func (x *HealthRequest) ProtoReflect() protoreflect.Message {
mi := &file_crypto_proto_msgTypes[8]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use HealthRequest.ProtoReflect.Descriptor instead.
func (*HealthRequest) Descriptor() ([]byte, []int) {
return file_crypto_proto_rawDescGZIP(), []int{8}
}
type HealthResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"`
Version string `protobuf:"bytes,2,opt,name=version,proto3" json:"version,omitempty"`
// Активный провайдер криптографии: "cryptopro" | "validata" | "vipnet" | "stub".
Provider string `protobuf:"bytes,3,opt,name=provider,proto3" json:"provider,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *HealthResponse) Reset() {
*x = HealthResponse{}
mi := &file_crypto_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *HealthResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*HealthResponse) ProtoMessage() {}
func (x *HealthResponse) ProtoReflect() protoreflect.Message {
mi := &file_crypto_proto_msgTypes[9]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use HealthResponse.ProtoReflect.Descriptor instead.
func (*HealthResponse) Descriptor() ([]byte, []int) {
return file_crypto_proto_rawDescGZIP(), []int{9}
}
func (x *HealthResponse) GetOk() bool {
if x != nil {
return x.Ok
}
return false
}
func (x *HealthResponse) GetVersion() string {
if x != nil {
return x.Version
}
return ""
}
func (x *HealthResponse) GetProvider() string {
if x != nil {
return x.Provider
}
return ""
}
var File_crypto_proto protoreflect.FileDescriptor
const file_crypto_proto_rawDesc = "" +
"\n" +
"\fcrypto.proto\x12\x1abridge_and_joins.crypto.v1\"+\n" +
"\x0fActivateRequest\x12\x18\n" +
"\aprofile\x18\x01 \x01(\tR\aprofile\"r\n" +
"\x10ActivateResponse\x12\x0e\n" +
"\x02ok\x18\x01 \x01(\bR\x02ok\x12\x1a\n" +
"\bprovider\x18\x02 \x01(\tR\bprovider\x12\x18\n" +
"\aprofile\x18\x03 \x01(\tR\aprofile\x12\x18\n" +
"\amessage\x18\x04 \x01(\tR\amessage\"\x11\n" +
"\x0fShutdownRequest\"\"\n" +
"\x10ShutdownResponse\x12\x0e\n" +
"\x02ok\x18\x01 \x01(\bR\x02ok\"C\n" +
"\rVerifyRequest\x12\x18\n" +
"\apayload\x18\x01 \x01(\fR\apayload\x12\x18\n" +
"\aprofile\x18\x02 \x01(\tR\aprofile\"\xce\x01\n" +
"\x0eVerifyResponse\x12\x14\n" +
"\x05valid\x18\x01 \x01(\bR\x05valid\x12\x1b\n" +
"\tsigner_cn\x18\x02 \x01(\tR\bsignerCn\x12\x1d\n" +
"\n" +
"signer_inn\x18\x03 \x01(\tR\tsignerInn\x12\x16\n" +
"\x06serial\x18\x04 \x01(\tR\x06serial\x12\x1d\n" +
"\n" +
"not_before\x18\x05 \x01(\x03R\tnotBefore\x12\x1b\n" +
"\tnot_after\x18\x06 \x01(\x03R\bnotAfter\x12\x16\n" +
"\x06errors\x18\a \x03(\tR\x06errors\"^\n" +
"\vSignRequest\x12\x18\n" +
"\apayload\x18\x01 \x01(\fR\apayload\x12\x1b\n" +
"\tkey_alias\x18\x02 \x01(\tR\bkeyAlias\x12\x18\n" +
"\aprofile\x18\x03 \x01(\tR\aprofile\"-\n" +
"\fSignResponse\x12\x1d\n" +
"\n" +
"signed_xml\x18\x01 \x01(\fR\tsignedXml\"\x0f\n" +
"\rHealthRequest\"V\n" +
"\x0eHealthResponse\x12\x0e\n" +
"\x02ok\x18\x01 \x01(\bR\x02ok\x12\x18\n" +
"\aversion\x18\x02 \x01(\tR\aversion\x12\x1a\n" +
"\bprovider\x18\x03 \x01(\tR\bprovider2\x88\x04\n" +
"\rCryptoService\x12f\n" +
"\rVerifyXMLDSig\x12).bridge_and_joins.crypto.v1.VerifyRequest\x1a*.bridge_and_joins.crypto.v1.VerifyResponse\x12`\n" +
"\vSignXMLDSig\x12'.bridge_and_joins.crypto.v1.SignRequest\x1a(.bridge_and_joins.crypto.v1.SignResponse\x12_\n" +
"\x06Health\x12).bridge_and_joins.crypto.v1.HealthRequest\x1a*.bridge_and_joins.crypto.v1.HealthResponse\x12e\n" +
"\bActivate\x12+.bridge_and_joins.crypto.v1.ActivateRequest\x1a,.bridge_and_joins.crypto.v1.ActivateResponse\x12e\n" +
"\bShutdown\x12+.bridge_and_joins.crypto.v1.ShutdownRequest\x1a,.bridge_and_joins.crypto.v1.ShutdownResponseBq\n" +
"!ru.zetit.bridgeandjoins.crypto.v1P\x01ZJgit.zetit.ru/zuevav/Bridge-and-Join-s/internal/cryptocli/cryptopb;cryptopbb\x06proto3"
var (
file_crypto_proto_rawDescOnce sync.Once
file_crypto_proto_rawDescData []byte
)
func file_crypto_proto_rawDescGZIP() []byte {
file_crypto_proto_rawDescOnce.Do(func() {
file_crypto_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_crypto_proto_rawDesc), len(file_crypto_proto_rawDesc)))
})
return file_crypto_proto_rawDescData
}
var file_crypto_proto_msgTypes = make([]protoimpl.MessageInfo, 10)
var file_crypto_proto_goTypes = []any{
(*ActivateRequest)(nil), // 0: bridge_and_joins.crypto.v1.ActivateRequest
(*ActivateResponse)(nil), // 1: bridge_and_joins.crypto.v1.ActivateResponse
(*ShutdownRequest)(nil), // 2: bridge_and_joins.crypto.v1.ShutdownRequest
(*ShutdownResponse)(nil), // 3: bridge_and_joins.crypto.v1.ShutdownResponse
(*VerifyRequest)(nil), // 4: bridge_and_joins.crypto.v1.VerifyRequest
(*VerifyResponse)(nil), // 5: bridge_and_joins.crypto.v1.VerifyResponse
(*SignRequest)(nil), // 6: bridge_and_joins.crypto.v1.SignRequest
(*SignResponse)(nil), // 7: bridge_and_joins.crypto.v1.SignResponse
(*HealthRequest)(nil), // 8: bridge_and_joins.crypto.v1.HealthRequest
(*HealthResponse)(nil), // 9: bridge_and_joins.crypto.v1.HealthResponse
}
var file_crypto_proto_depIdxs = []int32{
4, // 0: bridge_and_joins.crypto.v1.CryptoService.VerifyXMLDSig:input_type -> bridge_and_joins.crypto.v1.VerifyRequest
6, // 1: bridge_and_joins.crypto.v1.CryptoService.SignXMLDSig:input_type -> bridge_and_joins.crypto.v1.SignRequest
8, // 2: bridge_and_joins.crypto.v1.CryptoService.Health:input_type -> bridge_and_joins.crypto.v1.HealthRequest
0, // 3: bridge_and_joins.crypto.v1.CryptoService.Activate:input_type -> bridge_and_joins.crypto.v1.ActivateRequest
2, // 4: bridge_and_joins.crypto.v1.CryptoService.Shutdown:input_type -> bridge_and_joins.crypto.v1.ShutdownRequest
5, // 5: bridge_and_joins.crypto.v1.CryptoService.VerifyXMLDSig:output_type -> bridge_and_joins.crypto.v1.VerifyResponse
7, // 6: bridge_and_joins.crypto.v1.CryptoService.SignXMLDSig:output_type -> bridge_and_joins.crypto.v1.SignResponse
9, // 7: bridge_and_joins.crypto.v1.CryptoService.Health:output_type -> bridge_and_joins.crypto.v1.HealthResponse
1, // 8: bridge_and_joins.crypto.v1.CryptoService.Activate:output_type -> bridge_and_joins.crypto.v1.ActivateResponse
3, // 9: bridge_and_joins.crypto.v1.CryptoService.Shutdown:output_type -> bridge_and_joins.crypto.v1.ShutdownResponse
5, // [5:10] is the sub-list for method output_type
0, // [0:5] is the sub-list for method input_type
0, // [0:0] is the sub-list for extension type_name
0, // [0:0] is the sub-list for extension extendee
0, // [0:0] is the sub-list for field type_name
}
func init() { file_crypto_proto_init() }
func file_crypto_proto_init() {
if File_crypto_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_crypto_proto_rawDesc), len(file_crypto_proto_rawDesc)),
NumEnums: 0,
NumMessages: 10,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_crypto_proto_goTypes,
DependencyIndexes: file_crypto_proto_depIdxs,
MessageInfos: file_crypto_proto_msgTypes,
}.Build()
File_crypto_proto = out.File
file_crypto_proto_goTypes = nil
file_crypto_proto_depIdxs = nil
}
@@ -0,0 +1,305 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc v3.12.4
// source: crypto.proto
package cryptopb
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
CryptoService_VerifyXMLDSig_FullMethodName = "/bridge_and_joins.crypto.v1.CryptoService/VerifyXMLDSig"
CryptoService_SignXMLDSig_FullMethodName = "/bridge_and_joins.crypto.v1.CryptoService/SignXMLDSig"
CryptoService_Health_FullMethodName = "/bridge_and_joins.crypto.v1.CryptoService/Health"
CryptoService_Activate_FullMethodName = "/bridge_and_joins.crypto.v1.CryptoService/Activate"
CryptoService_Shutdown_FullMethodName = "/bridge_and_joins.crypto.v1.CryptoService/Shutdown"
)
// CryptoServiceClient is the client API for CryptoService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// CryptoService — серверная криптография по ГОСТ через КриптоПро JCP.
// Слушает на Unix Domain Socket (по умолчанию /run/bj/crypto.sock).
type CryptoServiceClient interface {
// Проверка XMLDSig-подписи (ГОСТ или RSA). Возвращает сведения о
// подписанте: CN, ИНН (если есть), срок действия сертификата.
VerifyXMLDSig(ctx context.Context, in *VerifyRequest, opts ...grpc.CallOption) (*VerifyResponse, error)
// Подпись XML по ГОСТ — для резервного канала WS ONYX и для
// серверной подписи действий оператора в admin-ui.
SignXMLDSig(ctx context.Context, in *SignRequest, opts ...grpc.CallOption) (*SignResponse, error)
// Health-check.
Health(ctx context.Context, in *HealthRequest, opts ...grpc.CallOption) (*HealthResponse, error)
// Activate — переинициализирует провайдер Валидаты на указанный
// профиль из pki1.conf. Если profile пуст — переходит в
// VCERT_InitMinimal (без доступа к ПСП/ЛСП/ССС). Не требует
// перезапуска сайдкара.
Activate(ctx context.Context, in *ActivateRequest, opts ...grpc.CallOption) (*ActivateResponse, error)
// Shutdown — корректно завершает процесс сайдкара (System.exit(2)
// после отправки ответа). systemd с Restart=on-failure поднимет
// его снова через RestartSec секунд. Используется для UI-кнопки
// «Перезапустить crypto-service» без sudo.
Shutdown(ctx context.Context, in *ShutdownRequest, opts ...grpc.CallOption) (*ShutdownResponse, error)
}
type cryptoServiceClient struct {
cc grpc.ClientConnInterface
}
func NewCryptoServiceClient(cc grpc.ClientConnInterface) CryptoServiceClient {
return &cryptoServiceClient{cc}
}
func (c *cryptoServiceClient) VerifyXMLDSig(ctx context.Context, in *VerifyRequest, opts ...grpc.CallOption) (*VerifyResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(VerifyResponse)
err := c.cc.Invoke(ctx, CryptoService_VerifyXMLDSig_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *cryptoServiceClient) SignXMLDSig(ctx context.Context, in *SignRequest, opts ...grpc.CallOption) (*SignResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(SignResponse)
err := c.cc.Invoke(ctx, CryptoService_SignXMLDSig_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *cryptoServiceClient) Health(ctx context.Context, in *HealthRequest, opts ...grpc.CallOption) (*HealthResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(HealthResponse)
err := c.cc.Invoke(ctx, CryptoService_Health_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *cryptoServiceClient) Activate(ctx context.Context, in *ActivateRequest, opts ...grpc.CallOption) (*ActivateResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ActivateResponse)
err := c.cc.Invoke(ctx, CryptoService_Activate_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *cryptoServiceClient) Shutdown(ctx context.Context, in *ShutdownRequest, opts ...grpc.CallOption) (*ShutdownResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ShutdownResponse)
err := c.cc.Invoke(ctx, CryptoService_Shutdown_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// CryptoServiceServer is the server API for CryptoService service.
// All implementations must embed UnimplementedCryptoServiceServer
// for forward compatibility.
//
// CryptoService — серверная криптография по ГОСТ через КриптоПро JCP.
// Слушает на Unix Domain Socket (по умолчанию /run/bj/crypto.sock).
type CryptoServiceServer interface {
// Проверка XMLDSig-подписи (ГОСТ или RSA). Возвращает сведения о
// подписанте: CN, ИНН (если есть), срок действия сертификата.
VerifyXMLDSig(context.Context, *VerifyRequest) (*VerifyResponse, error)
// Подпись XML по ГОСТ — для резервного канала WS ONYX и для
// серверной подписи действий оператора в admin-ui.
SignXMLDSig(context.Context, *SignRequest) (*SignResponse, error)
// Health-check.
Health(context.Context, *HealthRequest) (*HealthResponse, error)
// Activate — переинициализирует провайдер Валидаты на указанный
// профиль из pki1.conf. Если profile пуст — переходит в
// VCERT_InitMinimal (без доступа к ПСП/ЛСП/ССС). Не требует
// перезапуска сайдкара.
Activate(context.Context, *ActivateRequest) (*ActivateResponse, error)
// Shutdown — корректно завершает процесс сайдкара (System.exit(2)
// после отправки ответа). systemd с Restart=on-failure поднимет
// его снова через RestartSec секунд. Используется для UI-кнопки
// «Перезапустить crypto-service» без sudo.
Shutdown(context.Context, *ShutdownRequest) (*ShutdownResponse, error)
mustEmbedUnimplementedCryptoServiceServer()
}
// UnimplementedCryptoServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedCryptoServiceServer struct{}
func (UnimplementedCryptoServiceServer) VerifyXMLDSig(context.Context, *VerifyRequest) (*VerifyResponse, error) {
return nil, status.Error(codes.Unimplemented, "method VerifyXMLDSig not implemented")
}
func (UnimplementedCryptoServiceServer) SignXMLDSig(context.Context, *SignRequest) (*SignResponse, error) {
return nil, status.Error(codes.Unimplemented, "method SignXMLDSig not implemented")
}
func (UnimplementedCryptoServiceServer) Health(context.Context, *HealthRequest) (*HealthResponse, error) {
return nil, status.Error(codes.Unimplemented, "method Health not implemented")
}
func (UnimplementedCryptoServiceServer) Activate(context.Context, *ActivateRequest) (*ActivateResponse, error) {
return nil, status.Error(codes.Unimplemented, "method Activate not implemented")
}
func (UnimplementedCryptoServiceServer) Shutdown(context.Context, *ShutdownRequest) (*ShutdownResponse, error) {
return nil, status.Error(codes.Unimplemented, "method Shutdown not implemented")
}
func (UnimplementedCryptoServiceServer) mustEmbedUnimplementedCryptoServiceServer() {}
func (UnimplementedCryptoServiceServer) testEmbeddedByValue() {}
// UnsafeCryptoServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to CryptoServiceServer will
// result in compilation errors.
type UnsafeCryptoServiceServer interface {
mustEmbedUnimplementedCryptoServiceServer()
}
func RegisterCryptoServiceServer(s grpc.ServiceRegistrar, srv CryptoServiceServer) {
// If the following call panics, it indicates UnimplementedCryptoServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&CryptoService_ServiceDesc, srv)
}
func _CryptoService_VerifyXMLDSig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(VerifyRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(CryptoServiceServer).VerifyXMLDSig(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: CryptoService_VerifyXMLDSig_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(CryptoServiceServer).VerifyXMLDSig(ctx, req.(*VerifyRequest))
}
return interceptor(ctx, in, info, handler)
}
func _CryptoService_SignXMLDSig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SignRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(CryptoServiceServer).SignXMLDSig(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: CryptoService_SignXMLDSig_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(CryptoServiceServer).SignXMLDSig(ctx, req.(*SignRequest))
}
return interceptor(ctx, in, info, handler)
}
func _CryptoService_Health_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(HealthRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(CryptoServiceServer).Health(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: CryptoService_Health_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(CryptoServiceServer).Health(ctx, req.(*HealthRequest))
}
return interceptor(ctx, in, info, handler)
}
func _CryptoService_Activate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ActivateRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(CryptoServiceServer).Activate(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: CryptoService_Activate_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(CryptoServiceServer).Activate(ctx, req.(*ActivateRequest))
}
return interceptor(ctx, in, info, handler)
}
func _CryptoService_Shutdown_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ShutdownRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(CryptoServiceServer).Shutdown(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: CryptoService_Shutdown_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(CryptoServiceServer).Shutdown(ctx, req.(*ShutdownRequest))
}
return interceptor(ctx, in, info, handler)
}
// CryptoService_ServiceDesc is the grpc.ServiceDesc for CryptoService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var CryptoService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "bridge_and_joins.crypto.v1.CryptoService",
HandlerType: (*CryptoServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "VerifyXMLDSig",
Handler: _CryptoService_VerifyXMLDSig_Handler,
},
{
MethodName: "SignXMLDSig",
Handler: _CryptoService_SignXMLDSig_Handler,
},
{
MethodName: "Health",
Handler: _CryptoService_Health_Handler,
},
{
MethodName: "Activate",
Handler: _CryptoService_Activate_Handler,
},
{
MethodName: "Shutdown",
Handler: _CryptoService_Shutdown_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "crypto.proto",
}
+187
View File
@@ -0,0 +1,187 @@
// Package license — формат лицензии Bridge-and-Join-s и её подпись Ed25519.
//
// Лицензия — самодостаточный подписанный токен (offline-проверяемый):
// клиент проверяет подпись зашитым публичным ключом и срок действия БЕЗ
// обращения к серверу. Это значит, что on-prem bj-server продолжает
// работать даже если license-сервер недоступен.
//
// Online-сервер (cmd/bj-license-server) нужен только для отзыва (revocation)
// и выдачи новых ключей. Базовая модель — годовой ключ: выпустили на год,
// клиент проверяет offline; перед обновлением bj-server гейтит установку
// валидной непросроченной лицензией.
//
// Издатель держит приватный ключ в секрете; публичный зашит в bj-server.
package license
import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"time"
)
const CurrentSchema = 1
// Plan — тариф лицензии.
type Plan string
const (
PlanFree Plan = "free"
PlanPro Plan = "pro"
PlanEnterprise Plan = "enterprise"
)
// License — содержимое лицензии (подписывается целиком).
type License struct {
Schema int `json:"schema"`
ID string `json:"id"` // UUID лицензии
Tenant string `json:"tenant"` // организация-клиент
Product string `json:"product"` // "bj-server"
Plan Plan `json:"plan"` // free|pro|enterprise
IssuedAt time.Time `json:"issued_at"` // дата выпуска
ExpiresAt time.Time `json:"expires_at"` // дата окончания (годовой ключ)
Features []string `json:"features,omitempty"` // "updates","web-cabinet",...
MaxNodes int `json:"max_nodes,omitempty"` // лимит инсталляций (0 = без лимита)
Note string `json:"note,omitempty"`
}
// Token — лицензия + подпись. Именно это вводит клиент (одна base64-строка
// или JSON-файл). Формат: base64url(payload).base64url(sig) — компактно.
type Token struct {
Payload string `json:"payload"` // base64(каноничный JSON License)
Signature string `json:"signature"` // base64(ed25519 over каноничным JSON)
KeyID string `json:"key_id,omitempty"`
}
// Canonical сериализует лицензию детерминированно (для подписи/проверки).
func (l *License) Canonical() ([]byte, error) {
if l.Schema == 0 {
l.Schema = CurrentSchema
}
return json.Marshal(l)
}
// Valid проверяет срок действия на момент now.
func (l *License) Valid(now time.Time) error {
if now.Before(l.IssuedAt.Add(-24 * time.Hour)) {
return errors.New("license: ещё не действует (issued_at в будущем)")
}
if now.After(l.ExpiresAt) {
return fmt.Errorf("license: истекла %s", l.ExpiresAt.Format("02.01.2006"))
}
return nil
}
// HasFeature — включена ли фича (или план enterprise — всё включено).
func (l *License) HasFeature(f string) bool {
if l.Plan == PlanEnterprise {
return true
}
for _, x := range l.Features {
if x == f {
return true
}
}
return false
}
// AllowsUpdates — разрешены ли обновления по этой лицензии.
func (l *License) AllowsUpdates() bool { return l.HasFeature("updates") }
// DaysLeft — сколько дней до окончания (может быть отрицательным).
func (l *License) DaysLeft(now time.Time) int {
return int(l.ExpiresAt.Sub(now).Hours() / 24)
}
// Sign подписывает лицензию и возвращает Token.
func Sign(l *License, priv ed25519.PrivateKey, keyID string) (*Token, error) {
payload, err := l.Canonical()
if err != nil {
return nil, fmt.Errorf("license: canonical: %w", err)
}
sig := ed25519.Sign(priv, payload)
return &Token{
Payload: base64.StdEncoding.EncodeToString(payload),
Signature: base64.StdEncoding.EncodeToString(sig),
KeyID: keyID,
}, nil
}
// Verify проверяет подпись и возвращает License (срок проверяется отдельно
// через License.Valid — Verify только про подлинность).
func Verify(t *Token, pub ed25519.PublicKey) (*License, error) {
sig, err := base64.StdEncoding.DecodeString(t.Signature)
if err != nil {
return nil, fmt.Errorf("license: decode signature: %w", err)
}
payload, err := base64.StdEncoding.DecodeString(t.Payload)
if err != nil {
return nil, fmt.Errorf("license: decode payload: %w", err)
}
if !ed25519.Verify(pub, payload, sig) {
return nil, errors.New("license: подпись недействительна")
}
var l License
if err := json.Unmarshal(payload, &l); err != nil {
return nil, fmt.Errorf("license: unmarshal: %w", err)
}
if l.Schema != CurrentSchema {
return nil, fmt.Errorf("license: неподдерживаемая схема %d", l.Schema)
}
return &l, nil
}
// Encode сериализует Token в компактную строку payload.signature[.keyid]
// (то, что клиент вставляет в поле «лицензионный ключ»).
func (t *Token) Encode() string {
s := t.Payload + "." + t.Signature
if t.KeyID != "" {
s += "." + t.KeyID
}
return s
}
// DecodeToken разбирает компактную строку обратно в Token.
func DecodeToken(s string) (*Token, error) {
parts := strings.Split(strings.TrimSpace(s), ".")
if len(parts) < 2 {
return nil, errors.New("license: неверный формат ключа (ожидается payload.signature)")
}
t := &Token{Payload: parts[0], Signature: parts[1]}
if len(parts) >= 3 {
t.KeyID = parts[2]
}
return t, nil
}
// --- Ключи (как в release) ---
func LoadPrivateKey(path string) (ed25519.PrivateKey, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
seed, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(b)))
if err != nil {
return nil, fmt.Errorf("license: decode seed: %w", err)
}
if len(seed) != ed25519.SeedSize {
return nil, fmt.Errorf("license: неверный размер seed %d", len(seed))
}
return ed25519.NewKeyFromSeed(seed), nil
}
func ParsePublicKey(b64 string) (ed25519.PublicKey, error) {
pub, err := base64.StdEncoding.DecodeString(strings.TrimSpace(b64))
if err != nil {
return nil, err
}
if len(pub) != ed25519.PublicKeySize {
return nil, fmt.Errorf("license: неверный размер pubkey %d", len(pub))
}
return ed25519.PublicKey(pub), nil
}
+65
View File
@@ -0,0 +1,65 @@
package license
import (
"crypto/ed25519"
"crypto/rand"
"testing"
"time"
)
func mkLicense(plan Plan, expires time.Time, feats ...string) *License {
return &License{
ID: "test-id", Tenant: "ООО Тест", Product: "bj-server",
Plan: plan, IssuedAt: time.Now().UTC().Add(-time.Hour),
ExpiresAt: expires, Features: feats,
}
}
func TestSignVerifyAndEncode(t *testing.T) {
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
l := mkLicense(PlanPro, time.Now().Add(365*24*time.Hour), "updates")
tok, err := Sign(l, priv, "main")
if err != nil {
t.Fatal(err)
}
// round-trip через компактную строку
dec, err := DecodeToken(tok.Encode())
if err != nil {
t.Fatal(err)
}
got, err := Verify(dec, pub)
if err != nil {
t.Fatalf("Verify: %v", err)
}
if got.Tenant != l.Tenant || got.Plan != PlanPro || !got.AllowsUpdates() {
t.Fatalf("mismatch: %+v", got)
}
}
func TestExpired(t *testing.T) {
l := mkLicense(PlanPro, time.Now().Add(-time.Hour), "updates")
if err := l.Valid(time.Now().UTC()); err == nil {
t.Fatal("истёкшая лицензия прошла Valid")
}
}
func TestFeaturesAndEnterprise(t *testing.T) {
pro := mkLicense(PlanPro, time.Now().Add(time.Hour), "updates")
if !pro.HasFeature("updates") || pro.HasFeature("web-cabinet") {
t.Fatal("pro features неверны")
}
ent := mkLicense(PlanEnterprise, time.Now().Add(time.Hour))
if !ent.HasFeature("anything") || !ent.AllowsUpdates() {
t.Fatal("enterprise должен включать всё")
}
}
func TestVerifyRejectsWrongKey(t *testing.T) {
_, priv, _ := ed25519.GenerateKey(rand.Reader)
other, _, _ := ed25519.GenerateKey(rand.Reader)
l := mkLicense(PlanPro, time.Now().Add(time.Hour))
tok, _ := Sign(l, priv, "main")
if _, err := Verify(tok, other); err == nil {
t.Fatal("Verify принял подпись чужим ключом")
}
}
+51 -14
View File
@@ -19,19 +19,29 @@ var templatesFS embed.FS
// конкретный content-шаблон). Так html/template не путается с несколькими
// {{define "content"}} в разных файлах.
type admin struct {
home, claims, claim, status, setup *template.Template
help, helpDatabase, helpLK, helpCryptoPro, helpSystems, helpRobot *template.Template
wizard, news *template.Template
home, claims, claim, status, setup *template.Template
help, helpDatabase, helpLK, helpCrypto, helpSystems, helpRobot, helpArchitecture *template.Template
wizard, news, keyWizard *template.Template
}
// templateFuncs — функции, доступные внутри шаблонов. Главная задача —
// русификация статусов и других технических обозначений (см. требование
// «всё UI на русском, кроме программных терминов»).
var templateFuncs = template.FuncMap{
"ru": russianText,
"ruState": russianState,
"ruOutcome": russianOutcome,
"now": time.Now,
"ru": russianText,
"ruState": russianState,
"ruOutcome": russianOutcome,
"now": time.Now,
"add": func(a, b int) int { return a + b },
"fallbackTpl": fallback,
"anyKeymedia": func(ds []flashDrive) bool {
for _, d := range ds {
if d.IsKeymedia {
return true
}
}
return false
},
}
// russianState переводит технический FSM-state в человекочитаемый
@@ -115,9 +125,9 @@ func newAdmin() (*admin, error) {
if err != nil {
return nil, fmt.Errorf("parse admin_help_lk: %w", err)
}
helpCP, err := parse("admin_help_cryptopro.html")
helpCrypto, err := parse("admin_help_crypto.html")
if err != nil {
return nil, fmt.Errorf("parse admin_help_cryptopro: %w", err)
return nil, fmt.Errorf("parse admin_help_crypto: %w", err)
}
helpSys, err := parse("admin_help_systems.html")
if err != nil {
@@ -135,11 +145,19 @@ func newAdmin() (*admin, error) {
if err != nil {
return nil, fmt.Errorf("parse admin_help_robot: %w", err)
}
helpArch, err := parse("admin_help_architecture.html")
if err != nil {
return nil, fmt.Errorf("parse admin_help_architecture: %w", err)
}
keyWizard, err := parse("admin_keywizard.html")
if err != nil {
return nil, fmt.Errorf("parse admin_keywizard: %w", err)
}
return &admin{
home: home, claims: claims, claim: claim, status: status, setup: setup,
help: help, helpDatabase: helpDB, helpLK: helpLK, helpCryptoPro: helpCP, helpSystems: helpSys,
helpRobot: helpRobot,
wizard: wizard, news: news,
help: help, helpDatabase: helpDB, helpLK: helpLK, helpCrypto: helpCrypto, helpSystems: helpSys,
helpRobot: helpRobot, helpArchitecture: helpArch,
wizard: wizard, news: news, keyWizard: keyWizard,
}, nil
}
@@ -169,6 +187,11 @@ type homeData struct {
}
Recent []ClaimView
News []NewsItem // top-3 активных или свежих новостей
// Сводка готовности системы для hero-блока дашборда.
AllReady bool
NotReadyCount int
TotalCount int
}
// claimsData — данные журнала.
@@ -218,12 +241,14 @@ func RegisterAdmin(mux *http.ServeMux, svc *Service, rc *RuntimeConfig, getOpts
render(w, a.helpDatabase, nowPage("База данных", "help"))
case p == "help/lk-api":
render(w, a.helpLK, nowPage("API ЛК", "help"))
case p == "help/cryptopro":
render(w, a.helpCryptoPro, nowPage("КриптоПро", "help"))
case p == "help/crypto":
render(w, a.helpCrypto, nowPage("Криптография", "help"))
case p == "help/systems":
render(w, a.helpSystems, nowPage("Внешние системы", "help"))
case p == "help/robot":
render(w, a.helpRobot, nowPage("Тестирование с роботом", "help"))
case p == "help/architecture":
render(w, a.helpArchitecture, nowPage("Архитектура обмена", "help"))
default:
http.NotFound(w, r)
}
@@ -248,6 +273,18 @@ func (a *admin) renderHome(w http.ResponseWriter, r *http.Request, svc *Service,
Recent: recent.Items,
News: topNews(rc.Snapshot().News.Items, 3),
}
// Готовность системы считаем ТОЛЬКО по обязательным компонентам.
// Опциональные (напр. callback в ЛК) не влияют на «готовность».
for _, c := range status.Checks {
if c.Optional {
continue
}
data.TotalCount++
if !c.OK {
data.NotReadyCount++
}
}
data.AllReady = data.TotalCount > 0 && data.NotReadyCount == 0
full, err := svc.ListClaims(ctx, m2mcore.Filter{Limit: 200})
if err == nil {
for _, c := range full.Items {
+25 -42
View File
@@ -12,12 +12,14 @@ import (
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// caCertsDir — куда складываются скачанные сертификаты УЦ.
const caCertsDir = "/var/lib/bj/ca-certs"
// defaultNSDCAURLs — список URL для авто-загрузки сертификатов УЦ НРД.
// Эти URL пользователь может скорректировать в /admin/setup → «Сертификаты
// УЦ» (раздел появляется после первого сохранения настроек). На сайте НРД
@@ -31,8 +33,8 @@ var defaultNSDCAURLs = []string{
// нужные ссылки в UI после того, как уточните URL у НРД.
}
// FetchCACertificates скачивает все URL из настроек, парсит .cer, и при
// успехе вызывает certmgr -inst -store mroot. Если передан rc — на каждое
// FetchCACertificates скачивает все URL из настроек, парсит .cer и
// сохраняет файл в /var/lib/bj/ca-certs/. Если передан rc — на каждое
// фактическое изменение сертификата (новый или изменился SHA-256)
// публикуется новость в ленту через rc.AddNews. На сертификаты,
// истекающие в ближайшие 14 дней — отдельная новость-предупреждение.
@@ -68,13 +70,13 @@ func FetchCACertificates(ctx context.Context, s CACertsSettings, rc *RuntimeConf
fc.IssuerCN = cert.Issuer.CommonName
fc.NotAfter = cert.NotAfter
fc.SHA256 = hex.EncodeToString(sha256Bytes(der))
// УЦ-сертификаты с самоподписью (Issuer == Subject) идут в mroot,
// промежуточные — в uRoot.
store := "uRoot"
// Корневые (Issuer == Subject) и промежуточные складываем рядом,
// в общую папку /var/lib/bj/ca-certs/.
kind := "intermediate"
if cert.Subject.CommonName == cert.Issuer.CommonName {
store = "mroot"
kind = "root"
}
fc.Store = store
fc.Store = kind
// Дедуп: если sha256 совпадает с уже импортированным — пропускаем
// сам импорт (но фиксируем что проверили).
@@ -91,7 +93,7 @@ func FetchCACertificates(ctx context.Context, s CACertsSettings, rc *RuntimeConf
continue
}
// Импорт через certmgr.
// Сохраняем DER на диск в /var/lib/bj/ca-certs/<sha>.cer.
isNew := true
for _, old := range s.FetchedCerts {
if old.URL == u && old.Error == "" {
@@ -99,22 +101,22 @@ func FetchCACertificates(ctx context.Context, s CACertsSettings, rc *RuntimeConf
break
}
}
if err := importCertToStore(ctx, der, store); err != nil {
fc.Error = "certmgr: " + err.Error()
fmt.Fprintf(&logBuf, "%s — certmgr упал: %s\n", u, err)
if err := saveCertToDir(der, fc.SHA256); err != nil {
fc.Error = "save: " + err.Error()
fmt.Fprintf(&logBuf, "%s — сохранить не удалось: %s\n", u, err)
if rc != nil {
_ = rc.AddNews(NewsItem{
ID: "ca-error-" + fc.SHA256[:12],
At: now,
Kind: "system",
Title: "Не удалось импортировать сертификат УЦ",
Title: "Не удалось сохранить сертификат УЦ",
Body: "URL: " + u + "\nCN: " + fc.SubjectCN + "\nОшибка: " + err.Error(),
URL: u,
})
}
} else {
fmt.Fprintf(&logBuf, "%s — импортирован в %s (CN=%s, sha256=%s...)\n",
u, store, fc.SubjectCN, fc.SHA256[:12])
fmt.Fprintf(&logBuf, "%s — сохранён (%s, CN=%s, sha256=%s...)\n",
u, kind, fc.SubjectCN, fc.SHA256[:12])
if rc != nil {
kindTitle := "Обновлён сертификат УЦ"
if isNew {
@@ -125,8 +127,8 @@ func FetchCACertificates(ctx context.Context, s CACertsSettings, rc *RuntimeConf
At: now,
Kind: "feature",
Title: kindTitle + ": " + fc.SubjectCN,
Body: fmt.Sprintf("Хранилище: %s\nИздатель: %s\nДействителен до: %s\nSHA-256: %s…\nURL источника: %s",
store, fc.IssuerCN, fc.NotAfter.Format("02.01.2006"), fc.SHA256[:16], u),
Body: fmt.Sprintf("Тип: %s\nИздатель: %s\nДействителен до: %s\nSHA-256: %s…\nURL источника: %s",
kind, fc.IssuerCN, fc.NotAfter.Format("02.01.2006"), fc.SHA256[:16], u),
URL: u,
ValidTo: fc.NotAfter,
})
@@ -198,29 +200,13 @@ func downloadAndParseCert(ctx context.Context, rawURL string) ([]byte, error) {
return data, nil
}
// importCertToStore вызывает certmgr -inst -store <store> -file <tmp>.
func importCertToStore(ctx context.Context, der []byte, store string) error {
const certmgr = "/opt/cprocsp/bin/amd64/certmgr"
if _, err := os.Stat(certmgr); err != nil {
return fmt.Errorf("certmgr не найден (КриптоПро CSP не установлен?): %w", err)
}
tmp, err := os.CreateTemp("", "bj-ca-*.cer")
if err != nil {
// saveCertToDir сохраняет DER-байты в /var/lib/bj/ca-certs/<sha>.cer.
func saveCertToDir(der []byte, sha256hex string) error {
if err := os.MkdirAll(caCertsDir, 0o755); err != nil {
return err
}
defer os.Remove(tmp.Name())
if _, err := tmp.Write(der); err != nil {
tmp.Close()
return err
}
tmp.Close()
cmd := exec.CommandContext(ctx, certmgr, "-inst", "-store", store, "-file", tmp.Name())
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%w / %s", err, strings.TrimSpace(string(out)))
}
return nil
dst := filepath.Join(caCertsDir, sha256hex+".cer")
return os.WriteFile(dst, der, 0o644)
}
// StartCACertsAutoUpdater запускает горутину, которая раз в сутки
@@ -312,7 +298,4 @@ func (h *setupHandlers) fetchCACertsNow(w http.ResponseWriter, r *http.Request)
// caCertsTemplateString — компактный URL для отображения в UI.
func caCertsTemplateString(s CACertsSettings) string {
return strings.Join(s.URLs, "\n")
}
// доп. защита от пустых импортов (linter)
var _ = filepath.Join
}
+37 -12
View File
@@ -5,6 +5,8 @@ import (
"errors"
"net"
"net/http"
"github.com/jackc/pgx/v5/pgxpool"
"os"
"time"
)
@@ -15,6 +17,9 @@ type Status struct {
OK bool `json:"ok"`
Message string `json:"message,omitempty"`
Detail string `json:"detail,omitempty"`
// Optional — компонент не обязателен для работы с НРД. Его «не-OK»
// не делает систему «не готовой» (напр. callback в ЛК).
Optional bool `json:"optional,omitempty"`
}
// SystemStatus — все проверки.
@@ -55,17 +60,30 @@ func CheckAll(ctx context.Context, o CheckOptions) SystemStatus {
return out
}
func checkPostgres(_ context.Context, o CheckOptions) Status {
s := Status{Name: "postgres"}
func checkPostgres(ctx context.Context, o CheckOptions) Status {
s := Status{Name: "База данных PostgreSQL"}
if o.PostgresDSN == "" {
s.OK = true
s.Message = "in-memory (PostgresDSN не задан, репозиторий — m2mcore.MemoryRepository)"
s.Optional = true
s.Message = "in-memory — данные не сохраняются между перезапусками"
return s
}
// На M2 здесь будет sql.Open + Ping. На текущем шаге — заглушка.
s.OK = false
s.Message = "PostgreSQL Repository не подключён (требуется pgx, M2-шаг-3)"
s.Detail = "DSN: " + o.PostgresDSN
pctx, cancel := context.WithTimeout(ctx, o.Timeout)
defer cancel()
pool, err := pgxpool.New(pctx, o.PostgresDSN)
if err != nil {
s.OK = false
s.Message = "ошибка подключения: " + err.Error()
return s
}
defer pool.Close()
if err := pool.Ping(pctx); err != nil {
s.OK = false
s.Message = "не отвечает: " + err.Error()
return s
}
s.OK = true
s.Message = "подключена, репозиторий m2m_core.deals"
return s
}
@@ -111,20 +129,27 @@ func checkCryptoSocket(o CheckOptions) Status {
}
func checkNSDAdapter(ctx context.Context, o CheckOptions) Status {
s := Status{Name: "nsd-adapter (REST к ИШ)"}
s := Status{Name: "Интеграционный шлюз НРД"}
if o.NSDAdapterURL == "" {
s.OK = true
s.Message = "BJ_NSD_ADAPTER_URL не задан — используется mock NSDSender"
s.Optional = true
s.Message = "не подключён — режим эмуляции (mock)"
return s
}
return httpHealth(ctx, o.NSDAdapterURL+"/healthz", o.Timeout, s)
// У ИШ нет /healthz — проверяем рабочий эндпоинт Web API (engine/state
// отвечает 200 «Running», когда движок поднят).
st := httpHealth(ctx, o.NSDAdapterURL+"/api/admin/engine/state", o.Timeout, s)
if st.OK {
st.Message = "подключён, движок работает"
}
return st
}
func checkLKCallback(ctx context.Context, o CheckOptions) Status {
s := Status{Name: "lk-emulator (callback)"}
s := Status{Name: "Callback в личный кабинет", Optional: true}
if o.LKCallbackURL == "" {
s.OK = false
s.Message = "BJ_LK_CALLBACK_URL не задан — callback'и в ЛК отключены"
s.Message = "не настроен — уведомления в ЛК отключены (необязательно для работы с НРД)"
return s
}
return httpHealth(ctx, o.LKCallbackURL+"/healthz", o.Timeout, s)
-190
View File
@@ -1,190 +0,0 @@
package lkgateway
import (
"context"
"fmt"
"io"
"net/http"
"os"
"os/user"
"path/filepath"
"strings"
"time"
)
// FlashContainer — найденный на смонтированной флешке контейнер КриптоПро.
// КриптоПро CSP под Linux ожидает контейнер в виде папки <name>.000 с
// файлами header.key/masks.key/name.key/primary.key/primary2.key.
type FlashContainer struct {
// Mountpoint — путь смонтированной флешки, например /run/media/user/USB.
Mountpoint string
// Path — полный путь до папки <name>.000.
Path string
// Name — имя контейнера (без суффикса .000).
Name string
// Files — список файлов в контейнере (для дисплея).
Files []string
// AlreadyImported — true, если папка <name>.000 уже есть в локальном
// хранилище /var/opt/cprocsp/keys/<user>/.
AlreadyImported bool
}
// scanFlashContainers ищет контейнеры формата <name>.000 на типичных
// точках монтирования USB-носителей в Linux: /run/media/<user>/* и
// /media/<user>/* и /media/*. Возвращает список найденных контейнеров.
func scanFlashContainers() []FlashContainer {
u, err := user.Current()
if err != nil {
return nil
}
roots := []string{
filepath.Join("/run/media", u.Username),
filepath.Join("/media", u.Username),
"/media",
"/mnt",
}
localKeysDir := filepath.Join("/var/opt/cprocsp/keys", u.Username)
var out []FlashContainer
for _, root := range roots {
entries, err := os.ReadDir(root)
if err != nil {
continue
}
for _, e := range entries {
if !e.IsDir() {
continue
}
mountpoint := filepath.Join(root, e.Name())
out = append(out, findContainersAt(mountpoint, localKeysDir)...)
}
}
return out
}
func findContainersAt(mountpoint, localKeysDir string) []FlashContainer {
var out []FlashContainer
// Ищем папки <name>.000 на верхнем уровне и на 1 уровне вглубь.
_ = filepath.Walk(mountpoint, func(p string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
// Глубже 2 уровней не лезем (на флешке могут быть личные папки).
rel, _ := filepath.Rel(mountpoint, p)
if strings.Count(rel, string(filepath.Separator)) > 2 {
return filepath.SkipDir
}
if !info.IsDir() || !strings.HasSuffix(strings.ToLower(p), ".000") {
return nil
}
// Проверяем, что внутри лежат файлы вида *.key.
entries, _ := os.ReadDir(p)
var files []string
hasKey := false
for _, ent := range entries {
files = append(files, ent.Name())
if strings.HasSuffix(strings.ToLower(ent.Name()), ".key") {
hasKey = true
}
}
if !hasKey {
return nil
}
name := strings.TrimSuffix(filepath.Base(p), ".000")
fc := FlashContainer{
Mountpoint: mountpoint,
Path: p,
Name: name,
Files: files,
}
// Проверка: уже скопирован в локальное хранилище?
if _, err := os.Stat(filepath.Join(localKeysDir, name+".000")); err == nil {
fc.AlreadyImported = true
}
out = append(out, fc)
return filepath.SkipDir
})
return out
}
// copyContainerToLocal копирует папку <name>.000 с флешки в локальное
// хранилище КриптоПро /var/opt/cprocsp/keys/<user>/<name>.000. После
// этого контейнер виден как \\.\HDIMAGE\<name> и работает даже без
// вставленной флешки.
func copyContainerToLocal(srcDir string) (string, error) {
u, err := user.Current()
if err != nil {
return "", err
}
localKeysDir := filepath.Join("/var/opt/cprocsp/keys", u.Username)
if err := os.MkdirAll(localKeysDir, 0o700); err != nil {
return "", fmt.Errorf("создать %s: %w", localKeysDir, err)
}
base := filepath.Base(srcDir)
dstDir := filepath.Join(localKeysDir, base)
if _, err := os.Stat(dstDir); err == nil {
return "", fmt.Errorf("контейнер %s уже существует в локальном хранилище", dstDir)
}
if err := os.MkdirAll(dstDir, 0o700); err != nil {
return "", fmt.Errorf("создать %s: %w", dstDir, err)
}
entries, err := os.ReadDir(srcDir)
if err != nil {
return "", err
}
for _, e := range entries {
if e.IsDir() {
continue
}
src, err := os.Open(filepath.Join(srcDir, e.Name()))
if err != nil {
return "", err
}
dst, err := os.OpenFile(filepath.Join(dstDir, e.Name()),
os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
if err != nil {
src.Close()
return "", err
}
if _, err := io.Copy(dst, src); err != nil {
src.Close()
dst.Close()
return "", err
}
src.Close()
dst.Close()
}
return dstDir, nil
}
// copyContainer — POST /admin/setup/crypto/copy-container.
// Параметр src — путь до папки <name>.000 на флешке.
func (h *setupHandlers) copyContainer(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
src := strings.TrimSpace(r.FormValue("src"))
if src == "" {
setupFlash(w, r, "Копирование контейнера: не указан путь")
return
}
// Минимальная защита: ожидаем .000 в конце пути.
if !strings.HasSuffix(strings.ToLower(src), ".000") {
setupFlash(w, r, "Копирование контейнера: путь должен заканчиваться на .000")
return
}
if _, err := os.Stat(src); err != nil {
setupFlash(w, r, "Копирование контейнера: исходная папка недоступна: "+err.Error())
return
}
dst, err := copyContainerToLocal(src)
if err != nil {
setupFlash(w, r, "Копирование контейнера: "+err.Error())
return
}
// Дадим CSP несколько мс «заметить» новый контейнер (не критично).
_, cancel := context.WithTimeout(r.Context(), 100*time.Millisecond)
cancel()
setupFlash(w, r, "Контейнер скопирован в "+dst+". Теперь он виден как \\\\.\\HDIMAGE\\"+strings.TrimSuffix(filepath.Base(dst), ".000")+" и работает без вставленной флешки. Импортируйте сертификат: certmgr -inst -cont '\\\\.\\HDIMAGE\\"+strings.TrimSuffix(filepath.Base(dst), ".000")+"' -store uMy.")
}
+525
View File
@@ -0,0 +1,525 @@
package lkgateway
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/cryptocli"
)
// urlQ — экранирование строки для query-параметра flash.
func urlQ(s string) string { return url.QueryEscape(s) }
// Пошаговый мастер установки ключа Валидаты на съёмный носитель (USB keymedia
// ИШ). Реализует то, что просил пользователь: загрузил архив + пароль →
// распаковка → запись на флешку → формирование справочника сертификатов →
// проверка Валидаты → «Готово» → можно слать тестовый документ.
//
// Привилегированные операции с флешкой (бэкап, remount rw, запись, перезапуск
// VDCrySvc) делает помощник /usr/local/sbin/bj-keymedia-install через узкий
// sudoers (bj-server работает под непривилегированным bj).
// keyWizardStep — один шаг мастера.
type keyWizardStep struct {
Title string
Status string // pending | active | ok | error
Detail string
}
// keyWizardState — состояние одного прогона мастера (в памяти, один активный).
type keyWizardState struct {
mu sync.Mutex
StagingID string // id распаковки в /var/lib/bj/media/iso/<id>
VDK string // имя файла ключа
Profile string // имя установленного профиля на носителе
Backup string // путь бэкапа
Steps []keyWizardStep // 1..5
Done bool // все шаги пройдены
Flash string
}
func newKeyWizardState() *keyWizardState {
return &keyWizardState{Steps: defaultKeySteps()}
}
// reset обнуляет поля прогона, НЕ трогая мьютекс (вызывать под Lock).
func (s *keyWizardState) reset() {
s.StagingID = ""
s.VDK = ""
s.Profile = ""
s.Backup = ""
s.Steps = defaultKeySteps()
s.Done = false
s.Flash = ""
}
func defaultKeySteps() []keyWizardStep {
return []keyWizardStep{
{Title: "Загрузка архива и распаковка", Status: "pending"},
{Title: "Запись ключа на выбранную флешку (с бэкапом)", Status: "pending"},
{Title: "Формирование справочника сертификатов (CRL)", Status: "pending"},
{Title: "Перезапуск и проверка ИШ", Status: "pending"},
{Title: "Готово — можно отправлять тестовый документ", Status: "pending"},
}
}
func (s *keyWizardState) set(i int, status, detail string) {
if i >= 0 && i < len(s.Steps) {
s.Steps[i].Status = status
if detail != "" {
s.Steps[i].Detail = detail
}
}
}
// flashDrive — съёмный носитель (USB), обнаруженный в системе.
type flashDrive struct {
Device string // /dev/sdb1
Size string // 1,9G
Label string
FSType string
Mountpoint string // пусто если не смонтирован
Model string // USB2FlashStorage
IsKeymedia bool // смонтирован как текущий ключевой носитель ИШ
}
// keyWizardData — данные шаблона admin_keywizard.html.
type keyWizardData struct {
page
State *keyWizardState
Drives []flashDrive
}
const keymediaMount = "/var/lib/igate/keymedia"
// listFlashDrives перечисляет съёмные (removable/hotplug) USB-носители с ФС —
// чтобы пользователь выбрал, на какую флешку писать ключ.
func listFlashDrives() []flashDrive {
out, err := exec.Command("lsblk", "-J", "-b", "-o",
"NAME,SIZE,LABEL,MOUNTPOINT,RM,HOTPLUG,TYPE,MODEL,FSTYPE,PATH").Output()
if err != nil {
return nil
}
var parsed struct {
Blockdevices []json.RawMessage `json:"blockdevices"`
}
if json.Unmarshal(out, &parsed) != nil {
return nil
}
var drives []flashDrive
var walk func(raw []byte, parentRemovable bool, parentModel string)
walk = func(raw []byte, parentRemovable bool, parentModel string) {
var d struct {
Name string `json:"name"`
Size int64 `json:"size"`
Label string `json:"label"`
Mountpoint string `json:"mountpoint"`
RM bool `json:"rm"`
Hotplug bool `json:"hotplug"`
Type string `json:"type"`
Model string `json:"model"`
FSType string `json:"fstype"`
Path string `json:"path"`
Children []json.RawMessage `json:"children"`
}
if json.Unmarshal(raw, &d) != nil {
return
}
removable := d.RM || d.Hotplug || parentRemovable
model := strings.TrimSpace(d.Model)
if model == "" {
model = parentModel
}
// Носитель с ФС — кандидат на запись.
if removable && d.Type == "part" && d.FSType != "" {
drives = append(drives, flashDrive{
Device: d.Path,
Size: humanSize(d.Size),
Label: d.Label,
FSType: d.FSType,
Mountpoint: d.Mountpoint,
Model: model,
IsKeymedia: d.Mountpoint == keymediaMount,
})
}
for _, c := range d.Children {
walk(c, removable, model)
}
}
for _, b := range parsed.Blockdevices {
walk(b, false, "")
}
return drives
}
func humanSize(b int64) string {
switch {
case b >= 1<<30:
return fmt.Sprintf("%.1f ГБ", float64(b)/(1<<30))
case b >= 1<<20:
return fmt.Sprintf("%.0f МБ", float64(b)/(1<<20))
default:
return fmt.Sprintf("%d Б", b)
}
}
// registerKeyWizard вешает маршруты мастера установки ключа.
func (h *setupHandlers) registerKeyWizard(mux *http.ServeMux) {
if h.keyWiz == nil {
h.keyWiz = newKeyWizardState()
}
mux.HandleFunc("/admin/setup/keywizard", h.renderKeyWizard)
mux.HandleFunc("/admin/setup/keywizard/upload", h.keyWizardUpload)
mux.HandleFunc("/admin/setup/keywizard/install", h.keyWizardInstall)
mux.HandleFunc("/admin/setup/keywizard/reset", h.keyWizardReset)
}
func (h *setupHandlers) renderKeyWizard(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
h.keyWiz.mu.Lock()
h.keyWiz.Flash = r.URL.Query().Get("flash")
st := h.keyWiz
h.keyWiz.mu.Unlock()
// Список флешек нужен на шаге выбора носителя (когда архив уже загружен).
var drives []flashDrive
if st.StagingID != "" && !st.Done {
drives = listFlashDrives()
}
render(w, h.tpl.a.keyWizard, keyWizardData{page: nowPage("Установка ключа", "setup"), State: st, Drives: drives})
}
func (h *setupHandlers) keyWizardReset(w http.ResponseWriter, r *http.Request) {
h.keyWiz.mu.Lock()
h.keyWiz.reset()
h.keyWiz.mu.Unlock()
http.Redirect(w, r, "/admin/setup/keywizard", http.StatusSeeOther)
}
// keyWizardUpload — шаг 1: приём .7z + пароль, распаковка, инспекция.
func (h *setupHandlers) keyWizardUpload(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
if err := r.ParseMultipartForm(500 << 20); err != nil {
h.keyWizFlash(w, r, "Ошибка чтения формы: "+err.Error())
return
}
file, header, err := r.FormFile("archive")
if err != nil {
h.keyWizFlash(w, r, "Выберите файл архива (.7z/.zip)")
return
}
defer file.Close()
lower := strings.ToLower(header.Filename)
if !(strings.HasSuffix(lower, ".7z") || strings.HasSuffix(lower, ".zip")) {
h.keyWizFlash(w, r, "Архив должен быть .7z или .zip")
return
}
password := r.FormValue("password")
isoDir := "/var/lib/bj/iso"
if err := os.MkdirAll(isoDir, 0o755); err != nil {
h.keyWizFlash(w, r, "Не удалось создать "+isoDir+": "+err.Error())
return
}
dst := filepath.Join(isoDir, time.Now().UTC().Format("20060102-150405-")+filepath.Base(header.Filename))
out, err := os.Create(dst)
if err != nil {
h.keyWizFlash(w, r, "Запись архива: "+err.Error())
return
}
if _, err := io.Copy(out, file); err != nil {
out.Close()
_ = os.Remove(dst)
h.keyWizFlash(w, r, "Запись архива: "+err.Error())
return
}
out.Close()
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
defer cancel()
m, err := ExtractISO(ctx, dst, password)
if err != nil {
h.keyWizFlash(w, r, "Распаковка не удалась (проверьте пароль): "+err.Error())
return
}
h.keyWiz.mu.Lock()
h.keyWiz.reset()
h.keyWiz.StagingID = m.ID
// Инспекция через помощник (читает staging, находит .vdk/gdbm/pse).
staging := filepath.Join("/var/lib/bj/media/iso", m.ID)
insp, ierr := runKeymediaHelper(ctx, "inspect", staging, "", "", "")
if ierr == nil && insp["ok"] == true {
if v, ok := insp["vdk"].(string); ok {
h.keyWiz.VDK = v
}
}
detail := fmt.Sprintf("Ключ: %s · справочник сертификатов: %s",
fallback(h.keyWiz.VDK, "—"), yesNo(insp["has_gdbm"]))
h.keyWiz.set(0, "ok", detail)
h.keyWiz.set(1, "active", "")
h.keyWiz.mu.Unlock()
http.Redirect(w, r, "/admin/setup/keywizard", http.StatusSeeOther)
}
// keyWizardInstall — шаги 2-5: запись на флешку, справочник, проверка, готово.
func (h *setupHandlers) keyWizardInstall(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
h.keyWiz.mu.Lock()
id := h.keyWiz.StagingID
h.keyWiz.mu.Unlock()
if id == "" {
h.keyWizFlash(w, r, "Сначала загрузите архив (шаг 1)")
return
}
staging := filepath.Join("/var/lib/bj/media/iso", id)
// Выбор флешки и имя профиля из формы.
profileName := strings.TrimSpace(r.FormValue("profile_name"))
targetDev := strings.TrimSpace(r.FormValue("target_device"))
targetMnt := ""
if targetDev != "" {
for _, d := range listFlashDrives() {
if d.Device == targetDev {
targetMnt = d.Mountpoint
break
}
}
}
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
defer cancel()
// Шаг 2-3: запись ключа на флешку + справочник + CRL (привилегированный воркер).
res, err := runKeymediaHelper(ctx, "install", staging, profileName, targetDev, targetMnt)
h.keyWiz.mu.Lock()
if err != nil || res["ok"] != true {
msg := errStr(err)
if e, ok := res["error"].(string); ok {
msg = e
}
h.keyWiz.set(1, "error", "Запись на флешку не удалась: "+msg)
h.keyWiz.mu.Unlock()
h.keyWizFlash(w, r, "Установка прервана: "+msg)
return
}
if p, ok := res["profile"].(string); ok {
h.keyWiz.Profile = p
}
if b, ok := res["backup"].(string); ok {
h.keyWiz.Backup = b
}
tgt, _ := res["target"].(string)
spr, _ := res["spr"].(string)
h.keyWiz.set(1, "ok", fmt.Sprintf("Профиль «%s» записан на %s. Бэкап: %s", h.keyWiz.Profile, fallback(tgt, "носитель"), h.keyWiz.Backup))
crl, _ := res["crl"].(string)
h.keyWiz.set(2, "ok", fmt.Sprintf("Справочник «%s» сформирован. CRL: %s", fallback(spr, "—"), crlRu(crl)))
h.keyWiz.set(3, "active", "")
h.keyWiz.mu.Unlock()
// Шаг 4: перезапуск и проверка ИШ. Перезапуск VDCrySvc мог сбросить
// активацию серверного профиля bj-crypto — восстанавливаем. Затем
// перезапускаем ИШ и проверяем engine/state: ИШ поднялся с новым ключом.
h.reactivateCryptoProfile(ctx)
ishOK, ishMsg := h.restartAndVerifyISH(ctx)
h.keyWiz.mu.Lock()
if !ishOK {
h.keyWiz.set(3, "error", "ИШ не подтвердил готовность: "+ishMsg)
h.keyWiz.mu.Unlock()
h.keyWizFlash(w, r, "Ключ записан, но ИШ не готов: "+ishMsg)
return
}
h.keyWiz.set(3, "ok", "ИШ перезапущен и работает: "+ishMsg)
h.keyWiz.set(4, "ok", "Теперь подпишите новым ключом — отправьте тестовый документ роботу НРД ниже")
h.keyWiz.Done = true
h.keyWiz.mu.Unlock()
http.Redirect(w, r, "/admin/setup/keywizard?flash="+urlQ("Готово! Ключ на флешке, справочник сформирован, ИШ перезапущен и работает. Финальная проверка — тестовым документом роботу."), http.StatusSeeOther)
}
// restartAndVerifyISH перезапускает Интеграционный шлюз и проверяет, что он
// поднялся (engine/state). Возвращает (ok, сообщение).
func (h *setupHandlers) restartAndVerifyISH(ctx context.Context) (bool, string) {
// Перезапуск igate через привилегированный воркер (bj не sudoer).
res, err := runKeymediaHelper(ctx, "restart-ish", "/var/lib/bj/media/iso", "", "", "")
if err != nil || res["ok"] != true {
// restart-ish может быть не поддержан — не критично, проверим состояние.
_ = err
}
// Проверяем состояние ИШ через nsd-адаптер (engine/state).
deadline := time.Now().Add(40 * time.Second)
for time.Now().Before(deadline) {
if st := h.ishEngineState(ctx); st != "" {
return true, "engine "+st
}
select {
case <-ctx.Done():
return false, "таймаут"
case <-time.After(3 * time.Second):
}
}
return false, "ИШ не ответил на /api/admin/engine/state за 40 сек"
}
// ishEngineState запрашивает состояние движка ИШ; пусто если недоступен.
func (h *setupHandlers) ishEngineState(ctx context.Context) string {
s := h.rc.Snapshot()
base := s.NSD.IGWBaseURL
if base == "" {
return ""
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(base, "/")+"/api/admin/engine/state", nil)
if err != nil {
return ""
}
cl := &http.Client{Timeout: 5 * time.Second}
resp, err := cl.Do(req)
if err != nil {
return ""
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return ""
}
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return strings.TrimSpace(string(b))
}
func crlRu(s string) string {
switch s {
case "updated":
return "обновлены из точек распространения"
case "failed":
return "не удалось обновить (проверьте сеть/CDP)"
case "skip":
return "пропущено"
default:
return s
}
}
// reactivateCryptoProfile повторно активирует текущий серверный профиль
// bj-crypto (после перезапуска VDCrySvc активация в сайдкаре сбрасывается).
// Best-effort: возвращает true при успехе.
func (h *setupHandlers) reactivateCryptoProfile(ctx context.Context) bool {
s := h.rc.Snapshot()
if s.Crypto.Profile == "" {
return false
}
cli := cryptocli.New(cryptocli.Config{
Provider: cryptocli.Provider(s.Crypto.Provider),
SocketPath: s.Crypto.SocketPath,
})
defer cli.Close()
res, err := cli.Activate(ctx, s.Crypto.Profile)
return err == nil && res.OK
}
// vdcrysvcActive проверяет, что демон Валидаты (vdmkdev) запущен.
func vdcrysvcActive() bool {
out, _ := exec.Command("systemctl", "is-active", "vdmkdev.service").Output()
return strings.TrimSpace(string(out)) == "active"
}
func boolRu(b bool, yes, no string) string {
if b {
return yes
}
return no
}
const keymediaReqDir = "/var/lib/bj/keymedia-requests"
// runKeymediaHelper передаёт запрос привилегированному воркеру через файловый
// обмен: bj-server (в песочнице) пишет <id>.req, root-сервис bj-keymedia
// (host namespace, триггерится bj-keymedia.path) выполняет операцию с флешкой
// и пишет <id>.res. bj-server опрашивает результат. Так привилегированная
// работа идёт вне mount-namespace песочницы, где доступно перемонтирование USB.
func runKeymediaHelper(ctx context.Context, action, staging, profile, targetDev, targetMnt string) (map[string]any, error) {
id := fmt.Sprintf("%s-%d", action, time.Now().UnixNano())
reqPath := filepath.Join(keymediaReqDir, id+".req")
resPath := filepath.Join(keymediaReqDir, id+".res")
defer os.Remove(resPath)
reqBody, _ := json.Marshal(map[string]string{
"action": action, "staging": staging, "profile": profile,
"target_dev": targetDev, "target_mnt": targetMnt,
})
// Пишем атомарно (tmp → rename), чтобы .path не подхватил полупустой файл.
tmp := reqPath + ".tmp"
if err := os.WriteFile(tmp, reqBody, 0o660); err != nil {
return nil, fmt.Errorf("запись запроса: %w", err)
}
if err := os.Rename(tmp, reqPath); err != nil {
return nil, fmt.Errorf("публикация запроса: %w", err)
}
// Опрос результата.
ticker := time.NewTicker(200 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return nil, fmt.Errorf("таймаут ожидания воркера установки ключа")
case <-ticker.C:
b, err := os.ReadFile(resPath)
if err != nil {
continue // ещё не готово
}
res := map[string]any{}
if jerr := json.Unmarshal(b, &res); jerr != nil {
return nil, fmt.Errorf("разбор ответа воркера: %v (%s)", jerr, strings.TrimSpace(string(b)))
}
if res["ok"] != true {
msg, _ := res["error"].(string)
return res, fmt.Errorf("воркер: %s", fallback(msg, "ошибка"))
}
return res, nil
}
}
}
func (h *setupHandlers) keyWizFlash(w http.ResponseWriter, r *http.Request, msg string) {
http.Redirect(w, r, "/admin/setup/keywizard?flash="+urlQ(msg), http.StatusSeeOther)
}
func fallback(s, def string) string {
if s == "" {
return def
}
return s
}
func yesNo(v any) string {
if b, ok := v.(bool); ok && b {
return "да"
}
return "нет"
}
func errStr(err error) string {
if err == nil {
return "неизвестная ошибка"
}
return err.Error()
}
+79
View File
@@ -0,0 +1,79 @@
package lkgateway
import (
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/license"
)
// DefaultLicensePublicKey — публичный ключ лицензий, зашитый в релиз.
// Пустой в исходниках; подставляется при официальной сборке. Если задан в
// настройках (LicenseSettings.PublicKey) — приоритет у настроек.
var DefaultLicensePublicKey = ""
// LicenseStatus — сводка состояния лицензии для UI и гейтов.
type LicenseStatus struct {
Present bool // ключ введён
Valid bool // подпись верна и срок не истёк
Tenant string
Plan string
ExpiresAt time.Time
DaysLeft int
AllowsUpdates bool
Message string
}
// licensingEnabled — включено ли лицензирование (есть публичный ключ для
// проверки). Если ключа нет вовсе — продукт в открытом режиме, гейты не
// действуют (удобно для разработки и бесплатной редакции).
func licensingEnabled(rc *RuntimeConfig) bool {
return rc.Snapshot().License.PublicKey != "" || DefaultLicensePublicKey != ""
}
// licenseStatus разбирает и проверяет лицензию из настроек.
func licenseStatus(rc *RuntimeConfig) LicenseStatus {
s := rc.Snapshot().License
st := LicenseStatus{}
if s.Key == "" {
st.Message = "лицензионный ключ не введён"
return st
}
st.Present = true
pubB64 := s.PublicKey
if pubB64 == "" {
pubB64 = DefaultLicensePublicKey
}
if pubB64 == "" {
st.Message = "нет публичного ключа для проверки лицензии"
return st
}
pub, err := license.ParsePublicKey(pubB64)
if err != nil {
st.Message = "неверный публичный ключ: " + err.Error()
return st
}
tok, err := license.DecodeToken(s.Key)
if err != nil {
st.Message = "неверный формат ключа: " + err.Error()
return st
}
lic, err := license.Verify(tok, pub)
if err != nil {
st.Message = "подпись лицензии недействительна"
return st
}
now := time.Now().UTC()
st.Tenant = lic.Tenant
st.Plan = string(lic.Plan)
st.ExpiresAt = lic.ExpiresAt
st.DaysLeft = lic.DaysLeft(now)
st.AllowsUpdates = lic.AllowsUpdates()
if err := lic.Valid(now); err != nil {
st.Message = err.Error()
return st
}
st.Valid = true
st.Message = "активна до " + lic.ExpiresAt.Format("02.01.2006")
return st
}
+745
View File
@@ -0,0 +1,745 @@
package lkgateway
import (
"context"
"crypto/sha256"
"crypto/x509"
"encoding/asn1"
"encoding/hex"
"encoding/pem"
"errors"
"fmt"
"os"
"os/exec"
"os/user"
"path/filepath"
"strings"
"time"
)
// MediaRoot — где bj-server хранит свои носители: распакованные ISO,
// импортированные ключевые контейнеры. На прод-машине пользователь bj
// должен быть владельцем этой директории (создаётся install.sh).
const (
mediaRoot = "/var/lib/bj/media"
mediaISODir = "/var/lib/bj/media/iso"
containersDir = "/var/lib/bj/containers"
profilesDir = "/var/lib/bj/profiles"
keyFileMinPerDir = 2 // считаем директорию контейнером, если в ней >= 2 *.key файлов
)
// Medium — один носитель: USB-флешка или распакованная ISO.
type Medium struct {
// ID — стабильный идентификатор (для USB — sha1 от пути монтирования,
// для ISO — sha256-prefix от исходного файла).
ID string `json:"id"`
// Kind — "usb" или "iso".
Kind string `json:"kind"`
// Mountpoint — корень, по которому сейчас доступен носитель.
Mountpoint string `json:"mountpoint"`
// Source — для ISO: путь до исходного .iso на сервере.
Source string `json:"source,omitempty"`
// Profile — полный профиль Валидаты (pse + gdbm + vdkeys), если найден.
Profile *ValidataProfile `json:"profile,omitempty"`
// Containers — найденные ключевые контейнеры (директории с *.key/*.vdk).
Containers []KeyContainer `json:"containers"`
// Certificates — отдельно лежащие сертификаты (.cer/.crt/.pem/.pfx/.p12).
Certificates []CertFile `json:"certificates"`
}
// ValidataProfile — полный профиль АПК «Валидата Клиент L»: ПСП (.pse),
// ЛСП (.gdbm) и ключи (vdkeys/*.vdk).
type ValidataProfile struct {
Root string `json:"root"` // mountpoint, где найден профиль
PSEFiles []string `json:"pse_files"` // относительные пути до .pse
GDBMFiles []string `json:"gdbm_files"` // относительные пути до .gdbm
KeyFiles []string `json:"key_files"` // относительные пути до .vdk
Imported bool `json:"imported"` // уже скопирован в /var/lib/bj/profiles/
}
// KeyContainer — ключевой контейнер: директория с *.key или *.vdk.
type KeyContainer struct {
Path string `json:"path"`
Name string `json:"name"` // имя последней компоненты пути
Files []string `json:"files"` // имена файлов в контейнере
Imported bool `json:"imported"` // уже скопирован в /var/lib/bj/containers/
}
// CertFile — публичный или PKCS#12 сертификат.
type CertFile struct {
Path string `json:"path"`
Name string `json:"name"`
Format string `json:"format"` // "cer" | "pem" | "pfx"
SubjectCN string `json:"subject_cn"`
IssuerCN string `json:"issuer_cn"`
Serial string `json:"serial"`
NotBefore time.Time `json:"not_before"`
NotAfter time.Time `json:"not_after"`
INN string `json:"inn,omitempty"`
HasPrivateKey bool `json:"has_private_key"` // true для .pfx/.p12
ParseError string `json:"parse_error,omitempty"`
}
// ScanMedia собирает список всех видимых носителей: USB + распакованные
// ISO. Безопасна для частых вызовов — IO ограничен директориями верхнего
// уровня в типичных mount-точках.
func ScanMedia() []Medium {
var out []Medium
out = append(out, scanUSB()...)
out = append(out, listExtractedISOs()...)
return out
}
// scanUSB ищет USB-монтирования в /run/media/$USER, /media/$USER, /media, /mnt.
func scanUSB() []Medium {
u, err := user.Current()
if err != nil {
return nil
}
roots := []string{
filepath.Join("/run/media", u.Username),
filepath.Join("/media", u.Username),
"/media",
"/mnt",
}
var out []Medium
seen := map[string]bool{}
for _, root := range roots {
entries, err := os.ReadDir(root)
if err != nil {
continue
}
for _, e := range entries {
if !e.IsDir() {
continue
}
mountpoint := filepath.Join(root, e.Name())
// Не лезем в наши собственные /var/lib/bj/media/iso/*.
if strings.HasPrefix(mountpoint, mediaISODir) {
continue
}
if seen[mountpoint] {
continue
}
seen[mountpoint] = true
out = append(out, scanMountpoint("usb", mountpoint, ""))
}
}
return out
}
// listExtractedISOs возвращает все ранее распакованные ISO в /var/lib/bj/media/iso/.
func listExtractedISOs() []Medium {
entries, err := os.ReadDir(mediaISODir)
if err != nil {
return nil
}
var out []Medium
for _, e := range entries {
if !e.IsDir() {
continue
}
id := e.Name()
mountpoint := filepath.Join(mediaISODir, id)
source := readISOSource(id)
m := scanMountpoint("iso", mountpoint, source)
m.ID = id
out = append(out, m)
}
return out
}
// scanMountpoint сканирует точку монтирования на 3 уровня вглубь.
func scanMountpoint(kind, mountpoint, source string) Medium {
m := Medium{
ID: sha1Path(mountpoint),
Kind: kind,
Mountpoint: mountpoint,
Source: source,
}
containers, certs, profile := walkForArtifacts(mountpoint)
m.Containers = containers
m.Certificates = certs
m.Profile = profile
// Отмечаем контейнеры, уже импортированные в /var/lib/bj/containers/.
for i := range m.Containers {
if _, err := os.Stat(filepath.Join(containersDir, m.Containers[i].Name)); err == nil {
m.Containers[i].Imported = true
}
}
// Профиль помечается импортированным, если в /var/lib/bj/profiles/
// есть директория с тем же именем (имя берётся от носителя).
if m.Profile != nil {
name := filepath.Base(mountpoint)
if _, err := os.Stat(filepath.Join(profilesDir, name)); err == nil {
m.Profile.Imported = true
}
}
return m
}
// walkForArtifacts проходит дерево mountpoint (до 3 уровней) и собирает:
// - директории-контейнеры (>=2 *.key или >=1 *.vdk файла);
// - отдельные сертификаты (.cer/.pfx/...);
// - полный профиль Валидаты (наличие *.pse + *.gdbm + *.vdk в дереве).
func walkForArtifacts(root string) ([]KeyContainer, []CertFile, *ValidataProfile) {
var containers []KeyContainer
var certs []CertFile
prof := &ValidataProfile{Root: root}
_ = filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
rel, _ := filepath.Rel(root, p)
depth := strings.Count(rel, string(filepath.Separator))
if depth > 4 {
return filepath.SkipDir
}
if info.IsDir() {
if p != root {
if c, ok := classifyContainer(p); ok {
containers = append(containers, c)
// НЕ делаем SkipDir: внутри vdkeys/ нужно собрать
// .vdk-файлы для определения профиля Валидаты.
}
}
return nil
}
lower := strings.ToLower(info.Name())
switch {
case strings.HasSuffix(lower, ".pse"):
prof.PSEFiles = append(prof.PSEFiles, rel)
case strings.HasSuffix(lower, ".gdbm"):
prof.GDBMFiles = append(prof.GDBMFiles, rel)
case strings.HasSuffix(lower, ".vdk"):
prof.KeyFiles = append(prof.KeyFiles, rel)
default:
if cert := classifyCertFile(p); cert != nil {
certs = append(certs, *cert)
}
}
return nil
})
// Профилем считаем носитель если есть и pse, и vdk (gdbm
// опционален — но обычно тоже присутствует).
if len(prof.PSEFiles) == 0 || len(prof.KeyFiles) == 0 {
prof = nil
}
return containers, certs, prof
}
// classifyContainer — директория является ключевым контейнером, если:
// - в ней >=2 файлов *.key (старый формат КриптоПро/Валидата); или
// - в ней >=1 файл *.vdk (Валидата Linux).
func classifyContainer(dir string) (KeyContainer, bool) {
entries, err := os.ReadDir(dir)
if err != nil {
return KeyContainer{}, false
}
var keyFiles, vdkFiles, allFiles []string
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
allFiles = append(allFiles, name)
lower := strings.ToLower(name)
switch {
case strings.HasSuffix(lower, ".vdk"):
vdkFiles = append(vdkFiles, name)
case strings.HasSuffix(lower, ".key"):
keyFiles = append(keyFiles, name)
}
}
if len(vdkFiles) == 0 && len(keyFiles) < keyFileMinPerDir {
return KeyContainer{}, false
}
return KeyContainer{
Path: dir,
Name: filepath.Base(dir),
Files: allFiles,
}, true
}
// classifyCertFile парсит один файл — возвращает CertFile если это
// похоже на сертификат.
func classifyCertFile(path string) *CertFile {
lower := strings.ToLower(path)
var format string
switch {
case strings.HasSuffix(lower, ".cer"), strings.HasSuffix(lower, ".crt"):
format = "cer"
case strings.HasSuffix(lower, ".pem"):
format = "pem"
case strings.HasSuffix(lower, ".pfx"), strings.HasSuffix(lower, ".p12"):
format = "pfx"
default:
return nil
}
cf := &CertFile{
Path: path,
Name: filepath.Base(path),
Format: format,
}
if format == "pfx" {
// PKCS#12 шифрован PIN'ом — мета без него не вытащить.
cf.HasPrivateKey = true
return cf
}
data, err := os.ReadFile(path)
if err != nil {
cf.ParseError = "read: " + err.Error()
return cf
}
if len(data) > 32*1024 {
// Странно большой файл для сертификата — режем.
data = data[:32*1024]
}
der := data
if block, _ := pem.Decode(data); block != nil && block.Type == "CERTIFICATE" {
der = block.Bytes
}
cert, err := x509.ParseCertificate(der)
if err != nil {
cf.ParseError = "x509: " + err.Error()
return cf
}
cf.SubjectCN = cert.Subject.CommonName
cf.IssuerCN = cert.Issuer.CommonName
cf.Serial = cert.SerialNumber.Text(16)
cf.NotBefore = cert.NotBefore
cf.NotAfter = cert.NotAfter
cf.INN = extractCertINN(cert)
return cf
}
// extractCertINN — ИНН из OID 1.2.643.3.131.1.1 в Subject.
func extractCertINN(c *x509.Certificate) string {
innOID := asn1.ObjectIdentifier{1, 2, 643, 3, 131, 1, 1}
for _, name := range c.Subject.Names {
if name.Type.Equal(innOID) {
if s, ok := name.Value.(string); ok {
return s
}
}
}
return ""
}
// ExtractISO распаковывает образ диска (.iso/.img/.zip и т.п.) в
// /var/lib/bj/media/iso/<id>/ через 7z. password — опциональный пароль
// архива (пустая строка = без пароля). id — sha256-prefix от исходного
// пути. Возвращает Medium или ошибку.
func ExtractISO(ctx context.Context, isoPath, password string) (Medium, error) {
abs, err := filepath.Abs(isoPath)
if err != nil {
return Medium{}, fmt.Errorf("ISO путь: %w", err)
}
info, err := os.Stat(abs)
if err != nil {
return Medium{}, fmt.Errorf("ISO не найден: %w", err)
}
if info.IsDir() {
return Medium{}, errors.New("ISO путь — это директория, нужен файл")
}
id := isoID(abs)
dst := filepath.Join(mediaISODir, id)
if err := os.MkdirAll(dst, 0o755); err != nil {
return Medium{}, fmt.Errorf("создать %s: %w", dst, err)
}
if isEmpty, _ := dirEmpty(dst); !isEmpty {
// Уже распакован раньше — просто пересканируем.
writeISOSource(id, abs)
m := scanMountpoint("iso", dst, abs)
m.ID = id
return m, nil
}
cctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()
// 7z x -y -o<dst> [-p<pass>] <archive> — рекурсивное извлечение.
args := []string{"x", "-y", "-o" + dst}
if password != "" {
// 7z требует пароль через -p без пробела.
args = append(args, "-p"+password)
} else {
// -p- запрещает интерактивный запрос пароля (нам нечего вводить).
args = append(args, "-p-")
}
args = append(args, abs)
cmd := exec.CommandContext(cctx, "7z", args...)
out, err := cmd.CombinedOutput()
if err != nil {
_ = os.RemoveAll(dst)
return Medium{}, fmt.Errorf("7z x: %w / %s", err, strings.TrimSpace(string(out)))
}
writeISOSource(id, abs)
m := scanMountpoint("iso", dst, abs)
m.ID = id
return m, nil
}
// UnmountISO удаляет всё, что относится к загруженному образу:
// - распакованную директорию /var/lib/bj/media/iso/<id>/;
// - .src-meta файл с записанным источником;
// - сам исходный .img/.iso в /var/lib/bj/iso/, если он находится
// в наших границах (защита: путь должен начинаться с /var/lib/bj/iso/).
//
// Безопасно только для тех id, что лежат в нашем mediaISODir.
func UnmountISO(id string) error {
if strings.ContainsAny(id, "/.") {
return errors.New("неверный id")
}
dst := filepath.Join(mediaISODir, id)
if !strings.HasPrefix(dst, mediaISODir+"/") {
return errors.New("путь вне media-root")
}
// Сначала забираем путь исходника, потом удаляем .src.
src := readISOSource(id)
if err := os.RemoveAll(dst); err != nil {
return err
}
_ = os.Remove(filepath.Join(mediaISODir, id+".src"))
// Если запись об источнике существовала и путь — внутри /var/lib/bj/iso/,
// удаляем и сам файл .img/.iso.
if src != "" {
abs, _ := filepath.Abs(src)
if strings.HasPrefix(abs, "/var/lib/bj/iso/") {
_ = os.Remove(abs)
}
}
return nil
}
// ImportKeyContainer копирует контейнер в /var/lib/bj/containers/<name>/.
// Возвращает целевой путь.
func ImportKeyContainer(src string) (string, error) {
info, err := os.Stat(src)
if err != nil {
return "", fmt.Errorf("источник: %w", err)
}
if !info.IsDir() {
return "", errors.New("источник не директория")
}
if _, ok := classifyContainer(src); !ok {
return "", errors.New("в директории не найдено >=2 файлов *.key — не похоже на контейнер")
}
if err := os.MkdirAll(containersDir, 0o755); err != nil {
return "", fmt.Errorf("создать %s: %w", containersDir, err)
}
name := filepath.Base(src)
dst := filepath.Join(containersDir, name)
if _, err := os.Stat(dst); err == nil {
return "", fmt.Errorf("контейнер %q уже импортирован", name)
}
if err := os.MkdirAll(dst, 0o700); err != nil {
return "", fmt.Errorf("создать %s: %w", dst, err)
}
entries, err := os.ReadDir(src)
if err != nil {
return "", err
}
for _, e := range entries {
if e.IsDir() {
continue
}
sp := filepath.Join(src, e.Name())
dp := filepath.Join(dst, e.Name())
data, err := os.ReadFile(sp)
if err != nil {
return "", fmt.Errorf("чтение %s: %w", e.Name(), err)
}
if err := os.WriteFile(dp, data, 0o600); err != nil {
return "", fmt.Errorf("запись %s: %w", e.Name(), err)
}
}
return dst, nil
}
// ImportProfileResult — результат импорта профиля Валидаты.
type ImportProfileResult struct {
Path string // /var/lib/bj/profiles/<name>/
Pki1ConfSection string // готовая секция для pki1.conf
ConfWritten bool // удалось ли дописать в /opt/Validata/VDCSP/etc/pki1.conf
ConfWriteError string // если не удалось — причина
}
const validataPki1Conf = "/opt/Validata/VDCSP/etc/pki1.conf"
// ImportProfile копирует профиль Валидаты (pse/gdbm/vdkeys) в
// /var/lib/bj/profiles/<name>/, генерирует секцию для pki1.conf и
// пробует дописать её в системный конфиг Валидаты. Имя берётся от
// носителя, если name пуст. Возвращает деталь — что получилось.
func ImportProfile(root, name string) (ImportProfileResult, error) {
if name == "" {
name = filepath.Base(root)
}
if !validProfileName(name) {
return ImportProfileResult{}, errors.New("имя профиля: допустимы латинские буквы, цифры, '-' и '_'")
}
if err := os.MkdirAll(profilesDir, 0o755); err != nil {
return ImportProfileResult{}, fmt.Errorf("создать %s: %w", profilesDir, err)
}
dst := filepath.Join(profilesDir, name)
if _, err := os.Stat(dst); err == nil {
return ImportProfileResult{}, fmt.Errorf("профиль %q уже импортирован", name)
}
if err := copyTree(root, dst); err != nil {
_ = os.RemoveAll(dst)
return ImportProfileResult{}, err
}
// Ищем фактический pse и gdbm внутри импортированной папки —
// обычно spr*/local.pse + spr*/local.gdbm.
psePath, gdbmPath := findProfileFiles(dst)
if psePath == "" {
return ImportProfileResult{}, errors.New("после копирования не найден .pse — формат профиля нестандартный")
}
section := buildPki1ConfSection(name, psePath, gdbmPath)
// Сохраняем секцию рядом с профилем — чтобы оператор мог
// посмотреть/перечитать.
_ = os.WriteFile(filepath.Join(dst, "pki1.conf-section.txt"),
[]byte(section), 0o644)
res := ImportProfileResult{
Path: dst,
Pki1ConfSection: section,
}
// Пробуем дописать в pki1.conf — если файл доступен на запись.
if err := appendToPki1Conf(name, section); err != nil {
res.ConfWriteError = err.Error()
} else {
res.ConfWritten = true
}
return res, nil
}
func validProfileName(s string) bool {
if s == "" || len(s) > 64 {
return false
}
for _, r := range s {
ok := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') || r == '-' || r == '_'
if !ok {
return false
}
}
return true
}
// findProfileFiles ищет .pse и .gdbm внутри директории профиля.
// Возвращает абсолютные пути или пустые строки.
func findProfileFiles(dir string) (psePath, gdbmPath string) {
_ = filepath.Walk(dir, func(p string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
lower := strings.ToLower(info.Name())
if psePath == "" && strings.HasSuffix(lower, ".pse") {
psePath = p
}
if gdbmPath == "" && strings.HasSuffix(lower, ".gdbm") {
gdbmPath = p
}
return nil
})
return
}
// buildPki1ConfSection формирует блок pki1.conf для нашего профиля.
func buildPki1ConfSection(name, psePath, gdbmPath string) string {
var b strings.Builder
b.WriteString("\n# --- bj-server: профиль " + name + " ---\n")
b.WriteString("local: " + name + "\n")
b.WriteString("pse: pse://signed/" + psePath + "\n")
if gdbmPath != "" {
b.WriteString("localstore: file://" + gdbmPath + "\n")
}
return b.String()
}
// appendToPki1Conf пишет секцию в системный pki1.conf, если процесс
// имеет права. Возвращает ошибку при отсутствии прав или I/O-сбое.
// Дедуп — если в файле уже есть блок с тем же `local: <name>`, не
// пишем повторно.
func appendToPki1Conf(name, section string) error {
existing, err := os.ReadFile(validataPki1Conf)
if err != nil {
return fmt.Errorf("read %s: %w", validataPki1Conf, err)
}
marker := "local: " + name
if strings.Contains(string(existing), marker) {
return fmt.Errorf("в pki1.conf уже есть секция %q — пропускаем", name)
}
f, err := os.OpenFile(validataPki1Conf, os.O_WRONLY|os.O_APPEND, 0)
if err != nil {
return fmt.Errorf("open %s: %w", validataPki1Conf, err)
}
defer f.Close()
if _, err := f.WriteString(section); err != nil {
return fmt.Errorf("write %s: %w", validataPki1Conf, err)
}
return nil
}
// copyTree рекурсивно копирует src в dst, сохраняя структуру директорий.
// Права на новые директории — 0700, на файлы — 0600 (приватные ключи).
func copyTree(src, dst string) error {
return filepath.Walk(src, func(p string, info os.FileInfo, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(src, p)
if err != nil {
return err
}
target := filepath.Join(dst, rel)
if info.IsDir() {
return os.MkdirAll(target, 0o700)
}
data, err := os.ReadFile(p)
if err != nil {
return err
}
return os.WriteFile(target, data, 0o600)
})
}
// DeleteImportedContainer сносит /var/lib/bj/containers/<name>/.
func DeleteImportedContainer(name string) error {
if !validProfileName(name) {
return errors.New("неверное имя контейнера")
}
dir := filepath.Join(containersDir, name)
if _, err := os.Stat(dir); err != nil {
return fmt.Errorf("контейнер не найден: %w", err)
}
return os.RemoveAll(dir)
}
// DeleteImportedProfile сносит и директорию профиля
// /var/lib/bj/profiles/<name>/, и связанную секцию из pki1.conf
// (между маркерами «# --- bj-server: профиль <name> ---» и следующим
// «# --- bj-server: ...» или концом файла).
func DeleteImportedProfile(name string) error {
if !validProfileName(name) {
return errors.New("неверное имя профиля")
}
dir := filepath.Join(profilesDir, name)
if _, err := os.Stat(dir); err != nil {
return fmt.Errorf("профиль не найден: %w", err)
}
if err := os.RemoveAll(dir); err != nil {
return fmt.Errorf("удалить %s: %w", dir, err)
}
// Чистим секцию в pki1.conf — best effort, если файл недоступен на
// запись, профиль всё равно удалён, но в конфиге останется огрызок.
if err := removeFromPki1Conf(name); err != nil {
return fmt.Errorf("директория удалена, но pki1.conf не почистился: %w", err)
}
return nil
}
// removeFromPki1Conf удаляет блок профиля из pki1.conf.
// Блок начинается с «# --- bj-server: профиль <name> ---» и кончается
// перед следующим таким маркером или до конца файла. Если блок не
// найден — успех (idempotent).
func removeFromPki1Conf(name string) error {
data, err := os.ReadFile(validataPki1Conf)
if err != nil {
return err
}
startMarker := "# --- bj-server: профиль " + name + " ---"
startIdx := strings.Index(string(data), startMarker)
if startIdx < 0 {
return nil
}
// Найдём конец блока: следующий маркер «# --- bj-server: профиль» или EOF.
rest := string(data)[startIdx+len(startMarker):]
endRel := strings.Index(rest, "# --- bj-server: профиль ")
var newContent string
if endRel < 0 {
newContent = string(data)[:startIdx]
} else {
newContent = string(data)[:startIdx] + rest[endRel:]
}
// Убираем хвостовые пустые строки от секции.
newContent = strings.TrimRight(newContent, "\n") + "\n"
return os.WriteFile(validataPki1Conf, []byte(newContent), 0o644)
}
// ListImportedProfiles возвращает имена директорий в /var/lib/bj/profiles/.
func ListImportedProfiles() []string {
entries, err := os.ReadDir(profilesDir)
if err != nil {
return nil
}
var out []string
for _, e := range entries {
if e.IsDir() {
out = append(out, e.Name())
}
}
return out
}
// ListImportedContainers возвращает уже импортированные контейнеры.
func ListImportedContainers() []KeyContainer {
entries, err := os.ReadDir(containersDir)
if err != nil {
return nil
}
var out []KeyContainer
for _, e := range entries {
if !e.IsDir() {
continue
}
dir := filepath.Join(containersDir, e.Name())
if c, ok := classifyContainer(dir); ok {
c.Imported = true
out = append(out, c)
}
}
return out
}
func isoID(absPath string) string {
h := sha256.Sum256([]byte(absPath))
return hex.EncodeToString(h[:8])
}
func sha1Path(s string) string {
h := sha256.Sum256([]byte(s))
return hex.EncodeToString(h[:6])
}
func dirEmpty(path string) (bool, error) {
f, err := os.Open(path)
if err != nil {
return false, err
}
defer f.Close()
names, err := f.Readdirnames(1)
if errors.Is(err, os.ErrInvalid) || err != nil {
return len(names) == 0, nil
}
return len(names) == 0, nil
}
func readISOSource(id string) string {
data, err := os.ReadFile(filepath.Join(mediaISODir, id+".src"))
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}
func writeISOSource(id, src string) {
_ = os.WriteFile(filepath.Join(mediaISODir, id+".src"), []byte(src), 0o644)
}
+185
View File
@@ -0,0 +1,185 @@
package lkgateway
import (
"context"
"encoding/json"
"log"
"os"
"strings"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/igw"
)
// pollerStateFile — постоянная память id применённых входящих пакетов ИШ.
// Без неё перезапуск bj-server заново вычитывал бы старые ответы НРД и
// повторно применял их к заявкам (для ответов с нулевым GUID это приводило
// к ложным «отклонениям» по FIFO). Файл переживает перезапуски.
const pollerStateFile = "/var/lib/bj/.bj/poller-processed.json"
// loadProcessed читает множество обработанных id; второй результат false,
// если файла нет (самый первый запуск).
func loadProcessed() (map[int]bool, bool) {
m := make(map[int]bool)
b, err := os.ReadFile(pollerStateFile)
if err != nil {
return m, false
}
var ids []int
if json.Unmarshal(b, &ids) != nil {
return m, false
}
for _, id := range ids {
m[id] = true
}
return m, true
}
// saveProcessed атомарно сохраняет множество обработанных id.
func saveProcessed(m map[int]bool) {
ids := make([]int, 0, len(m))
for id := range m {
ids = append(ids, id)
}
b, err := json.Marshal(ids)
if err != nil {
return
}
tmp := pollerStateFile + ".tmp"
if os.WriteFile(tmp, b, 0o640) == nil {
_ = os.Rename(tmp, pollerStateFile)
}
}
// pollIncoming периодически опрашивает ИШ на входящие пакеты от НРД
// (M2MTransferDecision / Response) и применяет их через svc.ApplyDecision.
// Замыкает цикл: bj-server отправил заявку → ИШ → НРД → робот ответил →
// ИШ забрал ответ во входящие → этот поллер применяет Decision (статус
// заявки переходит в confirmed/rejected, срабатывает callback в ЛК).
//
// Дедупликация по id обработанных пакетов: ИШ возвращает их повторно,
// пока мы не подтвердим, поэтому держим множество уже обработанных.
func (s *Server) pollIncoming(ctx context.Context) {
const interval = 30 * time.Second
ticker := time.NewTicker(interval)
defer ticker.Stop()
processed, existed := loadProcessed()
if !existed {
// Первый запуск: помечаем все уже лежащие во входящих пакеты как
// обработанные, чтобы не применять исторический backlog (старые ответы
// НРД) к текущим заявкам. Реальные новые ответы придут позже.
s.seedProcessed(ctx, processed)
saveProcessed(processed)
}
log.Printf("lk-gateway: поллер входящих ИШ запущен (канал %s, интервал %s, обработанных в памяти %d)", s.igwChannel, interval, len(processed))
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.fetchAndApply(ctx, processed)
}
}
}
// fetchAndApply — один проход поллера: список входящих → для каждого нового
// забираем тело, распаковываем, парсим Decision, применяем.
func (s *Server) fetchAndApply(ctx context.Context, processed map[int]bool) {
cctx, cancel := context.WithTimeout(ctx, 25*time.Second)
defer cancel()
// Тип не указываем — ИШ вернёт оба (M2MTD + M2MER). Date=сегодня.
pkgs, err := s.igwClient.ListIncoming(cctx, igw.ListFilter{
Channel: s.igwChannel,
Date: time.Now(),
})
if err != nil {
log.Printf("lk-gateway: поллер ListIncoming: %v", err)
return
}
for _, p := range pkgs {
if processed[p.ID] {
continue
}
if err := s.applyIncoming(cctx, p); err != nil {
log.Printf("lk-gateway: поллер пакет id=%d (%s): %v", p.ID, p.Type, err)
continue // не помечаем обработанным — повторим в след. раз
}
processed[p.ID] = true
saveProcessed(processed) // переживает перезапуск
log.Printf("lk-gateway: поллер применил входящий пакет id=%d тип=%s", p.ID, p.Type)
}
}
// seedProcessed помечает все текущие входящие пакеты как обработанные
// (используется при самом первом запуске поллера).
func (s *Server) seedProcessed(ctx context.Context, processed map[int]bool) {
cctx, cancel := context.WithTimeout(ctx, 25*time.Second)
defer cancel()
pkgs, err := s.igwClient.ListIncoming(cctx, igw.ListFilter{Channel: s.igwChannel, Date: time.Now()})
if err != nil {
log.Printf("lk-gateway: поллер seed: ListIncoming: %v", err)
return
}
for _, p := range pkgs {
processed[p.ID] = true
}
log.Printf("lk-gateway: поллер первый запуск — засеяно %d существующих пакетов как обработанные", len(pkgs))
}
// applyIncoming забирает тело пакета и применяет M2M-ответ к сделке.
// Среди входящих от НРД много служебных пакетов (квитанции ЭДО типа C/Z,
// конверты) — они не M2M-документы и пропускаются. Реальные ответы —
// M2MTransferDecision (решение принимающей стороны) и M2MTransferResponse
// (ответ сервиса МОСТ, в т.ч. ошибки M2Mxx).
func (s *Server) applyIncoming(ctx context.Context, p igw.Package) error {
zipBytes, err := s.igwClient.GetPackage(ctx, p.ID)
if err != nil {
return err // сетевая ошибка — повторим в след. раз
}
unpacked, err := igw.UnpackPackage(zipBytes)
if err != nil {
// Нет основного .xml — служебный пакет (квитанция/конверт ЭДО).
// Не ошибка: помечаем обработанным, чтобы не повторять.
log.Printf("lk-gateway: поллер пакет id=%d (%s) — служебный (квитанция/конверт), пропуск", p.ID, p.Type)
return nil
}
doc := string(unpacked.DocXML)
switch {
case strings.Contains(doc, "M2MTransferDecision"):
decision, err := igw.ParseDecision(unpacked.DocXML)
if err != nil {
log.Printf("lk-gateway: поллер Decision id=%d: разбор: %v", p.ID, err)
return nil
}
return s.svc.ApplyDecision(ctx, decision)
case strings.Contains(doc, "M2MTransferResponse"):
resp, err := igw.ParseResponse(unpacked.DocXML)
if err != nil {
log.Printf("lk-gateway: поллер Response id=%d: разбор: %v", p.ID, err)
return nil
}
// Ответ сервиса МОСТ: статус + код (M2Mxx). Применяем к сделке:
// INFO — приём в обработку (статус не меняется), ERROR — отказ сервиса
// (напр. M2M14 — отправитель не в справочнике), сделка → Отклонена.
// Ответ сохраняется в сделке и виден в карточке заявки.
var codes string
for _, rr := range resp.Responses {
codes += string(rr.Code) + " "
}
log.Printf("lk-gateway: поллер M2MTransferResponse id=%d: статус=%s коды=[%s] GUID=%s",
p.ID, resp.StatusCode, strings.TrimSpace(codes), resp.GUID)
if err := s.svc.ApplyServiceResponse(ctx, resp, unpacked.DocXML); err != nil {
// Сделка может быть не найдена (ответ на чужой/старый GUID) —
// логируем, но помечаем обработанным, чтобы не зациклиться.
log.Printf("lk-gateway: поллер ApplyServiceResponse id=%d GUID=%s: %v", p.ID, resp.GUID, err)
}
return nil
default:
log.Printf("lk-gateway: поллер пакет id=%d (%s) — неизвестный M2M-документ, пропуск", p.ID, p.Type)
return nil
}
}
+54 -14
View File
@@ -26,10 +26,30 @@ type Settings struct {
LK LKSettings `json:"lk"`
CACerts CACertsSettings `json:"ca_certs"`
News NewsSettings `json:"news"`
Update UpdateSettings `json:"update"`
License LicenseSettings `json:"license"`
LastTest *TestRunResult `json:"last_test,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
// LicenseSettings — лицензионный ключ (подписанный токен).
type LicenseSettings struct {
Key string `json:"key"` // компактный токен payload.signature.keyid
PublicKey string `json:"public_key"` // base64 (если не зашит в бинарь)
}
// UpdateSettings — авто-обновления из артефактории (#18/#20).
type UpdateSettings struct {
BaseURL string `json:"base_url"` // https://updates.example.com
Channel string `json:"channel"` // "stable" | "beta"
PublicKey string `json:"public_key"` // base64 Ed25519 (если не зашит в бинарь)
AutoCheck bool `json:"auto_check"` // проверять автоматически
LastCheck time.Time `json:"last_check"` // когда последний раз проверяли
LastResult string `json:"last_result"` // текст результата проверки
Available string `json:"available_version"` // доступная версия (если новее)
Notes string `json:"notes,omitempty"` // заметки доступного релиза
}
// NewsSettings — лента новостей (события системы, окна техработ, обновления
// документации НРД). События добавляются вручную через UI или автоматически
// doc-watcher'ом и cron-задачами. Каждое событие может быть скрыто (Dismissed)
@@ -64,8 +84,7 @@ type DocSource struct {
// CACertsSettings — URL'ы для авто-загрузки сертификатов УЦ НРД и нашего
// УЦ. Список редактируется пользователем; раз в сутки фоновая горутина
// перекачивает каждый URL и переустанавливает сертификат, если он
// поменялся. Все сертификаты идут в mroot/uRoot хранилища КриптоПро.
// перекачивает каждый URL и сохраняет сертификат, если он поменялся.
type CACertsSettings struct {
URLs []string `json:"urls"`
AutoUpdate bool `json:"auto_update"`
@@ -91,12 +110,12 @@ type PostgresSettings struct {
DSN string `json:"dsn"`
}
// CryptoSettings — путь к JCP, провайдер, лицензионный ключ.
// CryptoSettings — путь к PKCS#11 модулю и тип провайдера.
type CryptoSettings struct {
Provider string `json:"provider"` // "stub" | "cryptopro" | "validata" | "vipnet"
Provider string `json:"provider"` // "stub" | "validata"
SocketPath string `json:"socket_path"` // UDS crypto-service
JCPPath string `json:"jcp_path"` // путь до jcp.jar
LicenseKey string `json:"license_key"` // лицензионный ключ КриптоПро
ModulePath string `json:"module_path"` // путь до .so модуля PKCS#11
Profile string `json:"profile"` // активный профиль Валидаты (имя из pki1.conf)
}
// NSDSettings — профиль и подключение к ИШ НРД.
@@ -104,6 +123,12 @@ type NSDSettings struct {
Profile string `json:"profile"` // "guest-gost", "test3-gost", ...
IGWBaseURL string `json:"igw_base_url"` // http://host:port
KeyContainer string `json:"key_container"` // имя контейнера (на стороне ИШ)
// Депозитарные реквизиты клиента — откуда списываются бумаги
// (SettlementLocation в M2MTransferRequest). Из договора/письма НРД.
DeponentCode string `json:"deponent_code"` // депкод, напр. MC0413600000
AccountID string `json:"account_id"` // депозитарный счёт
SectionID string `json:"section_id"` // раздел депозитарного счёта
}
// LKSettings — настройки callback в ЛК клиента.
@@ -165,6 +190,24 @@ func (r *RuntimeConfig) UpdatePostgres(s PostgresSettings) error {
return r.save()
}
// SaveLicense сохраняет лицензионные настройки.
func (r *RuntimeConfig) SaveLicense(s LicenseSettings) error {
r.mu.Lock()
r.data.License = s
r.data.UpdatedAt = time.Now().UTC()
r.mu.Unlock()
return r.save()
}
// SaveUpdateSettings сохраняет настройки авто-обновлений.
func (r *RuntimeConfig) SaveUpdateSettings(s UpdateSettings) error {
r.mu.Lock()
r.data.Update = s
r.data.UpdatedAt = time.Now().UTC()
r.mu.Unlock()
return r.save()
}
// UpdateCrypto сохраняет crypto-настройки.
func (r *RuntimeConfig) UpdateCrypto(s CryptoSettings) error {
r.mu.Lock()
@@ -307,7 +350,7 @@ func (r *RuntimeConfig) ReadinessSummary() []Readiness {
},
{
Name: "crypto-service",
Configured: s.Crypto.Provider != "" && s.Crypto.Provider != "stub" && s.Crypto.JCPPath != "",
Configured: s.Crypto.Provider != "" && s.Crypto.Provider != "stub",
Ready: false,
Message: cryptoMsg(s.Crypto),
},
@@ -336,15 +379,12 @@ func posMsg(dsn string) string {
func cryptoMsg(c CryptoSettings) string {
if c.Provider == "" || c.Provider == "stub" {
return "Криптография не настроена (provider=stub). КриптоПро JCP не подключён."
return "Криптография не настроена (provider=stub) — реальная подпись недоступна."
}
if c.JCPPath == "" {
return "Провайдер " + c.Provider + ", но путь к JCP не задан."
if c.ModulePath == "" {
return "Провайдер " + c.Provider + ", путь к PKCS#11 модулю не задан."
}
if c.LicenseKey == "" {
return "Провайдер " + c.Provider + ", JCP есть, лицензия не введена."
}
return "Провайдер " + c.Provider + ", JCP подключён, лицензия введена."
return "Провайдер " + c.Provider + ", PKCS#11 модуль: " + c.ModulePath
}
func nsdMsg(n NSDSettings) string {
+5 -1
View File
@@ -43,8 +43,12 @@ func NewSeedStore() *SeedStore {
"DP789456", "31MC0021900000F01", "P001", "7702070139")
s.addAccount("11111111-1111-1111-1111-111111111111",
"AA789451", "33MC0021900000F02", "F002", "7802031669")
// Тест-инвестор робота: место расчётов = наш реальный тестовый счёт в НРД
// (депкод MC0413600000, счёт HL171004001C, раздел 36MC0413600000F00,
// расчётный депозитарий НРД ИНН 7702165310). Иначе НРД отвечает M2M19
// «недопустимое место расчетов».
s.addAccount("22222222-2222-2222-2222-222222222222",
"DP100200", "31MC0010000000A01", "A001", "7702070139")
"MC0413600000", "HL171004001C", "36MC0413600000F00", "7702165310")
s.addAccount("33333333-3333-3333-3333-333333333333",
"DP300400", "31MC0030000000B01", "B001", "0702345678")
s.addAccount("55555555-5555-5555-5555-555555555555",
+75 -7
View File
@@ -9,9 +9,23 @@ import (
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/igw"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/mock"
)
// igwClientAdapter адаптирует igw.Client под узкий nsdadapter.IGWClient:
// разворачивает (channel, since, type) в igw.ListFilter.
type igwClientAdapter struct{ c *igw.Client }
func (a igwClientAdapter) SendPackage(ctx context.Context, channel, packageType string, body []byte) (string, error) {
return a.c.SendPackage(ctx, channel, packageType, body)
}
func (a igwClientAdapter) ListIncoming(ctx context.Context, channel string, since time.Time, packageType string) ([]igw.Package, error) {
return a.c.ListIncoming(ctx, igw.ListFilter{Channel: channel, Date: since, Type: packageType})
}
// ServerConfig — конфигурация HTTP-сервера lk-gateway.
type ServerConfig struct {
Addr string
@@ -31,6 +45,12 @@ type Server struct {
rc *RuntimeConfig
mux *http.ServeMux
server *http.Server
// igwClient/igwChannel заполнены только в реальном режиме (ИШ настроен).
// На них работает поллер входящих pollIncoming — забирает ответы НРД
// (M2MTransferDecision/Response) и применяет через svc.ApplyDecision.
igwClient *igw.Client
igwChannel string
}
// NewServer собирает Server с репозиторием, mock NSDSender, SeedStore
@@ -45,13 +65,44 @@ func NewServer(cfg ServerConfig) (*Server, error) {
if cfg.MockDecisionDelay > 0 {
mockCfg.DecisionDelay = cfg.MockDecisionDelay
}
sender := mock.NewSender(mockCfg)
rc, err := NewRuntimeConfig(cfg.SetupPath)
if err != nil {
return nil, err
}
// Выбор NSD-сендера: если в runtime-конфиге задан профиль ИШ и URL —
// используем реальный nsdadapter поверх REST ИШ; иначе mock-эмуляция.
// mockSender остаётся не-nil только в mock-режиме — на нём висит
// consumeDecisions (реальные Decision приходят поллером входящих ИШ).
var sender m2mcore.NSDSender
var mockSender *mock.Sender
var igwClient *igw.Client
var igwChannel string
{
s := rc.Snapshot()
if s.NSD.IGWBaseURL != "" && s.NSD.Profile != "" {
prof, perr := nsdadapter.LookupProfile(s.NSD.Profile)
if perr != nil {
log.Printf("lk-gateway: профиль ИШ %q неизвестен (%v) — fallback mock", s.NSD.Profile, perr)
} else {
prof.IGWBaseURL = s.NSD.IGWBaseURL // override URL из setup.json
cl := igw.NewClient(s.NSD.IGWBaseURL)
sender = nsdadapter.NewSender(prof, igwClientAdapter{c: cl})
igwClient = cl
// Канал ИШ резолвится по составному коду <канал>+<депонент>.
igwChannel = prof.Channel + string(cfg.DefaultSender)
log.Printf("lk-gateway: реальный ИШ-адаптер — профиль %s, канал %s, ИШ %s",
prof.Name, igwChannel, s.NSD.IGWBaseURL)
}
}
if sender == nil {
mockSender = mock.NewSender(mockCfg)
sender = mockSender
log.Printf("lk-gateway: NSD mock-режим (Decision через эмуляцию)")
}
}
// Repository: pgx если DSN указан, иначе in-memory.
var repo m2mcore.Repository = m2mcore.NewMemoryRepository()
if dsn := rc.Snapshot().Postgres.DSN; dsn != "" {
@@ -115,12 +166,14 @@ func NewServer(cfg ServerConfig) (*Server, error) {
registerSeedListing(mux, store)
return &Server{
cfg: cfg,
svc: svc,
mock: sender,
store: store,
rc: rc,
mux: mux,
cfg: cfg,
svc: svc,
mock: mockSender,
store: store,
rc: rc,
mux: mux,
igwClient: igwClient,
igwChannel: igwChannel,
server: &http.Server{
Addr: cfg.Addr,
Handler: mux,
@@ -159,6 +212,15 @@ func (s *Server) Mux() http.Handler { return s.mux }
func (s *Server) Run(ctx context.Context) error {
go s.consumeDecisions(ctx)
// Поллер входящих от НРД (только в реальном режиме ИШ): забирает
// ответы робота/контрагента и применяет их через ApplyDecision.
if s.igwClient != nil && s.igwChannel != "" {
go s.pollIncoming(ctx)
}
// Фоновая авто-проверка обновлений из артефактории (если включена).
go NewUpdater(s.rc).updateLoop(ctx)
// Авто-обновление сертификатов УЦ раз в сутки (если оператор включил).
stopCACerts := StartCACertsAutoUpdater(s.rc)
defer stopCACerts()
@@ -193,6 +255,12 @@ func (s *Server) Run(ctx context.Context) error {
// consumeDecisions слушает Decisions от mock и обновляет соответствующие сделки.
func (s *Server) consumeDecisions(ctx context.Context) {
if s.mock == nil {
// Реальный ИШ-режим: Decision приходят не из mock-канала, а через
// поллер входящих пакетов ИШ (отдельный механизм). Здесь нечего слушать.
<-ctx.Done()
return
}
for {
select {
case <-ctx.Done():
+11 -6
View File
@@ -104,11 +104,16 @@ func TestAdminHome(t *testing.T) {
t.Fatalf("admin home code=%d", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "lk-gateway") {
t.Errorf("в дашборде нет заголовка lk-gateway")
// Редизайн #26: оператор-дашборд в стиле Apple — бренд Bridge&Join,
// приветствие-hero и крупные плитки задач.
if !strings.Contains(body, "Bridge") {
t.Errorf("в дашборде нет бренда Bridge&Join")
}
if !strings.Contains(body, "Состояние системы") {
t.Errorf("в дашборде нет блока статуса")
if !strings.Contains(body, "Добрый день") {
t.Errorf("в дашборде нет hero-приветствия")
}
if !strings.Contains(body, "Диагностика") {
t.Errorf("в дашборде нет плитки задач")
}
}
@@ -120,8 +125,8 @@ func TestAdminStatus(t *testing.T) {
if w.Code != http.StatusOK {
t.Fatalf("status code=%d", w.Code)
}
if !strings.Contains(w.Body.String(), "postgres") {
t.Errorf("в статусе нет проверки postgres")
if !strings.Contains(w.Body.String(), "PostgreSQL") {
t.Errorf("в статусе нет проверки PostgreSQL")
}
}
+92 -1
View File
@@ -12,6 +12,7 @@ import (
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdxml"
)
// Service — бизнес-логика lk-gateway: преобразует DTO в доменные сущности
@@ -67,9 +68,13 @@ func (s *Service) CreateClaim(ctx context.Context, in CreateClaimRequest) (Creat
return CreateClaimResponse{}, fmt.Errorf("lkgateway: dtoToClaim: %w", err)
}
receiver := s.defaultReceiver
if in.ReceiverCodeOverride != "" {
receiver = m2m.DeponentCode(in.ReceiverCodeOverride)
}
req, err := m2mcore.EnrichRequest(ctx, s.store, domainClaim, m2mcore.SenderReceiver{
SenderCode: s.defaultSender,
ReceiverCode: s.defaultReceiver,
ReceiverCode: receiver,
})
if err != nil {
return CreateClaimResponse{}, fmt.Errorf("lkgateway: EnrichRequest: %w", err)
@@ -163,6 +168,80 @@ func (s *Service) ApplyDecision(ctx context.Context, decision *m2m.M2MTransferDe
return nil
}
// ApplyServiceResponse применяет M2MTransferResponse (ответ сервиса МОСТ) к
// сделке: сохраняет ответ, при ERROR переводит сделку в Rejected и шлёт
// callback в ЛК. Сделку ищем по GUID ответа.
func (s *Service) ApplyServiceResponse(ctx context.Context, resp *m2m.M2MTransferResponse, raw []byte) error {
if resp == nil {
return errors.New("lkgateway: ApplyServiceResponse: resp=nil")
}
deal, err := s.findDealForResponse(ctx, resp)
if err != nil {
return fmt.Errorf("lkgateway: поиск сделки для ответа: %w", err)
}
prev := deal.State
if err := deal.ReceiveServiceResponse(ctx, resp, raw); err != nil {
return fmt.Errorf("lkgateway: ReceiveServiceResponse: %w", err)
}
if err := s.repo.Update(ctx, deal); err != nil {
return fmt.Errorf("lkgateway: repo.Update: %w", err)
}
// Состояние сменилось (ERROR → Rejected) — учитываем в метриках и шлём
// callback. На INFO состояние не меняется — callback не нужен.
if deal.State != prev {
s.recorder.IncDeal(deal.State)
if s.callbackURL != "" {
s.sendCallback(ctx, deal)
}
}
return nil
}
// zeroGUID — нулевой UUID, который НРД присылает в сервисных ошибках
// (напр. M2M14), когда не идентифицирует исходный запрос.
const zeroGUID = "00000000-0000-0000-0000-000000000000"
// findDealForResponse сопоставляет ответ МОСТ со сделкой. Сначала по GUID;
// если GUID нулевой/пустой или сделка по нему не найдена (реальное поведение
// НРД при M2M14 — ответ без нашего GUID и без ReferenceID), применяем
// эвристику: берём самую раннюю ожидающую решение заявку без ответа. Для
// тестового сценария «одна заявка в полёте» это однозначно; при множестве
// заявок in-flight такие сервисные ошибки в принципе неразличимы на стороне
// НРД, и FIFO — наилучшее доступное приближение.
func (s *Service) findDealForResponse(ctx context.Context, resp *m2m.M2MTransferResponse) (*m2mcore.Deal, error) {
guid := string(resp.GUID)
if guid != "" && guid != zeroGUID {
deal, err := s.repo.GetByGUID(ctx, resp.GUID)
if err == nil {
return deal, nil
}
if !errors.Is(err, m2mcore.ErrNotFound) {
return nil, err
}
}
// Fallback: ответ без идентифицируемого GUID.
st := m2mcore.StateAwaitingDecision
deals, err := s.repo.List(ctx, m2mcore.Filter{State: &st, Limit: 200})
if err != nil {
return nil, err
}
var cand *m2mcore.Deal
for _, d := range deals {
if d.Response != nil {
continue // этой заявке ответ уже сопоставлен
}
if cand == nil || d.CreatedAt.Before(cand.CreatedAt) {
cand = d
}
}
if cand == nil {
return nil, m2mcore.ErrNotFound
}
log.Printf("lkgateway: ответ МОСТ с GUID=%s сопоставлен по эвристике (FIFO) заявке id=%s status=%s",
guid, cand.ID, resp.StatusCode)
return cand, nil
}
// sendCallback отправляет PATCH в ЛК с обновлением статуса.
func (s *Service) sendCallback(ctx context.Context, deal *m2mcore.Deal) {
cb := callbackForDeal(deal)
@@ -185,6 +264,14 @@ func dtoToClaim(in CreateClaimRequest) (m2mcore.ClaimInput, error) {
TransferringDepositoryINN: m2m.OrganizationINN(in.TransferringDepositoryINN),
ReceivingDepositoryINN: m2m.OrganizationINN(in.ReceivingDepositoryINN),
}
// Переопределение документа инвестора (тест с роботом: серия = сценарий).
if d := in.InvestorDocumentOverride; d != nil {
out.InvestorDocument = &m2mcore.ClientDocument{
DocumentType: m2m.IdentityDocumentCode(d.DocumentType),
Series: d.Series,
Number: d.Number,
}
}
// CostInfo
if in.CostInfo.Yes != nil {
out.CostInfo = m2m.CostInfo{Yes: &m2m.CostInfoYes{Code: m2m.DeponentCode(in.CostInfo.Yes.Code)}}
@@ -304,6 +391,10 @@ func dealToView(d *m2mcore.Deal) ClaimView {
}
if d.Response != nil {
out.M2MResponse = responseToView(d.Response)
if len(d.RawResponse) > 0 {
// Ответ НРД в windows-1251 — декодируем в UTF-8 для показа.
out.M2MResponse.RawXML = string(nsdxml.DecodeWindows1251(d.RawResponse))
}
}
if d.Decision != nil {
out.M2MDecision = decisionToView(d.Decision)
File diff suppressed because it is too large Load Diff
+10
View File
@@ -18,6 +18,13 @@ type CreateClaimRequest struct {
Securities []ClaimSec `json:"securities"`
SignedDocument string `json:"signed_document"`
SignatureFormat string `json:"signature_format"`
// ReceiverCodeOverride — если задан, переопределяет код получателя
// (Header.ReceiverCode). Используется для тестовых пакетов роботу НРД
// (MC0012500000). Пусто = берётся defaultReceiver.
ReceiverCodeOverride string `json:"receiver_code_override,omitempty"`
// InvestorDocumentOverride — если задан, переопределяет документ инвестора
// из анкеты. Используется тестом с роботом НРД (серия ДУЛ = код сценария).
InvestorDocumentOverride *Document `json:"investor_document_override,omitempty"`
}
// Investor — анкета.
@@ -159,6 +166,9 @@ type NSDResponseView struct {
GUID string `json:"guid"`
StatusCode string `json:"status_code"`
Responses []NSDResponseEntry `json:"responses"`
// RawXML — точные байты ответа МОСТ от НРД (декодированные в UTF-8 для
// показа). Для дословной пересылки в техподдержку НРД.
RawXML string `json:"raw_xml,omitempty"`
}
// NSDResponseEntry — одна запись Response.
+204
View File
@@ -0,0 +1,204 @@
package lkgateway
import (
"context"
"fmt"
"log"
"os"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/release"
)
// BuildVersion — версия bj-server. Переопределяется при сборке:
//
// go build -ldflags "-X .../lkgateway.BuildVersion=1.2.0"
var BuildVersion = "0.1.0"
// DefaultUpdatePublicKey — публичный ключ артефактории, зашитый в релиз.
// Пустой в исходниках; подставляется при официальной сборке. Если задан в
// настройках (UpdateSettings.PublicKey) — приоритет у настроек.
var DefaultUpdatePublicKey = ""
// installPaths — куда устанавливать артефакты по логическому имени.
// Файлы не из этого списка при авто-обновлении пропускаются (скрипты/SQL
// обновляются отдельно, не на лету).
var installPaths = map[string]string{
"bj-server": "/opt/bj/bj-server",
"crypto-service.jar": "/opt/bj/crypto-service.jar",
}
// Updater — авто-обновление bj-server из артефактории (работает поверх rc).
type Updater struct{ rc *RuntimeConfig }
// NewUpdater создаёт Updater на текущем runtime-конфиге.
func NewUpdater(rc *RuntimeConfig) *Updater { return &Updater{rc: rc} }
// UpdateStatus — сводка для UI/handler.
type UpdateStatus struct {
Configured bool
CurrentVersion string
Available string
HasUpdate bool
Channel string
Notes string
Message string
}
func (u *Updater) updateClient() (*release.Client, error) {
cfg := u.rc.Snapshot().Update
pub := cfg.PublicKey
if pub == "" {
pub = DefaultUpdatePublicKey
}
if cfg.BaseURL == "" || pub == "" {
return nil, fmt.Errorf("обновления не настроены (нужны URL артефактории и публичный ключ)")
}
channel := cfg.Channel
if channel == "" {
channel = "stable"
}
return release.NewClient(cfg.BaseURL, channel, pub)
}
// CheckForUpdate скачивает манифест, проверяет подпись, сравнивает версии и
// сохраняет результат в настройки. Возвращает сводку.
func (u *Updater) CheckForUpdate(ctx context.Context) (UpdateStatus, error) {
st := UpdateStatus{CurrentVersion: BuildVersion, Channel: u.rc.Snapshot().Update.Channel}
cl, err := u.updateClient()
if err != nil {
st.Message = err.Error()
return st, nil // не настроено — не ошибка
}
st.Configured = true
m, err := cl.FetchManifest(ctx)
if err != nil {
st.Message = "проверка не удалась: " + err.Error()
u.saveCheckResult(st)
return st, err
}
st.Available = m.Version
st.Notes = m.Notes
st.HasUpdate = release.IsNewer(m.Version, BuildVersion)
if st.HasUpdate {
st.Message = fmt.Sprintf("доступна версия %s (текущая %s)", m.Version, BuildVersion)
} else {
st.Message = "установлена актуальная версия " + BuildVersion
}
u.saveCheckResult(st)
return st, nil
}
func (u *Updater) saveCheckResult(st UpdateStatus) {
cfg := u.rc.Snapshot().Update
cfg.LastCheck = time.Now().UTC()
cfg.LastResult = st.Message
cfg.Available = st.Available
cfg.Notes = st.Notes
if err := u.rc.SaveUpdateSettings(cfg); err != nil {
log.Printf("lk-gateway: сохранение результата проверки обновления: %v", err)
}
}
// ApplyUpdate скачивает обновлённые артефакты (с проверкой подписи манифеста
// и sha256 каждого файла), атомарно заменяет бинари и завершает процесс с
// ненулевым кодом — systemd (Restart=on-failure) поднимает новую версию.
func (u *Updater) ApplyUpdate(ctx context.Context) error {
// Гейт лицензией: если лицензирование включено — требуется валидная
// лицензия с фичей updates. Без лицензирования (открытый режим) — пропускаем.
if licensingEnabled(u.rc) {
ls := licenseStatus(u.rc)
if !ls.Valid {
return fmt.Errorf("обновления заблокированы — лицензия: %s", ls.Message)
}
if !ls.AllowsUpdates {
return fmt.Errorf("обновления не входят в план %q", ls.Plan)
}
}
cl, err := u.updateClient()
if err != nil {
return err
}
m, err := cl.FetchManifest(ctx)
if err != nil {
return fmt.Errorf("манифест: %w", err)
}
if !release.IsNewer(m.Version, BuildVersion) {
return fmt.Errorf("обновление не требуется (текущая %s, доступна %s)", BuildVersion, m.Version)
}
updated := 0
for _, a := range m.Artifacts {
dst, ok := installPaths[a.Name]
if !ok {
continue // скрипты/SQL не обновляем на лету
}
dir := dirOf(dst)
path, err := cl.DownloadArtifact(ctx, a, dir)
if err != nil {
return fmt.Errorf("скачивание %s: %w", a.Name, err)
}
// DownloadArtifact кладёт файл под именем a.File; если целевое имя
// иное — переименуем атомарно.
if path != dst {
if err := os.Rename(path, dst); err != nil {
return fmt.Errorf("установка %s: %w", a.Name, err)
}
}
log.Printf("lk-gateway: обновлён %s → %s (%s)", a.Name, dst, m.Version)
updated++
}
if updated == 0 {
return fmt.Errorf("в манифесте %s нет обновляемых бинарей", m.Version)
}
log.Printf("lk-gateway: обновление до %s применено (%d файлов), перезапуск через systemd…", m.Version, updated)
// Завершаемся с ненулевым кодом — systemd Restart=on-failure поднимет
// новый бинарь. Даём пару секунд на флаш логов/ответа.
go func() {
time.Sleep(800 * time.Millisecond)
os.Exit(42)
}()
return nil
}
// updateLoop — фоновая авто-проверка обновлений (если включена).
func (u *Updater) updateLoop(ctx context.Context) {
ticker := time.NewTicker(6 * time.Hour)
defer ticker.Stop()
check := func() {
if !u.rc.Snapshot().Update.AutoCheck {
return
}
cctx, cancel := context.WithTimeout(ctx, 90*time.Second)
defer cancel()
if st, err := u.CheckForUpdate(cctx); err == nil && st.HasUpdate {
log.Printf("lk-gateway: доступно обновление %s (текущая %s)", st.Available, st.CurrentVersion)
}
}
// первая проверка через минуту после старта
select {
case <-ctx.Done():
return
case <-time.After(time.Minute):
check()
}
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
check()
}
}
}
func dirOf(path string) string {
for i := len(path) - 1; i >= 0; i-- {
if path[i] == '/' {
return path[:i]
}
}
return "."
}
@@ -51,16 +51,51 @@
{{if .Claim.M2MResponse}}
<div class="card">
<h2>Ответ НРД (M2MTransferResponse)</h2>
<p class="muted">GUID <code>{{.Claim.M2MResponse.GUID}}</code> · Status <code>{{.Claim.M2MResponse.StatusCode}}</code></p>
<h2>Ответ сервиса МОСТ (M2MTransferResponse)</h2>
<p>
{{if eq .Claim.M2MResponse.StatusCode "ERROR"}}
<span class="badge err">● ERROR — заявка отклонена сервисом НРД</span>
{{else}}
<span class="badge ok">● {{.Claim.M2MResponse.StatusCode}} — принято в обработку</span>
{{end}}
</p>
<p class="muted">GUID <code>{{.Claim.M2MResponse.GUID}}</code></p>
<table>
<thead><tr><th>ReferenceID</th><th>Код</th><th>Текст</th></tr></thead>
<thead><tr><th>ReferenceID</th><th>Код</th><th>Текст ответа НРД</th></tr></thead>
<tbody>
{{range .Claim.M2MResponse.Responses}}
<tr><td><code>{{.ReferenceID}}</code></td><td>{{.Code}}</td><td>{{.Text}}</td></tr>
<tr><td><code>{{.ReferenceID}}</code></td><td><code>{{.Code}}</code></td><td>{{.Text}}</td></tr>
{{end}}
</tbody>
</table>
{{if eq .Claim.M2MResponse.StatusCode "ERROR"}}
<p class="muted" style="margin-top:10px">
Это отказ на сервисном уровне — запрос не дошёл до контрагента. Решение
(M2MTransferDecision) по такой заявке не придёт. Устраните причину по коду
выше и отправьте новую заявку.
</p>
{{end}}
{{if .Claim.M2MResponse.RawXML}}
<details style="margin-top:12px">
<summary style="cursor:pointer;font-weight:600">
Сырой ответ НРД (для техподдержки M2MOST@nsd.ru)
</summary>
<p class="muted" style="margin:8px 0">
Точные байты ответа сервиса МОСТ. Можно дословно переслать в поддержку НРД.
</p>
<button type="button" class="btn" onclick="copyRaw(this)">Скопировать</button>
<pre id="raw-response" style="white-space:pre-wrap;word-break:break-all;background:var(--surface-2,#f5f5f7);padding:12px;border-radius:8px;font-size:12px;overflow:auto;max-height:340px">{{.Claim.M2MResponse.RawXML}}</pre>
</details>
<script>
function copyRaw(btn){
var t=document.getElementById('raw-response').innerText;
navigator.clipboard.writeText(t).then(function(){
var o=btn.textContent; btn.textContent='Скопировано ✓';
setTimeout(function(){btn.textContent=o;},1500);
});
}
</script>
{{end}}
</div>
{{end}}
@@ -17,10 +17,10 @@
<p class="muted">REST-контракт ESIA Finance V1: <code>POST /api/v1/back_office/claims/</code>, GET/PATCH-операции, формат callback'ов, аутентификация Basic, примеры запросов curl.</p>
</div>
</a>
<a href="/admin/help/cryptopro" style="text-decoration:none">
<a href="/admin/help/crypto" style="text-decoration:none">
<div class="card" style="height:100%">
<h2 style="color:var(--accent)">КриптоПро и Рутокен</h2>
<p class="muted">Установка КриптоПро CSP на РЕД ОС / Ubuntu, ввод серийного номера, PKCS#11 модуль, серверная подпись и подпись оператора через Рутокен ЭЦП 2.0, тестирование.</p>
<h2 style="color:var(--accent)">Криптография (Валидата)</h2>
<p class="muted">Установка АПК «Валидата Клиент L» на Astra Linux SE, подключение через PKCS#11, тестирование подписи и проверки квитанций НРД.</p>
</div>
</a>
<a href="/admin/help/systems" style="text-decoration:none">
@@ -35,5 +35,11 @@
<p class="muted">Робот НРД на TEST3 (код <code>MC0012500000</code>), 4 тестовых сценария (отказ / принять все / частично / встречный перевод), управление через DocumentSeries и DocumentNumber, тестовые наборы депозитариев и кодов ошибок.</p>
</div>
</a>
<a href="/admin/help/architecture" style="text-decoration:none">
<div class="card" style="height:100%">
<h2 style="color:var(--accent)">Архитектура обмена с НРД →</h2>
<p class="muted">Полная схема: bj-server → ИШ (на Astra Linux ВМ) → ONYX (НРД) → робот-автотест. Кто на чьей стороне, какое СКЗИ, какие сертификаты, FAQ.</p>
</div>
</a>
</div>
{{end}}
@@ -0,0 +1,136 @@
{{define "content"}}
<p style="margin-bottom:16px"><a href="/admin/help">← все инструкции</a></p>
<div class="card">
<h2>Архитектура: как устроен обмен с НРД</h2>
<p class="muted">Документ-источник: <code>DOC/ruk_install_ish_2025_11_10.pdf</code> (Руководство по установке ИШ от 10.11.2025), <code>DOC/instr-ish-rest-api.pdf</code> (REST API ИШ).</p>
</div>
<div class="card">
<h2>Схема обмена (полная)</h2>
<pre style="font-size:12px;line-height:1.4;background:var(--bg);padding:16px;border-radius:6px;overflow:auto">
┌─────────────────────────────────────────────────────────────────┐
│ НАША СТОРОНА │
│ │
│ Linux ВМ (РЕД ОС 8) Astra Linux ВМ │
│ ────────────────── ────────────────── │
│ ┌──────────────────┐ REST API ┌──────────────────┐ │
│ │ bj-server │ ────POST/GET──> │ ИШ (igate) │ │
│ │ (наше ПО) │ <───────────── │ (получаем у НРД)│ │
│ │ │ │ │ │
│ │ • стейт-машина │ │ Делает САМ: │ │
│ │ • PostgreSQL │ │ • подпись │ │
│ │ • админка :8080 │ │ • упаковку ЭДО │ │
│ │ • lk-emulator │ │ • проверку │ │
│ │ │ │ подписей НРД │ │
│ └──────────────────┘ │ • БД PostgreSQL │ │
│ │ (history) │ │
│ └──────┬───────────┘ │
│ │ │
│ АПК «Валидата Клиент L» │ АПК «Валидата │
│ (PKCS#11) — общий для всех │ Клиент L» │
│ компонентов │ │
└──────────────────────────────────────────────┼─────────────────┘
SOAP/REST/HTTPS │ Web-сервис ONYX
┌────────────────────────────┐
│ СТОРОНА НРД │
│ │
│ GUEST: gost-gt.nsd.ru │
│ TEST3: gost.nsd.ru │
│ PROM: edog.nsd.ru │
│ │
│ /onyxgs/WslService │
│ /onyxt3/WslService │
│ /onyxpr/WslService │
│ │
│ ↓ внутрь НРД │
│ • робот-автотест │
│ MC0012500000 │
│ • реальные депозитарии │
│ • биржевые системы │
└────────────────────────────┘
</pre>
</div>
<div class="card">
<h2>Компоненты — кто на чьей стороне</h2>
<table>
<thead><tr><th>Компонент</th><th>Сторона</th><th>ОС</th><th>СКЗИ</th><th>Назначение</th></tr></thead>
<tbody>
<tr>
<td><strong>bj-server</strong></td>
<td>наша</td>
<td>Astra Linux SE 1.7 / Linux</td>
<td>АПК «Валидата Клиент L» (PKCS#11)</td>
<td>Стейт-машина, журнал в БД, веб-админка, lk-emulator</td>
</tr>
<tr>
<td><strong>ИШ (igate)</strong></td>
<td>наша <span class="muted">(но дистрибутив даёт НРД)</span></td>
<td>Astra Linux SE 1.6/1.7</td>
<td>АПК «Валидата Клиент L»</td>
<td>Подписывает наш XML сертификатом УЦ МБ, упаковывает в пакет ЭДО, отправляет в НРД</td>
</tr>
<tr>
<td><strong>ONYX (WSL)</strong></td>
<td>НРД</td>
<td></td>
<td></td>
<td>Web-сервис НРД — принимает пакеты от ИШ всех клиентов</td>
</tr>
<tr>
<td><strong>Робот-автотест</strong></td>
<td>НРД</td>
<td></td>
<td></td>
<td>Контрагент-эмулятор внутри НРД. Адресуется кодом <code>MC0012500000</code> в TEST3</td>
</tr>
</tbody>
</table>
</div>
<div class="card">
<h2>Часто задаваемые вопросы</h2>
<h3>Q: ИШ — это сервер НРД, к которому мы подключаемся?</h3>
<p>Нет. <strong>ИШ — это наша программа, поставленная у нас.</strong> НРД даёт дистрибутив (<code>igate_100.0-765_amd64.deb</code>, 117 МБ), но ставим у себя. ИШ — это «персональный почтовый клиент к НРД» с подписью.</p>
<h3>Q: ИШ можно поставить на ту же ВМ, что и bj-server?</h3>
<p>Да, если ВМ — Astra Linux. И bj-server, и ИШ работают на Astra Linux SE 1.6/1.7 и используют одно и то же СКЗИ — АПК «Валидата Клиент L». Можно собрать всё на одной ВМ или разнести по отдельным.</p>
<h3>Q: Мы перекладываем файлы между bj-server и ИШ?</h3>
<p>Нет. Мы используем <strong>REST API</strong> ИШ (раздел 2.5 инструкции). bj-server делает HTTP-запросы: <code>POST /api/package/{channel}/file</code> с ZIP в теле. Никаких разделяемых папок. (Альтернативный режим «обменные папки» в ИШ есть — мы его не используем.)</p>
<h3>Q: Где брать Валидату?</h3>
<p>Дистрибутив для Astra Linux SE опубликован на сайте Московской Биржи: <a href="https://fs.moex.com/cdp/po/ClientL_ALSE.zip" target="_blank" rel="noopener">fs.moex.com/cdp/po/ClientL_ALSE.zip</a>. На Linux отдельной лицензии и регистрационных данных не требует — пакеты <code>zpki</code>/<code>zsdk</code> ставятся через <code>dpkg -i</code> и работают сразу.</p>
<h3>Q: Какой сертификат нужен?</h3>
<p>Только от <strong>УЦ Московской Биржи</strong> (<code>ca.moex.com</code>). Сертификаты других УЦ ИШ не примет. УЦ МБ выпускает сертификаты только для организаций, подключённых к ЭДО НРД (по договору).</p>
<h3>Q: Что делать, чтобы протестировать на роботе НРД на TEST3?</h3>
<ol>
<li>Получить сертификат УЦ МБ для нашей организации.</li>
<li>Подать <a href="https://www.nsd.ru/workflow/zayavka-na-testirovanie/" target="_blank">заявку на тестирование</a> в НРД (инструкция в <code>DOC/instr_int_sh_01072025.pdf</code>).</li>
<li>Получить от НРД код депонента-тестера и доступ к TEST3.</li>
<li>Поднять Astra Linux ВМ, поставить ИШ + Валидату, импортировать сертификат.</li>
<li>В нашем <a href="/admin/setup">/admin/setup</a> → «Интеграционный шлюз НРД» указать URL ИШ (например <code>http://10.10.10.23:8080</code>) и имя канала из ИШ.</li>
<li>Отправить тестовую заявку с <code>ReceiverCode = MC0012500000</code> и <code>DocumentSeries = 2001</code> — робот ответит «Принять все бумаги».</li>
</ol>
<h3>Q: Сколько времени нужно от старта подключения до прогона на TEST3?</h3>
<p>Оптимистично — <strong>1-2 недели</strong> (если все ответы НРД быстрые и УЦ МБ не задерживает). Реалистично — <strong>3-4 недели</strong>. На нашей стороне всё уже готово; задержка только во внешних шагах.</p>
</div>
<div class="card">
<h2>Что у нас уже готово</h2>
<ul>
<li><strong>REST-клиент ИШ</strong> в <code>internal/nsdadapter/igw/</code> — все 4 endpoint'а по спецификации, упаковщик/распаковщик ZIP, 10 тестов PASS</li>
<li><strong>Робот-эмулятор</strong> в <code>internal/nsdadapter/mock/</code> — позволяет проверить нашу логику до получения реального ИШ</li>
<li><strong>Конфигурация в админке</strong> — поля <code>igw_base_url</code> и <code>channel</code> в /admin/setup; авто-определение профилей GUEST/TEST3/PROD</li>
<li><strong>Подбор URL контуров</strong> — при выборе профиля URL ONYX заполняется автоматически</li>
<li><strong>Полная документация ИШ</strong> в <code>DOC/</code> и дистрибутив в <code>dist/ish/</code></li>
</ul>
</div>
{{end}}
@@ -0,0 +1,136 @@
{{define "content"}}
<div class="card">
<h2>Криптография (АПК «Валидата Клиент L»)</h2>
<p class="muted">bj-server общается с СКЗИ «Валидата Клиент L» через сайдкар <code>bj-crypto</code> по UDS <code>/run/bj/crypto.sock</code>. Чтобы подпись и проверка квитанций НРД заработали, нужен <strong>ключевой профиль</strong> — папка с тремя сущностями: <code>local.pse</code> (зашифрованный контейнер), <code>local.gdbm</code> (база сертификатов) и <code>vdkeys/*.vdk</code> (сам ключ).</p>
<p class="muted"><strong>Архив от MOEX/НРД содержит «резервную копию», а не готовый профиль.</strong> На Linux рабочий <code>local.gdbm</code> нельзя восстановить headless — Валидата Linux требует GUI-операции «Восстановить справочники из резервной копии». Поэтому профиль готовится один раз на Windows и переносится на сервер через USB.</p>
</div>
<div class="card" style="border-left:4px solid var(--accent)">
<h2>Почему профиль готовится на Windows, а не на сервере</h2>
<p>Боевой Astra Linux SE-сервер с ГОСТ-криптографией <strong>обязан быть headless</strong>: чем меньше пакетов и поверхности атаки, тем проще сертификация ФСТЭК и тем меньше нарушений требований к контуру ЭДО НРД. Установка GUI (X-сервер, GTK, шрифты, VNC/RDP) тянет 50+ пакетов, расширяет surface attack и усложняет аудит — поэтому отказались.</p>
<p>Это <strong>стандартная практика</strong> в фин-секторе: на admin-станции (под Windows или отдельной защищённой ВМ) генерируются и обновляются профили; на боевые серверы они доставляются готовыми через выделенный USB или защищённый канал. Все инструкции MOEX/НРД написаны именно под Windows — этот путь поддерживается официально.</p>
<p class="muted">Альтернативный путь — Linux GUI через X11-forwarding или VNC на дев-стенды — допустим только в песочнице, не в проде. На боевых серверах <code>zcs</code>/<code>vdcsp_cfg</code> не должны запускаться.</p>
</div>
<div class="card" style="border-left:4px solid var(--ok)">
<h2>✅ Подготовка профиля (Windows → USB → bj-server)</h2>
<h3 style="margin-top:14px">Шаг A — на компьютере под Windows</h3>
<ol>
<li><strong>Установите СКЗИ Валидата CSP для Windows</strong>.<br>
Скачайте дистрибутив с <a href="https://www.moex.com/s1292" target="_blank" rel="noopener">moex.com/s1292</a> (раздел «СКЗИ для Windows», файл «Валидата CSP v.6.0.482.0 64bit»). Внутри архива есть <code>Readme.txt</code> с регистрационными данными — введите их во время установки.
</li>
<li><strong>Распакуйте архив-профиль от MOEX/НРД</strong>.<br>
Например <code>PrUser985.7z</code> с паролем <code>11</code> в папку <code>C:\moex-src\</code>. Получится структура:
<pre style="font-size:12px">C:\moex-src\
spr985\
local.pse
local.gdbm ← это «резервная копия», на Linux не работает напрямую
vdkeys\
XXXXXXXXXXXXXXXX.vdk
key.reg</pre>
</li>
<li><strong>Зарегистрируйте ключ в системе Windows</strong>.<br>
Двойной клик по <code>key.reg</code> → «Да» на запрос о записи в реестр. Это нужно, чтобы Валидата увидела ключ при восстановлении справочников.
</li>
<li><strong>Откройте «Справочник сертификатов x64»</strong> из меню «Пуск» → «АПК Валидата Клиент».</li>
<li><strong>Создайте профиль на флешке</strong>:
<ul>
<li>Вставьте чистую USB-флешку, запомните её букву (например <code>E:</code>).</li>
<li>В Справочнике: меню <em>Профили</em><em>Настройка профилей</em><em>Добавить</em>.</li>
<li>Имя профиля: например <code>moex</code>.</li>
<li><strong>Каталог профиля</strong>: создайте новую пустую папку <strong>на флешке</strong>, например <code>E:\moex\</code>. Это путь, куда Валидата положит рабочую копию.</li>
</ul>
</li>
<li><strong>Восстановите справочники из резервной копии</strong>:<br>
Меню <em>Сервис</em><em>Восстановить справочники из резервной копии</em>. В диалоге укажите папку <code>C:\moex-src\spr985\</code>. Дождитесь сообщения «Справочники восстановлены».<br>
После этого в <code>E:\moex\</code> появятся <code>local.pse</code> и <strong>рабочий</strong> <code>local.gdbm</code> (отличается от исходной резервной копии).
</li>
<li><strong>Скопируйте папку <code>vdkeys</code> на корень флешки</strong>.<br>
Скопируйте папку <code>C:\moex-src\vdkeys\</code> в корень флешки. Итоговая структура:
<pre style="font-size:12px">E:\
moex\ ← рабочий профиль, созданный Валидатой
local.pse
local.gdbm ← теперь правильный
vdkeys\
XXXXXXXXXXXXXXXX.vdk</pre>
</li>
<li><strong>Безопасно извлеките флешку</strong> через значок в системном трее Windows.</li>
</ol>
<h3 style="margin-top:18px">Шаг B — на сервере (этот веб-интерфейс)</h3>
<ol>
<li><strong>Вставьте флешку в сервер</strong> (физический USB-порт или прокинутая через гипервизор виртуальная флешка).</li>
<li>Откройте <a href="/admin/setup">/admin/setup</a>. Через 2-3 секунды (автодетект монтирования) в блоке <strong>«Носители ключей»</strong> появится строка <code>🔌 USB /run/media/...</code>. Внутри неё — сабблок <strong>«Профиль Валидаты»</strong> с тремя строками: <code>local.pse</code> / <code>local.gdbm</code> / <code>*.vdk</code>.</li>
<li>В поле <strong>«Имя профиля»</strong> введите осмысленное имя (например <code>moex</code>) и нажмите <strong>«Импортировать профиль в Валидату»</strong>.<br>
Сервер скопирует файлы в <code>/var/lib/bj/profiles/&lt;имя&gt;/</code>, допишет секцию в <code>/opt/Validata/VDCSP/etc/pki1.conf</code>. Toast подтвердит: «Секция дописана в pki1.conf».</li>
<li>В таблице <strong>«Импортированные профили Валидаты»</strong> ниже — нажмите <strong>«Активировать»</strong> в строке вашего профиля.<br>
Toast: «Валидата: контекст с профилем &lt;имя&gt; инициализирован» → готово.</li>
<li>Можно извлекать флешку — все нужные файлы уже скопированы в <code>/var/lib/bj/profiles/</code>.</li>
</ol>
<h3 style="margin-top:18px">Проверка</h3>
<ol>
<li>В блоке «СКЗИ» нажмите зелёную кнопку <strong>«✓ Проверить подключение СКЗИ»</strong>.</li>
<li>Toast должен показать что-то вроде: <code>СКЗИ validata: 0.1.0 (Валидата: контекст с профилем «moex» инициализирован)</code>.</li>
</ol>
</div>
<div class="card">
<h2>Что делать если профиль на флешке не виден</h2>
<ul>
<li><strong>USB не монтируется автоматически в Astra Linux SE.</strong> Подключите вручную: посмотрите <code>lsblk</code>, потом <code>sudo mount /dev/sdb1 /mnt</code>. Через секунду «Носители ключей» подхватит точку монтирования.</li>
<li><strong>Файлы лежат не в корне флешки.</strong> Сканер ищет в глубину 4 уровня — если поместили в <code>E:\very\deep\folder\moex\</code>, должно тоже найтись.</li>
<li><strong>На флешке нет <code>vdkeys\</code>.</strong> Без неё профиль не работает — ключ <code>.vdk</code> обязателен.</li>
<li><strong>«Ни контейнеров, ни сертификатов, ни профиля Валидаты не найдено».</strong> Это значит на носителе нет <em>одновременно</em> <code>.pse</code> и <code>.vdk</code> файлов. Перепроверьте Шаг 6-7 на Windows.</li>
</ul>
</div>
<div class="card">
<h2>Альтернатива: загрузка как ZIP-архив</h2>
<p>Если USB-доступ к серверу неудобен — можно собрать содержимое флешки в обычный <code>.zip</code> на Windows и загрузить через web-форму.</p>
<ol>
<li>После шага A.7 (когда на флешке готовая структура <code>moex\</code> + <code>vdkeys\</code>) — выделите обе папки, правый клик → <em>Отправить</em><em>Сжатая ZIP-папка</em>.</li>
<li>На сервере: <a href="/admin/setup">/admin/setup</a> → «Носители ключей» → форма «Загрузить образ или архив» → выберите ZIP, поле «Пароль» оставьте пустым.</li>
<li>Дальше как в Шаге B со 2-го пункта.</li>
</ol>
<p class="muted">Под капотом сервер распаковывает архив через <code>7z</code> в <code>/var/lib/bj/media/iso/</code>, сканирует на профиль Валидаты — далее всё то же самое, что с USB.</p>
</div>
<div class="card">
<h2>Справочные команды (диагностика)</h2>
<table>
<tbody>
<tr><td><code>systemctl status bj-crypto</code></td><td>Состояние Java-сайдкара (UDS-сокет, провайдер).</td></tr>
<tr><td><code>sudo journalctl -u bj-crypto -n 50</code></td><td>Последние строки лога сайдкара.</td></tr>
<tr><td><code>cat /opt/Validata/VDCSP/etc/pki1.conf</code></td><td>Список профилей, которые видит Валидата (наши секции помечены <code># --- bj-server: профиль ...</code>).</td></tr>
<tr><td><code>sudo ls -la /var/lib/bj/profiles/</code></td><td>Импортированные профили на сервере.</td></tr>
<tr><td><code>/opt/Validata/VDCSP/bin/amd64/testcsp -silent</code></td><td>Базовая проверка провайдера CSP.</td></tr>
</tbody>
</table>
</div>
<div class="card">
<h2>Установка Валидаты на сервер (если её ещё нет)</h2>
<p class="muted">Если этот раздел вам не показывает «✓ ready» — повторите установку:</p>
<pre>curl -fsSL https://fs.moex.com/cdp/po/ClientL_ALSE.zip -o ClientL_ALSE.zip
unzip ClientL_ALSE.zip
sudo apt-get install -y libccid pcscd execstack
sudo dpkg -i ClientL_ALSE/zpki-*.deb ClientL_ALSE/zsdk-*.deb
sudo apt-get -f install -y
sudo execstack -c /opt/Validata/VDCSP/lib/amd64/libvdcsp.so
sudo systemctl enable --now pcscd</pre>
<p class="muted">Дистрибутив для Astra Linux SE — <a href="https://fs.moex.com/cdp/po/ClientL_ALSE.zip" target="_blank" rel="noopener">fs.moex.com/cdp/po/ClientL_ALSE.zip</a>. Linux-версия отдельной лицензии не требует.</p>
</div>
{{end}}
@@ -1,130 +0,0 @@
{{define "content"}}
<p style="margin-bottom:16px"><a href="/admin/help">← все инструкции</a></p>
<div class="card">
<h2>КриптоПро и Рутокен</h2>
<p class="muted">Bridge-and-Join-s использует ГОСТ Р 34.10-2012 для подписи и проверки XMLDSig. Серверная криптография — КриптоПро CSP. Подпись оператора в admin-ui — Рутокен ЭЦП 2.0 (опционально). Оба продукта говорят со стандартным интерфейсом PKCS#11, поэтому Go-клиент общается с ними одинаково.</p>
</div>
<div class="card">
<h2>1. Что и зачем нужно</h2>
<table>
<thead><tr><th>Сценарий</th><th>СКЗИ</th><th>Цена (ориентир)</th></tr></thead>
<tbody>
<tr><td>Проверка XMLDSig входящих от НРД и брокеров</td><td>КриптоПро CSP «Сервер»</td><td>~30-50к ₽ (один раз)</td></tr>
<tr><td>Подпись пакетов в НРД (резервный канал WS ONYX)</td><td>КриптоПро CSP «Сервер»</td><td>включено</td></tr>
<tr><td>Подпись действий оператора в admin-ui</td><td>Рутокен ЭЦП 2.0 + лицензия CSP «Рабочее место»</td><td>~3-5к ₽ железо + ~2-3к ₽ лицензия</td></tr>
<tr><td>Проверка XMLDSig заявлений от ЛК</td><td>КриптоПро CSP «Сервер»</td><td>включено</td></tr>
</tbody>
</table>
<p class="muted">Если используется Интеграционный шлюз НРД (ИШ), он сам подписывает пакеты — наша серверная подпись нужна только для резервного канала ONYX и подписи действий оператора. Можно начать с минимума: только Рутокен оператора и отложить серверную лицензию.</p>
</div>
<div class="card">
<h2>2. Установка КриптоПро CSP на РЕД ОС (проверено)</h2>
<p><strong>Способ 1 — через веб-интерфейс (рекомендуется):</strong> <a href="/admin/setup">/admin/setup</a> → «СКЗИ» → «Установка КриптоПро CSP» → выбрать <code>linux-amd64.tar</code> с cryptopro.ru → «Загрузить и установить».</p>
<p><strong>Способ 2 — вручную из терминала.</strong> Скачать <code>linux-amd64.tgz</code> с <code>www.cryptopro.ru/products/csp/downloads</code> (доступ через личный кабинет), распаковать на ВМ и установить минимальный набор:</p>
<pre>tar -xzf linux-amd64.tgz
cd linux-amd64
sudo rpm -Uvh --replacepkgs --nodeps \
lsb-cprocsp-base-5.0.*.noarch.rpm \
lsb-cprocsp-ca-certs-5.0.*.noarch.rpm \
lsb-cprocsp-rdr-64-5.0.*.x86_64.rpm \
lsb-cprocsp-capilite-64-5.0.*.x86_64.rpm \
lsb-cprocsp-kc1-64-5.0.*.x86_64.rpm \
lsb-cprocsp-pkcs11-64-5.0.*.x86_64.rpm \
cprocsp-curl-64-5.0.*.x86_64.rpm \
cprocsp-rdr-gui-gtk-64-5.0.*.x86_64.rpm</pre>
<p>Ключевые пакеты:</p>
<ul>
<li><code>lsb-cprocsp-base</code> + <code>lsb-cprocsp-rdr-64</code> — базовая инфраструктура</li>
<li><code>lsb-cprocsp-capilite-64</code> — CAPILite (<code>libcapi20.so.4</code>, <code>libcpext.so.4</code>) — иначе libcppkcs11.so не загрузится</li>
<li><code>lsb-cprocsp-kc1-64</code> — CSP класса КС1 (без него Initialize упадёт с CKR_FUNCTION_FAILED)</li>
<li><code>lsb-cprocsp-pkcs11-64</code> — собственно <code>libcppkcs11.so</code></li>
</ul>
<p>Демо-лицензия на 3 месяца встроена в дистрибутив, отдельная активация не требуется. Проверка:</p>
<pre>/opt/cprocsp/sbin/amd64/cpconfig -license -view
/opt/cprocsp/bin/amd64/csptest -keyset -enum -unique</pre>
<p><strong>Важно — LD_LIBRARY_PATH.</strong> КриптоПро CSP кладёт .so в <code>/opt/cprocsp/lib/amd64</code> без записи в <code>/etc/ld.so.conf.d</code>. Bj-server при запуске должен иметь:</p>
<pre>Environment=LD_LIBRARY_PATH=/opt/cprocsp/lib/amd64</pre>
<p>В systemd-юните это уже прописано (<code>deploy/systemd/bj-server.service</code>). При ручном запуске из shell — <code>LD_LIBRARY_PATH=/opt/cprocsp/lib/amd64 ./bin/bj-server</code>.</p>
<p><strong>Активация коммерческой лицензии.</strong> После того как демо истечёт, серийник вводится через UI на <a href="/admin/setup">/admin/setup</a> → «Активация лицензии», или вручную:</p>
<pre>sudo /opt/cprocsp/sbin/amd64/cpconfig -license -set XXXX-XXXXX-XXXXX-XXXXX-XXXXX</pre>
</div>
<div class="card">
<h2>3. Установка на Ubuntu / Debian</h2>
<pre>sudo dpkg -i cprocsp-rdr-gui-gtk-64_5.0.*_amd64.deb \
cprocsp-rdr-64_5.0.*_amd64.deb \
lsb-cprocsp-base_5.0.*_all.deb \
lsb-cprocsp-rdr-64_5.0.*_amd64.deb
sudo apt-get install -f
sudo /opt/cprocsp/sbin/amd64/cpconfig -license -set XXXX-XXXXX-XXXXX-XXXXX-XXXXX</pre>
</div>
<div class="card">
<h2>4. PKCS#11 модуль</h2>
<p>Путь к библиотеке после установки:</p>
<pre>/opt/cprocsp/lib/amd64/libcppkcs11.so</pre>
<p>Эта же библиотека работает и с CSP-ключами (контейнеры на диске или в реестре), и с Рутокен ЭЦП 2.0 (подключённым по USB или в виде smart-card reader).</p>
<p>На <a href="/admin/setup">странице «Настройка»</a> в карточке «Криптография» укажите:</p>
<ul>
<li><strong>Провайдер</strong>: <code>cryptopro</code></li>
<li><strong>UDS-сокет</strong>: <code>/run/bj/crypto.sock</code> (для legacy crypto-service на Java — на M2+ переходим на Go-клиент напрямую через PKCS#11)</li>
<li><strong>Путь к jcp.jar / PKCS#11</strong>: <code>/opt/cprocsp/lib/amd64/libcppkcs11.so</code></li>
<li><strong>Лицензионный ключ</strong>: серийный номер CSP</li>
</ul>
</div>
<div class="card">
<h2>5. Подключение Рутокен ЭЦП 2.0</h2>
<p>Подключите Рутокен в USB. Драйверы КриптоПро CSP уже включают поддержку Рутокен:</p>
<pre># увидеть подключённые токены
/opt/cprocsp/bin/amd64/csptest -card -enum
# увидеть ключевые контейнеры на токене
/opt/cprocsp/bin/amd64/csptest -keyset -enum -unique</pre>
<p>Для подписи действий оператора в admin-ui:</p>
<ol>
<li>Запросить сертификат на физлицо у УЦ (через личный кабинет КриптоПро или через АРМ оператора УЦ).</li>
<li>Записать сертификат и контейнер на Рутокен.</li>
<li>На <a href="/admin/setup">странице «Настройка»</a> в карточке «Криптография» выбрать провайдер <code>cryptopro</code> и указать слот Рутокен.</li>
</ol>
</div>
<div class="card">
<h2>6. Импорт сертификата</h2>
<pre># сертификат корневого УЦ (если ещё нет в системе)
/opt/cprocsp/bin/amd64/certmgr -inst -store mroot -file /path/to/root-ca.cer
# сертификат подписанта (контейнер на токене)
/opt/cprocsp/bin/amd64/certmgr -inst -store uMy -cont '\\.\HDIMAGE\my-keys' \
-file /path/to/operator.cer
# проверить установленные сертификаты
/opt/cprocsp/bin/amd64/certmgr -list -store uMy</pre>
</div>
<div class="card">
<h2>7. Тестирование подписи</h2>
<p>Через CLI КриптоПро (быстрая проверка что криптография работает):</p>
<pre># подписать произвольный файл
/opt/cprocsp/bin/amd64/cryptcp -signf -dn 'CN=Иванов И.И.' \
-det -strict /tmp/test.txt
# проверить подпись
/opt/cprocsp/bin/amd64/cryptcp -vsignf -det /tmp/test.txt /tmp/test.txt.sgn</pre>
<p>Через нашу систему — раздел <a href="/admin/setup">Настройка</a> → кнопка «Запустить тестовую заявку». На странице «Заявка» появится результат и расшифровка проверки подписи.</p>
</div>
<div class="card">
<h2>8. Поддержка</h2>
<ul>
<li>Документация КриптоПро: <code>www.cryptopro.ru/products/csp</code></li>
<li>Установка на РЕД ОС: <code>www.cryptopro.ru/forum2/default.aspx?g=topics&f=43</code></li>
<li>Технические вопросы: <code>support@cryptopro.ru</code></li>
<li>Рутокен: <code>dev.rutoken.ru/display/PUB/Rutoken+EDS</code></li>
</ul>
<p class="muted">При проблемах с лицензией сначала проверьте <code>cpconfig -license -view</code> — лицензия должна быть валидна и не просрочена. Срок действия КриптоПро лицензии — обычно 1 год.</p>
</div>
{{end}}
@@ -87,7 +87,7 @@
<div class="card">
<h2>8. Подписание заявления</h2>
<p>ЛК должен подписать заявление XMLDSig (ГОСТ или RSA) и положить в поле <code>signed_document</code> (base64). Мы проверяем подпись через crypto-service — см. <a href="/admin/help/cryptopro">инструкцию по КриптоПро</a>.</p>
<p>ЛК должен подписать заявление XMLDSig (ГОСТ или RSA) и положить в поле <code>signed_document</code> (base64). Мы проверяем подпись через crypto-service — см. <a href="/admin/help/crypto">инструкцию по криптографии</a>.</p>
<p class="muted">На M2 проверка подписи отключена (stub). На M3-M4 включится после подключения СКЗИ.</p>
</div>
{{end}}
@@ -43,26 +43,20 @@
<ul>
<li>Профиль (например, <code>test3-gost</code>) — при выборе URL и контейнер заполняются автоматически</li>
<li>URL ONYX — например <code>https://gost-t3.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo</code></li>
<li>Ключевой контейнер — имя контейнера КриптоПро с ключами ЭДО НРД (выдаются УЦ НРД, см. ниже)</li>
<li>Ключевой контейнер — имя контейнера Валидаты с ключами ЭДО НРД (выдаются УЦ НРД, см. ниже)</li>
</ul>
<p class="muted">Без настроенного ИШ система работает в <strong>mock-режиме</strong>: bj-server эмитирует синтетический Decision через 3 секунды для каждой заявки. Это удобно для дев-демо и не требует подключения к НРД.</p>
</div>
<div class="card">
<h2>1а. Сертификаты УЦ НРД (для проверки квитанций)</h2>
<p>НРД подписывает все исходящие пакеты ЭДО ГОСТ-подписью своего УЦ. Для проверки этих подписей на нашей стороне нужно импортировать корневые сертификаты УЦ НРД в хранилище <code>mroot</code> (доверенные корневые).</p>
<p>НРД подписывает все исходящие пакеты ЭДО ГОСТ-подписью своего УЦ. Для проверки этих подписей на нашей стороне нужно загрузить корневые и промежуточные сертификаты УЦ НРД.</p>
<ol>
<li>Скачать сертификаты с сайта УЦ НРД: <code>www.nsd.ru/workflow/system/cryptography/</code> (или из дистрибутива ИШ).</li>
<li>В <a href="/admin/setup">/admin/setup</a> → раздел «Импорт сертификата» → выбрать файл <code>.cer</code>, тип хранилища <code>mroot — корневой УЦ</code>, нажать «Импортировать». Под капотом выполняется <code>certmgr -inst -file root.cer -store mroot</code>.</li>
<li>Промежуточные сертификаты УЦ — в хранилище <code>uRoot</code>.</li>
<li>Для проверки подписей самой системы НРД (квитанции ЭДО) — импортировать сертификат подписи НРД в <code>uMy</code> (как корреспондента), либо оставить в <code>mroot</code>, если он самоподписной.</li>
</ol>
<p><strong>Наши сертификаты для отправки в НРД</strong> (получаются из другого УЦ — нашей организации):</p>
<ol>
<li>Сертификат подписи нашей организации (с приватным ключом в виде <code>.pfx</code>/<code>.p12</code> или на Рутокен) — импортировать в <code>uMy</code> с PIN.</li>
<li>Цепочка сертификатов вашего УЦ — в <code>mroot</code> (корневой) и <code>uRoot</code> (промежуточные).</li>
<li>После импорта проверить: <code>certmgr -list -store uMy</code> и <code>cpverify</code>.</li>
<li>В <a href="/admin/setup">/admin/setup</a> → раздел «Сертификаты УЦ» добавить прямые URL <code>.cer</code>-файлов и нажать «Скачать и импортировать сейчас». Файлы сохраняются в <code>/var/lib/bj/ca-certs/</code> (по SHA-256). Включите «Авто-обновление раз в сутки» — система перепроверит и обновит.</li>
<li>Загруженные через Валидату ключи и сертификаты управляются её собственным справочником (<code>zcs</code>/<code>vdcsp_cfg</code>).</li>
</ol>
<p><strong>Наши сертификаты для отправки в НРД</strong> загружаются в профиль Валидаты её утилитой <code>zcs</code> (импорт ключевого контейнера и сертификата подписи).</p>
<p class="muted">Полный цикл обмена сертификатами с НРД описан в <code>DOC/Инструкция M2M.pdf</code> и <code>DOC/Презентация MOEX MOST.pdf</code>.</p>
</div>
<p><strong>Документация по подключению</strong>: <code>DOC/instr_podkl_stend_v3.pdf</code>, <code>DOC/Ссылки для доступа в тестовые контуры.pdf</code>.</p>
@@ -105,7 +99,6 @@
<tr><td>НРД (Национальный расчётный депозитарий)</td><td>Тестовые сертификаты GUEST/TEST3, дистрибутив ИШ, доступ к личному кабинету УЦ НРД</td></tr>
<tr><td>Команда ЛК (ESIA Finance)</td><td>Базовый URL ЛК, Basic-auth учётные данные, очерёдность подключения (сначала эмулятор, потом реальный ЛК)</td></tr>
<tr><td>Команда Fansy</td><td>Контракт <code>docs/fansy-contract/v1/</code>, SLA, окна обслуживания, IP-allowlist</td></tr>
<tr><td>КриптоПро</td><td>Серийный номер лицензии CSP, актуальный дистрибутив, поддержка <code>support@cryptopro.ru</code></td></tr>
<tr><td>Брокеры-контрагенты MOST</td><td>БКС (ИНН 5406121446), Ренессанс (7709258228), Альфа-Банк (7728168971) — уже в seed</td></tr>
</tbody>
</table>
@@ -1,63 +1,75 @@
{{define "content"}}
{{/* Активные новости — сразу под навигацией. Показываем top-3: те у которых ValidFrom..ValidTo сейчас активны, иначе свежие. */}}
{{if .News}}
<div class="card" style="border-left:3px solid var(--accent);margin-bottom:16px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<h2 style="margin:0">📢 Новости</h2>
<a href="/admin/news" style="font-size:13px">все новости →</a>
</div>
{{range .News}}
<div style="padding:8px 0;border-bottom:1px solid var(--border)">
<div style="font-weight:600;font-size:14px">
{{if eq .Kind "maintenance"}}🔧 {{end}}{{if eq .Kind "feature"}}✨ {{end}}{{if eq .Kind "system"}}⚠ {{end}}{{if eq .Kind "doc-update"}}📄 {{end}}{{.Title}}
{{/* ===== Оператор-дашборд (Apple-стиль): приветствие → статус → плитки задач → сводка ===== */}}
<div class="hero">
<h1 class="hero-greeting">Добрый день</h1>
{{if .AllReady}}
<span class="hero-status ok">● Система готова к работе</span>
{{else}}
<div style="display:flex;align-items:center;gap:14px;flex-wrap:wrap">
<span class="hero-status warn">● Требуется настройка — {{.NotReadyCount}} из {{.TotalCount}} компонентов</span>
<a href="/admin/wizard" class="btn">Открыть мастер настройки →</a>
</div>
{{if .Body}}<div class="muted" style="font-size:12px;margin-top:4px">{{.Body}}</div>{{end}}
{{if and (not .ValidFrom.IsZero) (not .ValidTo.IsZero)}}
<div class="muted" style="font-size:11px;margin-top:4px">с {{.ValidFrom.Format "02.01.2006"}} по {{.ValidTo.Format "02.01.2006"}}</div>
{{end}}
</div>
{{end}}
</div>
{{end}}
{{/* ===== Крупные плитки задач ===== */}}
<div class="tiles">
<a href="/admin/claims?new=1" class="tile brand">
<span class="ico"></span>
<span class="t-title">Новый перевод</span>
<span class="t-sub">Заявка на перевод ценных бумаг M2M</span>
<span class="t-arrow"></span>
</a>
<a href="/admin/claims" class="tile">
<span class="ico">📋</span>
<span class="t-title">Переводы</span>
<span class="t-sub">{{.Counts.Total}} всего · {{.Counts.InProgress}} в работе</span>
<span class="t-arrow"></span>
</a>
<a href="/admin/status" class="tile">
<span class="ico">🔍</span>
<span class="t-title">Диагностика</span>
<span class="t-sub">Состояние СКЗИ, ИШ и базы</span>
<span class="t-arrow"></span>
</a>
<a href="/admin/setup" class="tile">
<span class="ico">⚙️</span>
<span class="t-title">Настройка</span>
<span class="t-sub">Криптография, НРД, подключения</span>
<span class="t-arrow"></span>
</a>
</div>
{{/* ===== Сводка по переводам ===== */}}
<div class="grid">
<div class="stat">
<div class="stat-label">Всего сделок</div>
<div class="stat-label">Всего переводов</div>
<div class="stat-value">{{.Counts.Total}}</div>
</div>
<div class="stat">
<div class="stat-label">Подтверждено</div>
<div class="stat-value" style="color: var(--ok)">{{.Counts.Confirmed}}</div>
<div class="stat-value" style="color:var(--ok)">{{.Counts.Confirmed}}</div>
</div>
<div class="stat">
<div class="stat-label">В ожидании</div>
<div class="stat-value" style="color: var(--warn)">{{.Counts.InProgress}}</div>
<div class="stat-value" style="color:var(--warn)">{{.Counts.InProgress}}</div>
</div>
<div class="stat">
<div class="stat-label">Отказы / таймауты</div>
<div class="stat-value" style="color: var(--err)">{{.Counts.Failed}}</div>
<div class="stat-value" style="color:var(--err)">{{.Counts.Failed}}</div>
</div>
</div>
<div class="card">
<h2>Состояние системы</h2>
{{range .Status.Checks}}
<div style="padding: 6px 0">
<span class="dot {{if .OK}}ok{{else}}err{{end}}"></span>
<strong>{{.Name}}</strong> — {{.Message}}
{{if .Detail}}<span class="muted"> · <code>{{.Detail}}</code></span>{{end}}
</div>
{{end}}
<div class="muted" style="margin-top: 12px">
Профиль: <code>{{.Status.Profile}}</code> · Crypto-провайдер: <code>{{.Status.Provider}}</code>
</div>
{{/* ===== Последние переводы ===== */}}
<div class="section-head">
<h2>Последние переводы</h2>
<a href="/admin/claims">все →</a>
</div>
<div class="card">
<h2>Последние заявки</h2>
{{if .Recent}}
<table>
<thead><tr><th>Создана</th><th>ID</th><th>Инвестор</th><th>ЦБ</th><th>Статус</th><th></th></tr></thead>
<thead><tr><th>Время</th><th>ID</th><th>Инвестор</th><th>ЦБ</th><th>Статус</th><th></th></tr></thead>
<tbody>
{{range .Recent}}
<tr>
@@ -72,7 +84,25 @@
</tbody>
</table>
{{else}}
<p class="muted">Заявок ещё нет. Подайте первую через lk-emulator или POST /api/v1/back_office/claims/.</p>
<p class="muted" style="margin:0">Переводов ещё нет. Нажмите «Новый перевод», чтобы создать первый.</p>
{{end}}
</div>
{{/* ===== События (компактно, если есть) ===== */}}
{{if .News}}
<div class="section-head">
<h2>События</h2>
<a href="/admin/news">все →</a>
</div>
<div class="card">
{{range .News}}
<div style="padding:9px 0;border-bottom:1px solid var(--border)">
<div style="font-weight:600;font-size:13.5px">
{{if eq .Kind "maintenance"}}🔧 {{end}}{{if eq .Kind "feature"}}✨ {{end}}{{if eq .Kind "system"}}⚠️ {{end}}{{if eq .Kind "doc-update"}}📄 {{end}}{{.Title}}
</div>
{{if .Body}}<div class="muted" style="font-size:12px;margin-top:3px">{{.Body}}</div>{{end}}
</div>
{{end}}
</div>
{{end}}
{{end}}
@@ -0,0 +1,106 @@
{{define "content"}}
{{/* Пошаговый мастер установки ключа Валидаты на флешку. */}}
<div class="hero">
<h1 class="hero-greeting">Установка ключа на флешку</h1>
<span class="hero-status">Загрузите архив НРД → запись на носитель → справочник сертификатов → проверка → готово</span>
</div>
{{$s := .State}}
{{/* ===== Лента шагов ===== */}}
<div class="card">
<ol style="list-style:none;padding:0;margin:0;display:grid;gap:12px">
{{range $i, $step := $s.Steps}}
<li style="display:flex;gap:12px;align-items:flex-start">
<span style="flex:0 0 28px;height:28px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:13px;
{{if eq $step.Status "ok"}}background:var(--ok-weak);color:var(--ok)
{{else if eq $step.Status "error"}}background:var(--err-weak);color:var(--err)
{{else if eq $step.Status "active"}}background:var(--accent-weak);color:var(--accent)
{{else}}background:var(--surface-2,#eee);color:var(--muted,#999){{end}}">
{{if eq $step.Status "ok"}}✓{{else if eq $step.Status "error"}}✕{{else}}{{add $i 1}}{{end}}
</span>
<div style="flex:1">
<div style="font-weight:600">{{$step.Title}}</div>
{{if $step.Detail}}<div class="muted" style="font-size:13px;margin-top:2px">{{$step.Detail}}</div>{{end}}
</div>
</li>
{{end}}
</ol>
</div>
{{/* ===== Действие в зависимости от состояния ===== */}}
{{if $s.Done}}
<div class="card" style="border-left:3px solid var(--ok)">
<h2>✓ Готово</h2>
<p>Ключ установлен на флешку, справочник сертификатов сформирован, Валидата проверена.</p>
{{if $s.Backup}}<p class="muted">Бэкап прежнего носителя: <code>{{$s.Backup}}</code></p>{{end}}
<div style="display:flex;gap:8px;margin-top:12px;flex-wrap:wrap">
<form method="post" action="/admin/setup/test-nsd" style="margin:0">
<input type="hidden" name="scenario" value="2001">
<button type="submit" class="btn btn-ok">→ Отправить тестовый документ роботу</button>
</form>
<form method="post" action="/admin/setup/keywizard/reset" style="margin:0">
<button type="submit" class="btn btn-secondary">Установить ещё один ключ</button>
</form>
</div>
</div>
{{else if $s.StagingID}}
{{/* Архив загружен — выбор флешки + запись */}}
<div class="card">
<h2>Шаг 2 — выбор флешки и запись</h2>
<p class="muted">Архив распакован. Ключ: <code>{{fallbackTpl $s.VDK "—"}}</code>.
Выберите носитель — запись сделает бэкап, запишет ключ и справочник
сертификатов, дотянет CRL и перезапустит ИШ.</p>
<form method="post" action="/admin/setup/keywizard/install" style="margin-top:12px;display:grid;gap:12px;max-width:640px"
onsubmit="this.querySelector('button[type=submit]').disabled=true;this.querySelector('button[type=submit]').textContent='Устанавливаю…';">
<div>
<label style="font-weight:600;display:block;margin-bottom:6px">Целевая флешка</label>
{{if .Drives}}
{{range $i, $d := .Drives}}
<label style="display:flex;gap:10px;align-items:flex-start;padding:10px;border:1px solid var(--border,#ddd);border-radius:8px;margin-bottom:8px;cursor:pointer">
<input type="radio" name="target_device" value="{{$d.Device}}" {{if $d.IsKeymedia}}checked{{else if and (eq $i 0) (not (anyKeymedia $.Drives))}}checked{{end}} style="margin-top:3px">
<span>
<b>{{fallbackTpl $d.Model "USB-носитель"}}</b> · {{$d.Size}} · {{$d.FSType}}
{{if $d.Label}}· метка «{{$d.Label}}»{{end}}<br>
<span class="muted" style="font-size:12px">{{$d.Device}}{{if $d.Mountpoint}} · {{$d.Mountpoint}}{{end}}
{{if $d.IsKeymedia}}<b style="color:var(--accent)">← текущий ключевой носитель ИШ (рекомендуется)</b>{{end}}</span>
</span>
</label>
{{end}}
{{else}}
<p class="muted">Съёмные носители не обнаружены — будет использован текущий ключевой носитель ИШ по умолчанию.</p>
{{end}}
</div>
<div>
<label style="font-weight:600;display:block;margin-bottom:6px">Имя профиля в справочнике (необязательно)</label>
<input type="text" name="profile_name" placeholder="Авто из архива (напр. PrUser1046)" autocomplete="off"
pattern="[A-Za-z0-9_-]*" style="width:100%">
<span class="muted" style="font-size:12px">Пусто = имя берётся из архива автоматически.</span>
</div>
<button type="submit" class="btn btn-ok">Записать на флешку, сформировать справочник и проверить ИШ</button>
</form>
<form method="post" action="/admin/setup/keywizard/reset" style="margin-top:8px">
<button type="submit" class="btn btn-secondary">Отмена / загрузить другой архив</button>
</form>
</div>
{{else}}
{{/* Начало — форма загрузки */}}
<div class="card">
<h2>Шаг 1 — загрузка архива</h2>
<p class="muted">Выберите .7z-архив с ключом от НРД и введите пароль архива.</p>
<form method="post" action="/admin/setup/keywizard/upload" enctype="multipart/form-data"
style="margin-top:12px;display:grid;gap:10px;max-width:560px">
<input type="file" name="archive" accept=".7z,.zip" required>
<input type="password" name="password" placeholder="Пароль архива (например 11)" autocomplete="off">
<button type="submit" class="btn btn-ok">Загрузить и распаковать</button>
</form>
</div>
{{end}}
<p style="margin-top:16px"><a href="/admin/setup" class="muted">← Назад к настройкам</a></p>
{{end}}
@@ -11,7 +11,7 @@
.news-title { font-size:15px; font-weight:600; margin:0 0 6px 0; }
.news-body { font-size:13px; white-space:pre-wrap; }
.news-validity { margin-top:6px; padding:4px 8px; background:var(--bg); border-radius:4px; display:inline-block; font-size:12px; }
.news-validity.active { background:rgba(232,177,58,0.15); color:var(--warn); }
.news-validity.active { background:var(--warn-weak); color:var(--warn); }
</style>
{{if .Flash}}<div class="card" style="border-left:3px solid var(--accent)"><p style="margin:0">{{.Flash}}</p></div>{{end}}
+358 -298
View File
@@ -1,324 +1,384 @@
{{define "content"}}
{{if .Flash}}<div style="padding:12px 16px;background:rgba(63,191,108,0.1);border-left:3px solid var(--ok);border-radius:4px;margin-bottom:16px">{{.Flash}}</div>{{end}}
{{if .Flash}}<div style="padding:12px 16px;background:var(--ok-weak);border-left:3px solid var(--ok);border-radius:8px;margin-bottom:16px">{{.Flash}}</div>{{end}}
<div class="card">
<h2>Готовность системы: {{.ReadyCount}} из {{.TotalCount}}</h2>
<div style="display:flex;gap:8px;margin-top:8px">
{{range .Readiness}}
<div style="flex:1;text-align:center;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px">
<span class="dot {{if .Configured}}ok{{else}}err{{end}}"></span>
<strong>{{.Name}}</strong><br>
<span class="muted" style="font-size:11px">{{if .Configured}}настроено{{else}}не настроено{{end}}</span>
</div>
{{end}}
</div>
</div>
<div class="settings">
{{/* ===== Боковая навигация разделов с индикаторами ===== */}}
<nav class="settings-nav">
<button data-sec="overview" class="active"><span class="nico"></span>Обзор</button>
<button data-sec="db"><span class="nico">🗄️</span>База данных<span class="ind {{if .Settings.Postgres.DSN}}ok{{else}}err{{end}}"></span></button>
<button data-sec="crypto"><span class="nico">🔐</span>Криптография<span class="ind {{if .Settings.Crypto.Profile}}ok{{else}}warn{{end}}"></span></button>
<button data-sec="nsd"><span class="nico">🏛️</span>НРД<span class="ind {{if .Settings.NSD.IGWBaseURL}}ok{{else}}warn{{end}}"></span></button>
<button data-sec="tests"><span class="nico">🧪</span>Тесты</button>
<button data-sec="update"><span class="nico">⬆️</span>Обновления{{if .Settings.Update.Available}}<span class="ind warn"></span>{{end}}</button>
<button data-sec="license"><span class="nico">🔑</span>Лицензия{{if .License.Present}}<span class="ind {{if .License.Valid}}ok{{else}}err{{end}}"></span>{{end}}</button>
</nav>
<!-- PostgreSQL -->
<div class="card">
<h2><span class="dot {{if .Settings.Postgres.DSN}}ok{{else}}err{{end}}"></span>PostgreSQL</h2>
<p class="muted">Принимающая БД (fansy-store) и журнал сделок m2m-core. Сейчас:
{{if .Settings.Postgres.DSN}}<code>настроено</code>{{else}}<code>in-memory</code> (M2-демо){{end}}.</p>
<div class="settings-body">
{{if not .Settings.Postgres.DSN}}
<div style="background:var(--bg);border:1px solid var(--accent);border-radius:6px;padding:14px;margin-top:12px">
<h3 style="margin:0 0 8px 0;font-size:15px">Самый простой вариант — подключить автоматически</h3>
<p class="muted" style="margin:0 0 10px 0">Если у вас ещё нет своего PostgreSQL, мы поднимем его сами в контейнере (podman-compose), применим все миграции и запишем DSN. Подходит для дев-стенда и тестирования. Для прода — лучше указать свой DSN ниже.</p>
<form method="post" action="/admin/setup/postgres/quick-start" style="margin:0">
<button type="submit" class="btn" style="background:var(--accent);color:white;border:none;padding:10px 18px;border-radius:4px;font-weight:600;cursor:pointer">⚡ Поднять локальный PostgreSQL автоматически</button>
<span class="muted" style="margin-left:10px;font-size:12px">Займёт ~10-30 секунд. Требуется установленный <code>podman-compose</code>.</span>
</form>
</div>
{{end}}
{{/* ============ ОБЗОР ============ */}}
<section class="settings-section active" id="sec-overview">
<h1>Обзор</h1>
<div class="card">
<h2>Готовность системы: {{.ReadyCount}} из {{.TotalCount}}</h2>
<div class="grid" style="margin-top:12px">
{{range .Readiness}}
<div class="stat">
<div><span class="dot {{if .Configured}}ok{{else}}err{{end}}"></span><strong>{{.Name}}</strong></div>
<div class="muted" style="font-size:12px;margin-top:4px">{{if .Configured}}настроено{{else}}не настроено{{end}}</div>
</div>
{{end}}
</div>
</div>
</section>
<details {{if not .Settings.Postgres.DSN}}style="margin-top:12px"{{end}}>
<summary style="cursor:pointer;color:var(--accent);font-size:13px">{{if .Settings.Postgres.DSN}}Изменить параметры подключения{{else}}…или ввести параметры подключения вручную (для существующего PostgreSQL){{end}}</summary>
<form method="post" action="/admin/setup/postgres" style="margin-top:12px">
<div class="form-row" style="display:grid;grid-template-columns:200px 1fr;gap:12px;align-items:center">
<label>DSN <span class="muted" title="DSN = Data Source Name. Строка вида postgres://пользователь:пароль@хост:порт/база?опции" style="cursor:help">(?)</span></label>
<input type="text" name="dsn" value="{{.Settings.Postgres.DSN}}" placeholder="postgres://bj:secret@127.0.0.1:5432/bj?sslmode=disable" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
{{/* ============ БАЗА ДАННЫХ ============ */}}
<section class="settings-section" id="sec-db">
<h1>База данных</h1>
<div class="card">
<h2><span class="dot {{if .Settings.Postgres.DSN}}ok{{else}}err{{end}}"></span>PostgreSQL</h2>
<p class="muted">Журнал сделок m2m-core и принимающая БД. Сейчас: {{if .Settings.Postgres.DSN}}<code>подключено</code>{{else}}<code>in-memory</code> (данные не сохраняются){{end}}.</p>
{{if not .Settings.Postgres.DSN}}
<div style="background:var(--card-2);border:1px solid var(--accent);border-radius:10px;padding:16px;margin-top:12px">
<h3 style="margin:0 0 8px">Подключить автоматически</h3>
<p class="muted" style="margin:0 0 12px">Поднимем локальный PostgreSQL в контейнере, применим миграции и запишем DSN. Для дев-стенда. Для прода — укажите свой DSN ниже.</p>
<form method="post" action="/admin/setup/postgres/quick-start" style="margin:0">
<button type="submit" class="btn">⚡ Поднять локальный PostgreSQL</button>
</form>
</div>
<p class="muted" style="margin-top:8px">При сохранении выполняется Ping. Если БД недоступна — будет ошибка.</p>
<button type="submit" class="btn" style="background:var(--accent);color:white;border:none;padding:8px 16px;border-radius:4px;margin-top:8px">Сохранить и проверить</button>
</form>
</details>
</div>
<!-- СКЗИ через PKCS#11: КриптоПро CSP / Рутокен / Валидата / ViPNet -->
<div class="card">
<h2><span class="dot {{if and .Settings.Crypto.JCPPath .Settings.Crypto.LicenseKey}}ok{{else}}err{{end}}"></span>СКЗИ (КриптоПро CSP, Рутокен и др. через PKCS#11)</h2>
<p class="muted">Go-клиент подключается к СКЗИ напрямую через стандартный PKCS#11 интерфейс. Поддерживаются КриптоПро CSP, Рутокен ЭЦП 2.0, Валидата, ViPNet — один клиент, разные .so модули. Подробно — раздел <a href="/admin/help/cryptopro">«КриптоПро»</a> в инструкциях.</p>
<table style="margin-bottom:12px">
<tr><td style="width:220px" class="muted">Текущий провайдер</td><td><code>{{.Settings.Crypto.Provider}}</code></td></tr>
<tr><td class="muted">Путь к модулю PKCS#11</td><td><code>{{if .Settings.Crypto.JCPPath}}{{.Settings.Crypto.JCPPath}}{{else}}—{{end}}</code></td></tr>
<tr><td class="muted">UDS-сокет (legacy)</td><td><code>{{.Settings.Crypto.SocketPath}}</code></td></tr>
<tr><td class="muted">Лицензия введена</td><td>{{if .Settings.Crypto.LicenseKey}}<span style="color:var(--ok)">да</span>{{else}}<span style="color:var(--err)">нет</span>{{end}}</td></tr>
</table>
<details {{if or (eq .Settings.Crypto.Provider "stub") (not .Settings.Crypto.JCPPath)}}open{{end}}>
<summary style="cursor:pointer;color:var(--accent);font-size:13px">Изменить параметры СКЗИ</summary>
<form method="post" action="/admin/setup/crypto" style="margin-top:12px;display:grid;gap:10px">
<div class="form-row" style="display:grid;grid-template-columns:220px 1fr;gap:12px;align-items:center">
<label>Провайдер СКЗИ</label>
<select name="provider" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
<option value="stub" {{if eq .Settings.Crypto.Provider "stub"}}selected{{end}}>stub — без криптографии (демо)</option>
<option value="cryptopro" {{if eq .Settings.Crypto.Provider "cryptopro"}}selected{{end}}>КриптоПро CSP (через PKCS#11)</option>
<option value="rutoken" {{if eq .Settings.Crypto.Provider "rutoken"}}selected{{end}}>Рутокен ЭЦП 2.0 (для подписи оператора)</option>
<option value="validata" {{if eq .Settings.Crypto.Provider "validata"}}selected{{end}}>Валидата</option>
<option value="vipnet" {{if eq .Settings.Crypto.Provider "vipnet"}}selected{{end}}>ViPNet</option>
</select>
</div>
<div class="form-row" style="display:grid;grid-template-columns:220px 1fr;gap:12px;align-items:center">
<label>Путь к модулю PKCS#11</label>
<input type="text" name="jcp_path" value="{{.Settings.Crypto.JCPPath}}" placeholder="/opt/cprocsp/lib/amd64/libcppkcs11.so" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
</div>
<div class="form-row" style="display:grid;grid-template-columns:220px 1fr;gap:12px;align-items:center">
<label>UDS-сокет (legacy)</label>
<input type="text" name="socket_path" value="{{.Settings.Crypto.SocketPath}}" placeholder="/run/bj/crypto.sock (только для совместимости со старым Java crypto-service)" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
</div>
<div class="form-row" style="display:grid;grid-template-columns:220px 1fr;gap:12px;align-items:flex-start">
<label>Серийный номер лицензии</label>
<textarea name="license_key" rows="3" placeholder="XXXX-XXXXX-XXXXX-XXXXX-XXXXX (серийный номер КриптоПро CSP)" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;font-family:monospace;font-size:12px">{{.Settings.Crypto.LicenseKey}}</textarea>
</div>
<p class="muted">
<strong>КриптоПро CSP</strong>: установить пакеты <code>rpm -i cprocsp-*.rpm</code>, активировать лицензию командой <code>cpconfig -license -set XXXX-...</code>, указать <code>/opt/cprocsp/lib/amd64/libcppkcs11.so</code>.<br>
<strong>Рутокен</strong>: подключить токен USB, указать <code>/usr/lib64/librtpkcs11ecp.so</code>.<br>
Полная инструкция: <a href="/admin/help/cryptopro">/admin/help/cryptopro</a>. При сохранении проверим, что файл модуля существует.
</p>
<div style="display:flex;gap:8px">
<button type="submit" class="btn" style="background:var(--accent);color:white;border:none;padding:8px 16px;border-radius:4px">Сохранить</button>
</div>
</form>
<form method="post" action="/admin/setup/crypto/check" style="margin-top:12px">
<button type="submit" class="btn" style="background:var(--border);color:var(--text);border:none;padding:8px 16px;border-radius:4px">Проверить подключение СКЗИ</button>
<span class="muted" style="margin-left:8px">Загрузит PKCS#11 модуль, опросит список токенов, покажет результат сверху страницы.</span>
</form>
<hr style="margin:18px 0;border:none;border-top:1px solid var(--border)">
<h3 style="font-size:14px;margin:0 0 8px">Установка КриптоПро CSP</h3>
<p class="muted">Дистрибутив с <a href="https://www.cryptopro.ru/products/csp/downloads" target="_blank" rel="noopener">cryptopro.ru</a> (например, <code>linux-amd64.tgz</code> или <code>linux-amd64.tar</code> для РЕД ОС/ALT/ROSA). Загрузите файл здесь — он будет распакован и установлен через <code>sudo rpm -Uvh</code>. Установка длится ~30 секунд.</p>
<form method="post" action="/admin/setup/crypto/install" enctype="multipart/form-data" style="margin-top:12px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<input type="file" name="dist" accept=".tar,.tgz,.gz,.rpm" required style="padding:6px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;flex:1;min-width:300px">
<button type="submit" class="btn" style="background:var(--accent);color:white;border:none;padding:8px 16px;border-radius:4px">Загрузить и установить</button>
</form>
<hr style="margin:18px 0;border:none;border-top:1px solid var(--border)">
<h3 style="font-size:14px;margin:0 0 8px">Сертификаты на токенах</h3>
{{if .Certificates}}
<table>
<thead><tr><th>Кому</th><th>Кем выдан</th><th>ИНН</th><th>Действителен</th><th>Токен</th><th>Приватный ключ</th></tr></thead>
<tbody>
{{range .Certificates}}
<tr>
<td>{{.SubjectCN}}</td>
<td class="muted">{{.IssuerCN}}</td>
<td><code>{{.INN}}</code></td>
<td class="muted">до {{.NotAfter.Format "02.01.2006"}}</td>
<td class="muted">«{{.TokenLabel}}» (slot {{.SlotID}})</td>
<td>{{if .HasPrivateKey}}<span style="color:var(--ok)">есть</span>{{else}}<span class="muted">нет</span>{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="muted">На подключенных токенах сертификатов не найдено. Загрузите .pfx ниже или подключите Рутокен с сертификатом.</p>
{{end}}
<details {{if not .Settings.Postgres.DSN}}open{{end}} style="margin-top:14px">
<summary style="cursor:pointer;color:var(--accent);font-size:13px">{{if .Settings.Postgres.DSN}}Изменить подключение{{else}}…или ввести DSN вручную{{end}}</summary>
<form method="post" action="/admin/setup/postgres" style="margin-top:12px;display:grid;gap:10px;max-width:640px">
<label>DSN</label>
<input type="text" name="dsn" value="{{.Settings.Postgres.DSN}}" placeholder="postgres://bj:secret@127.0.0.1:5432/bj?sslmode=disable">
<p class="muted" style="margin:0">При сохранении выполняется Ping.</p>
<div><button type="submit" class="btn">Сохранить и проверить</button></div>
</form>
</details>
</div>
</section>
<hr style="margin:18px 0;border:none;border-top:1px solid var(--border)">
<h3 style="font-size:14px;margin:0 0 8px">Импорт сертификата (.pfx / .cer / .crt)</h3>
<p class="muted">PFX с приватным ключом (с PIN) — для серверной подписи и подписи оператора. CER/CRT без приватного ключа — для проверки чужих подписей (например, сертификаты УЦ НРД для проверки квитанций). Подробно — <a href="/admin/help/cryptopro">/admin/help/cryptopro</a>.</p>
<form method="post" action="/admin/setup/crypto/import-cert" enctype="multipart/form-data" style="margin-top:8px;display:grid;gap:8px;grid-template-columns:auto auto auto auto;align-items:center">
<input type="file" name="cert" accept=".pfx,.p12,.cer,.crt" required style="padding:6px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
<input type="text" name="pin" placeholder="PIN (только для .pfx/.p12)" style="padding:6px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;font-family:monospace">
<select name="store" style="padding:6px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
<option value="uMy">uMy — личный (для подписи)</option>
<option value="mroot">mroot — корневой УЦ (для проверки)</option>
<option value="uRoot">uRoot — промежуточные УЦ</option>
</select>
<button type="submit" class="btn" style="background:var(--accent);color:white;border:none;padding:8px 16px;border-radius:4px">Импортировать</button>
</form>
{{/* ============ КРИПТОГРАФИЯ ============ */}}
<section class="settings-section" id="sec-crypto">
<h1>Криптография</h1>
<hr style="margin:18px 0;border:none;border-top:1px solid var(--border)">
<h3 style="font-size:14px;margin:0 0 8px">Активация лицензии</h3>
<form method="post" action="/admin/setup/crypto/activate" style="margin-top:6px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<input type="text" name="license_key" value="{{.Settings.Crypto.LicenseKey}}" placeholder="XXXX-XXXXX-XXXXX-XXXXX-XXXXX (серийный номер КриптоПро CSP)" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;font-family:monospace;font-size:12px;min-width:340px">
<button type="submit" class="btn" style="background:var(--ok);color:#0a0f1a;border:none;padding:8px 16px;border-radius:4px;font-weight:600">Активировать лицензию</button>
</form>
<p class="muted" style="margin-top:8px">Вызовет <code>cpconfig -license -set</code> и сохранит серийник. Если КриптоПро CSP ещё не установлен — покажет инструкцию.</p>
</details>
</div>
<div class="card" style="border-left:3px solid var(--accent)">
<h2>🔑 Установка ключа на флешку</h2>
<p class="muted">Пошаговый мастер: загрузить архив НРД с паролем → запись на флешку → справочник сертификатов → проверка Валидаты → готово.</p>
<a href="/admin/setup/keywizard" class="btn btn-ok" style="margin-top:10px;display:inline-block">Открыть мастер установки ключа →</a>
</div>
<!-- Контейнеры КриптоПро на флешке -->
<div class="card">
<h2><span class="dot {{if .FlashContainers}}ok{{else}}warn{{end}}"></span>Контейнеры на USB-носителях (флешка/Рутокен)</h2>
{{if .FlashContainers}}
<p class="muted">Найдено {{len .FlashContainers}} контейнер(а) формата <code>name.000</code> на смонтированных USB-носителях. Кнопка ниже копирует папку в <code>/var/opt/cprocsp/keys/$USER/</code> — после этого контейнер виден как <code>\\.\HDIMAGE\name</code> и работает без вставленной флешки.</p>
<table style="margin-top:8px">
<thead><tr><th>Носитель</th><th>Имя контейнера</th><th>Файлы</th><th>Статус</th><th></th></tr></thead>
<tbody>
{{range .FlashContainers}}
<tr>
<td><code style="font-size:12px">{{.Mountpoint}}</code></td>
<td><strong>{{.Name}}</strong></td>
<td><span class="muted" style="font-size:11px">{{len .Files}} файлов</span></td>
<td>{{if .AlreadyImported}}<span style="color:var(--ok)">уже в HDIMAGE</span>{{else}}<span class="muted">только на флешке</span>{{end}}</td>
<td>
{{if not .AlreadyImported}}
<form method="post" action="/admin/setup/crypto/copy-container" style="margin:0">
<input type="hidden" name="src" value="{{.Path}}">
<button type="submit" class="btn" style="background:var(--ok);color:#0a0f1a;padding:6px 12px;font-size:12px;font-weight:600">Скопировать в локальное хранилище</button>
<div class="card">
<h2><span class="dot {{if .Settings.Crypto.Profile}}ok{{else}}warn{{end}}"></span>СКЗИ «Валидата Клиент L»</h2>
<p class="muted">Активный профиль: <code>{{if .Settings.Crypto.Profile}}{{.Settings.Crypto.Profile}}{{else}}{{end}}</code> · провайдер <code>{{.Settings.Crypto.Provider}}</code>. Подробно — <a href="/admin/help/crypto">справка</a>.</p>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:12px">
<form method="post" action="/admin/setup/crypto/check" style="margin:0"><button type="submit" class="btn btn-ok">✓ Проверить СКЗИ</button></form>
<form method="post" action="/admin/setup/crypto/test-sign" style="margin:0"><button type="submit" class="btn">✎ Тестовая подпись</button></form>
<form method="post" action="/admin/setup/restart-crypto" style="margin:0" onsubmit="return confirm('Перезапустить crypto-service? Поднимется через ~5 сек.');"><button type="submit" class="btn btn-warn">↻ crypto-service</button></form>
<form method="post" action="/admin/setup/restart-server" style="margin:0" onsubmit="return confirm('Перезапустить bj-server? Через 5-10 сек страница вернётся.');"><button type="submit" class="btn btn-secondary">↻ bj-server</button></form>
</div>
<details style="margin-top:14px">
<summary style="cursor:pointer;color:var(--accent);font-size:13px">Параметры провайдера (для совместимости)</summary>
<form method="post" action="/admin/setup/crypto" style="margin-top:12px;display:grid;gap:10px;max-width:640px">
<label>Провайдер</label>
<select name="provider">
<option value="stub" {{if eq .Settings.Crypto.Provider "stub"}}selected{{end}}>stub — без криптографии (демо)</option>
<option value="validata" {{if eq .Settings.Crypto.Provider "validata"}}selected{{end}}>Валидата Клиент L</option>
</select>
<label>Путь к модулю PKCS#11</label>
<input type="text" name="module_path" value="{{.Settings.Crypto.ModulePath}}" placeholder="/opt/Validata/VDCSP/lib/amd64/libvdpkcs11.so">
<label>UDS-сокет</label>
<input type="text" name="socket_path" value="{{.Settings.Crypto.SocketPath}}" placeholder="/run/bj/crypto.sock">
<div><button type="submit" class="btn">Сохранить</button></div>
</form>
</details>
</div>
{{/* Носители ключей */}}
<div class="card">
<h2><span class="dot {{if .Media}}ok{{else}}warn{{end}}"></span>Носители ключей</h2>
<p class="muted">USB-флешки сканируются автоматически. Образы (.iso/.img/.zip/.7z) загружаются ниже — bj-server распакует и найдёт профиль Валидаты, контейнеры, сертификаты.</p>
<form method="post" action="/admin/setup/media/iso/upload" enctype="multipart/form-data" style="margin-top:12px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<input type="file" name="iso" accept=".iso,.img,.zip,.7z" required style="flex:1;min-width:240px">
<input type="password" name="password" placeholder="Пароль архива (для MOEX — 11)" autocomplete="off" style="min-width:200px">
<button type="submit" class="btn">Загрузить</button>
</form>
<p class="muted" style="margin-top:6px;font-size:12px">Лимит 500 МБ. Распаковка через 7z.</p>
{{if .Media}}
{{range .Media}}
<div style="margin-top:14px;padding:14px;background:var(--card-2);border:1px solid var(--border);border-radius:10px">
<div style="display:flex;justify-content:space-between;align-items:center">
<div><strong>{{if eq .Kind "iso"}}📀 ISO{{else}}🔌 USB{{end}}</strong> <code style="font-size:12px;margin-left:8px">{{.Mountpoint}}</code>{{if .Source}}<span class="muted" style="font-size:11px;margin-left:8px">{{.Source}}</span>{{end}}</div>
{{if eq .Kind "iso"}}
<form method="post" action="/admin/setup/media/iso/unmount" style="margin:0" onsubmit="return confirm('Удалить распаковку {{.Mountpoint}}?');">
<input type="hidden" name="id" value="{{.ID}}"><button type="submit" class="btn btn-secondary" style="padding:5px 11px;font-size:12px">Удалить распаковку</button>
</form>
{{end}}
</td>
</tr>
</div>
{{if .Profile}}
<h3>Профиль Валидаты</h3>
<table>
<tbody>
<tr><td>ПСП (.pse)</td><td class="muted" style="font-size:11px">{{range .Profile.PSEFiles}}{{.}}<br>{{end}}</td></tr>
<tr><td>ЛСП (.gdbm)</td><td class="muted" style="font-size:11px">{{range .Profile.GDBMFiles}}{{.}}<br>{{end}}</td></tr>
<tr><td>Ключи (.vdk)</td><td class="muted" style="font-size:11px">{{range .Profile.KeyFiles}}{{.}}<br>{{end}}</td></tr>
</tbody>
</table>
<form method="post" action="/admin/setup/media/import-profile" style="margin-top:8px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<input type="hidden" name="root" value="{{.Profile.Root}}">
<input type="text" name="name" placeholder="Имя профиля, напр. nrd-edo">
{{if .Profile.Imported}}<span style="color:var(--ok)">✓ импортирован</span>{{else}}<button type="submit" class="btn btn-ok" style="padding:6px 12px;font-size:12px">Импортировать профиль</button>{{end}}
</form>
{{end}}
{{if .Containers}}
<h3>Контейнеры ({{len .Containers}})</h3>
<table>
<thead><tr><th>Имя</th><th>Статус</th><th></th></tr></thead>
<tbody>{{range .Containers}}
<tr><td><strong>{{.Name}}</strong> <span class="muted" style="font-size:11px">{{.Path}}</span></td><td>{{if .Imported}}<span style="color:var(--ok)">импортирован</span>{{else}}<span class="muted">нет</span>{{end}}</td>
<td>{{if not .Imported}}<form method="post" action="/admin/setup/media/import-container" style="margin:0"><input type="hidden" name="path" value="{{.Path}}"><button type="submit" class="btn btn-ok" style="padding:5px 11px;font-size:12px">Импортировать</button></form>{{end}}</td></tr>
{{end}}</tbody>
</table>
{{end}}
{{if and (not .Containers) (not .Certificates) (not .Profile)}}<p class="muted" style="margin-top:8px;font-size:12px">Профиль Валидаты не найден на носителе.</p>{{end}}
</div>
{{end}}
</tbody>
</table>
{{else}}
<p class="muted">Подключённые USB-носители с контейнерами КриптоПро (папки <code>name.000</code> с *.key) не обнаружены. Поиск идёт в <code>/run/media/$USER/</code>, <code>/media/$USER/</code>, <code>/media/</code>, <code>/mnt/</code>. Вставьте флешку и обновите страницу.</p>
{{end}}
</div>
{{else}}<p class="muted" style="margin-top:10px">Носители не обнаружены. Подключите USB или загрузите образ.</p>{{end}}
<!-- Авто-загрузка сертификатов УЦ НРД -->
<div class="card">
<h2><span class="dot {{if .Settings.CACerts.URLs}}ok{{else}}warn{{end}}"></span>Сертификаты УЦ (НРД и др.) — авто-загрузка</h2>
<p class="muted">Прямые URL .cer-файлов УЦ НРД (см. <a href="https://www.nsd.ru/workflow/system/cryptography/" target="_blank" rel="noopener">nsd.ru/workflow/system/cryptography/</a>) и других УЦ. Каждый URL скачивается, парсится X.509, и автоматически импортируется в КриптоПро (<code>mroot</code> для корневых, <code>uRoot</code> для промежуточных). Включите авто-обновление — раз в сутки система перепроверит и переустановит, если сертификат изменился.</p>
<form method="post" action="/admin/setup/cacerts" style="margin-top:10px;display:grid;gap:10px">
<label>URL'ы .cer-файлов (один на строку)</label>
<textarea name="urls" rows="4" placeholder="https://www.nsd.ru/path/to/root-ca.cer&#10;https://www.nsd.ru/path/to/sub-ca.cer" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;font-family:monospace;font-size:12px">{{range .Settings.CACerts.URLs}}{{.}}
{{end}}</textarea>
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" name="auto_update" {{if .Settings.CACerts.AutoUpdate}}checked{{end}}>
<span>Авто-обновление раз в сутки</span>
</label>
<div style="display:flex;gap:8px;align-items:center">
<button type="submit" class="btn">Сохранить</button>
{{if .ImportedProfiles}}
<h3 style="margin-top:18px">Импортированные профили</h3>
<table>
<thead><tr><th>Имя</th><th>Состояние</th><th>Действия</th></tr></thead>
<tbody>{{range .ImportedProfiles}}
<tr><td><strong>{{.}}</strong></td><td>{{if eq . $.Settings.Crypto.Profile}}<span style="color:var(--ok)">✓ активен</span>{{else}}<span class="muted">не активен</span>{{end}}</td>
<td style="display:flex;gap:6px;flex-wrap:wrap">
{{if ne . $.Settings.Crypto.Profile}}<form method="post" action="/admin/setup/media/activate-profile" style="margin:0"><input type="hidden" name="name" value="{{.}}"><button type="submit" class="btn" style="padding:5px 11px;font-size:12px">Активировать</button></form>{{end}}
<form method="post" action="/admin/setup/media/delete-profile" style="margin:0" onsubmit="return confirm('Удалить профиль «{{.}}»?');"><input type="hidden" name="name" value="{{.}}"><button type="submit" class="btn btn-danger" style="padding:5px 11px;font-size:12px">Удалить</button></form>
</td></tr>
{{end}}</tbody>
</table>
{{end}}
</div>
</form>
<form method="post" action="/admin/setup/cacerts/fetch" style="margin-top:8px">
<button type="submit" class="btn" style="background:var(--ok);color:#0a0f1a;font-weight:600">⬇ Скачать и импортировать сейчас</button>
{{if not .Settings.CACerts.LastFetch.IsZero}}
<span class="muted" style="margin-left:10px">Последнее обновление: {{.Settings.CACerts.LastFetch.Format "02.01.2006 15:04:05"}}</span>
{{end}}
</form>
{{if .Settings.CACerts.FetchedCerts}}
<table style="margin-top:14px">
<thead><tr><th>URL</th><th>Владелец</th><th>Хранилище</th><th>Действителен до</th><th>SHA-256</th><th>Статус</th></tr></thead>
<tbody>
{{range .Settings.CACerts.FetchedCerts}}
<tr>
<td style="max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="{{.URL}}"><code style="font-size:11px">{{.URL}}</code></td>
<td>{{.SubjectCN}}</td>
<td><code>{{.Store}}</code></td>
<td>{{if not .NotAfter.IsZero}}{{.NotAfter.Format "02.01.2006"}}{{end}}</td>
<td><code style="font-size:11px">{{if .SHA256}}{{slice .SHA256 0 12}}…{{end}}</code></td>
<td>{{if .Error}}<span style="color:var(--err)" title="{{.Error}}">ошибка</span>{{else}}<span style="color:var(--ok)">ок</span>{{end}}</td>
</tr>
{{/* Сертификаты УЦ */}}
<div class="card">
<h2><span class="dot {{if .Settings.CACerts.URLs}}ok{{else}}warn{{end}}"></span>Сертификаты УЦ (авто-загрузка)</h2>
<p class="muted">URL .cer-файлов УЦ НРД (<a href="https://www.nsd.ru/workflow/system/cryptography/" target="_blank" rel="noopener">nsd.ru</a>). Скачиваются, парсятся и сохраняются в <code>/var/lib/bj/ca-certs/</code>.</p>
<form method="post" action="/admin/setup/cacerts" style="margin-top:10px;display:grid;gap:10px;max-width:720px">
<label>URL'ы (один на строку)</label>
<textarea name="urls" rows="3" style="font-family:ui-monospace,monospace;font-size:12px">{{range .Settings.CACerts.URLs}}{{.}}
{{end}}</textarea>
<label style="display:flex;align-items:center;gap:8px;cursor:pointer"><input type="checkbox" name="auto_update" {{if .Settings.CACerts.AutoUpdate}}checked{{end}} style="width:auto"> Авто-обновление раз в сутки</label>
<div style="display:flex;gap:8px"><button type="submit" class="btn">Сохранить</button></div>
</form>
<form method="post" action="/admin/setup/cacerts/fetch" style="margin-top:8px"><button type="submit" class="btn btn-ok">⬇ Скачать сейчас</button>{{if not .Settings.CACerts.LastFetch.IsZero}}<span class="muted" style="margin-left:10px">обновлено {{.Settings.CACerts.LastFetch.Format "02.01.2006 15:04"}}</span>{{end}}</form>
{{if .Settings.CACerts.FetchedCerts}}
<table style="margin-top:14px">
<thead><tr><th>Владелец</th><th>Тип</th><th>До</th><th>Статус</th></tr></thead>
<tbody>{{range .Settings.CACerts.FetchedCerts}}<tr><td>{{.SubjectCN}}</td><td><code>{{.Store}}</code></td><td>{{if not .NotAfter.IsZero}}{{.NotAfter.Format "02.01.2006"}}{{end}}</td><td>{{if .Error}}<span style="color:var(--err)" title="{{.Error}}">ошибка</span>{{else}}<span style="color:var(--ok)">ок</span>{{end}}</td></tr>{{end}}</tbody>
</table>
{{end}}
</tbody>
</table>
{{end}}
{{if .Settings.CACerts.LastFetchLog}}
<details style="margin-top:10px">
<summary style="cursor:pointer;color:var(--accent);font-size:13px">Лог последнего обновления</summary>
<pre style="margin-top:8px">{{.Settings.CACerts.LastFetchLog}}</pre>
</details>
{{end}}
</div>
</div>
</section>
<!-- nsd-adapter / ИШ НРД -->
<div class="card">
<h2><span class="dot {{if .Settings.NSD.IGWBaseURL}}ok{{else}}err{{end}}"></span>Интеграционный шлюз НРД (ИШ)</h2>
<p class="muted">{{if not .Settings.NSD.IGWBaseURL}}Сейчас <code>mock-режим</code> — Decision эмитируется через 3 секунды после Send.{{else}}Профиль <code>{{.Settings.NSD.Profile}}</code>, ИШ <code>{{.Settings.NSD.IGWBaseURL}}</code>.{{end}}</p>
<p class="muted">Подключение к стендам: <a href="/admin/help/systems">/admin/help/systems</a> — там полная таблица URL контуров GUEST/TEST3/PROD и инструкция по установке ИШ. Дистрибутив ИШ скачивается с <code>www.nsd.ru/workflow/system/programs/#0-widget-faq-0-4</code>.</p>
<details {{if not .Settings.NSD.IGWBaseURL}}open{{end}}>
<summary style="cursor:pointer;color:var(--accent);font-size:13px">Изменить параметры ИШ</summary>
<form method="post" action="/admin/setup/nsd" style="margin-top:12px;display:grid;gap:10px">
<div class="form-row" style="display:grid;grid-template-columns:200px 1fr;gap:12px;align-items:center">
<label>Профиль</label>
<select name="profile" id="nsd-profile" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
<option value="" {{if eq .Settings.NSD.Profile ""}}selected{{end}}>— mock (демо без реального ИШ) —</option>
<option value="guest-gost" {{if eq .Settings.NSD.Profile "guest-gost"}}selected{{end}}>guest-gost — контур GUEST, ГОСТ ключи</option>
<option value="guest-rsa" {{if eq .Settings.NSD.Profile "guest-rsa"}}selected{{end}}>guest-rsa — контур GUEST, RSA ключи</option>
<option value="test3-gost" {{if eq .Settings.NSD.Profile "test3-gost"}}selected{{end}}>test3-gost — контур TEST3, ГОСТ ключи</option>
<option value="test3-rsa" {{if eq .Settings.NSD.Profile "test3-rsa"}}selected{{end}}>test3-rsa — контур TEST3, RSA ключи</option>
<option value="prod-gost" {{if eq .Settings.NSD.Profile "prod-gost"}}selected{{end}}>prod-gost — продуктивный, ГОСТ</option>
<option value="prod-rsa" {{if eq .Settings.NSD.Profile "prod-rsa"}}selected{{end}}>prod-rsa — продуктивный, RSA</option>
{{/* ============ НРД ============ */}}
<section class="settings-section" id="sec-nsd">
<h1>НРД</h1>
<div class="card">
<h2><span class="dot {{if .Settings.NSD.IGWBaseURL}}ok{{else}}warn{{end}}"></span>Интеграционный шлюз (ИШ)</h2>
<p class="muted">{{if not .Settings.NSD.IGWBaseURL}}<code>mock-режим</code> — без реального ИШ.{{else}}Профиль <code>{{.Settings.NSD.Profile}}</code>, ИШ <code>{{.Settings.NSD.IGWBaseURL}}</code>.{{end}} Стенды и установка — <a href="/admin/help/systems">справка</a>.</p>
<form method="post" action="/admin/setup/nsd" style="margin-top:12px;display:grid;gap:10px;max-width:680px">
<label>Профиль / контур</label>
<select name="profile" id="nsd-profile">
<option value="" {{if eq .Settings.NSD.Profile ""}}selected{{end}}>— mock (демо) —</option>
<option value="guest-gost" {{if eq .Settings.NSD.Profile "guest-gost"}}selected{{end}}>guest-gost — GUEST, ГОСТ</option>
<option value="guest-rsa" {{if eq .Settings.NSD.Profile "guest-rsa"}}selected{{end}}>guest-rsa — GUEST, RSA</option>
<option value="test3-gost" {{if eq .Settings.NSD.Profile "test3-gost"}}selected{{end}}>test3-gost — TEST3, ГОСТ</option>
<option value="test3-rsa" {{if eq .Settings.NSD.Profile "test3-rsa"}}selected{{end}}>test3-rsa — TEST3, RSA</option>
<option value="prod-gost" {{if eq .Settings.NSD.Profile "prod-gost"}}selected{{end}}>prod-gost — ПРОМ, ГОСТ</option>
<option value="prod-rsa" {{if eq .Settings.NSD.Profile "prod-rsa"}}selected{{end}}>prod-rsa — ПРОМ, RSA</option>
</select>
</div>
<div class="form-row" style="display:grid;grid-template-columns:200px 1fr;gap:12px;align-items:center">
<label>URL ONYX (WSL) НРД</label>
<input type="text" name="igw_base_url" id="nsd-url" value="{{.Settings.NSD.IGWBaseURL}}" placeholder="будет заполнено по профилю" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
</div>
<div class="form-row" style="display:grid;grid-template-columns:200px 1fr;gap:12px;align-items:center">
<input type="text" name="igw_base_url" id="nsd-url" value="{{.Settings.NSD.IGWBaseURL}}" placeholder="автозаполнится по профилю">
<label>Ключевой контейнер</label>
<input type="text" name="key_container" id="nsd-container" value="{{.Settings.NSD.KeyContainer}}" placeholder="GUEST_GOST_CONTAINER (или ваш контейнер УЦ НРД)" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
</div>
<p class="muted">При смене профиля URL ONYX автозаполнится по таблице НРД (из <code>DOC/Ссылки для доступа в тестовые контуры.pdf</code>). При сохранении проверяется доступность URL.</p>
<button type="submit" class="btn" style="background:var(--accent);color:white;border:none;padding:8px 16px;border-radius:4px">Сохранить и проверить</button>
</form>
<script>
// Автозаполнение URL ONYX и дефолтного контейнера по выбранному профилю.
(function() {
var urls = {
"guest-gost": ["https://gost-gt.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "GUEST_GOST_CONTAINER"],
"guest-rsa": ["https://rsa-gt.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "GUEST_RSA_CONTAINER"],
"test3-gost": ["https://gost-t3.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "TEST3_GOST_CONTAINER"],
"test3-rsa": ["https://rsa-t3.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "TEST3_RSA_CONTAINER"],
"prod-gost": ["https://gost.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "PROD_GOST_CONTAINER"],
"prod-rsa": ["https://rsa.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "PROD_RSA_CONTAINER"]
};
var profile = document.getElementById("nsd-profile");
var urlInput = document.getElementById("nsd-url");
var contInput = document.getElementById("nsd-container");
profile.addEventListener("change", function() {
var p = profile.value;
if (urls[p]) {
if (!urlInput.value || confirm("Заменить URL и контейнер на дефолт для профиля " + p + "?")) {
urlInput.value = urls[p][0];
contInput.value = urls[p][1];
}
}
});
})();
</script>
</details>
</div>
<input type="text" name="key_container" id="nsd-container" value="{{.Settings.NSD.KeyContainer}}" placeholder="напр. TEST3_GOST_CONTAINER">
<hr style="border:none;border-top:1px solid var(--border);margin:6px 0">
<p class="muted" style="margin:0">Депозитарные реквизиты (откуда списываются бумаги) — из договора/письма НРД. Нужны для формирования заявки на перевод.</p>
<label>Депозитарный код</label>
<input type="text" name="deponent_code" value="{{.Settings.NSD.DeponentCode}}" placeholder="напр. MC0413600000">
<label>Депозитарный счёт</label>
<input type="text" name="account_id" value="{{.Settings.NSD.AccountID}}" placeholder="депозитарный счёт">
<label>Раздел счёта</label>
<input type="text" name="section_id" value="{{.Settings.NSD.SectionID}}" placeholder="раздел депозитарного счёта">
<div><button type="submit" class="btn">Сохранить и проверить</button></div>
</form>
<script>
(function() {
var urls = {
"guest-gost": ["https://gost-gt.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "GUEST_GOST_CONTAINER"],
"guest-rsa": ["https://rsa-gt.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "GUEST_RSA_CONTAINER"],
"test3-gost": ["https://gost-t3.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "TEST3_GOST_CONTAINER"],
"test3-rsa": ["https://rsa-t3.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "TEST3_RSA_CONTAINER"],
"prod-gost": ["https://gost.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "PROD_GOST_CONTAINER"],
"prod-rsa": ["https://rsa.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "PROD_RSA_CONTAINER"]
};
var p = document.getElementById("nsd-profile"), u = document.getElementById("nsd-url"), c = document.getElementById("nsd-container");
if (p) p.addEventListener("change", function() {
var v = p.value;
if (urls[v] && (!u.value || confirm("Заменить URL и контейнер на дефолт для " + v + "?"))) { u.value = urls[v][0]; c.value = urls[v][1]; }
});
})();
</script>
</div>
<!-- LK callback -->
<div class="card">
<h2><span class="dot {{if .Settings.LK.CallbackURL}}ok{{else}}err{{end}}"></span>Callback в ЛК</h2>
<p class="muted">{{if .Settings.LK.CallbackURL}}Callback URL: <code>{{.Settings.LK.CallbackURL}}</code>{{else}}Сейчас используется встроенный lk-emulator (он сам зарегистрировал свой адрес при старте).{{end}}</p>
<details {{if not .Settings.LK.CallbackURL}}open{{end}}>
<summary style="cursor:pointer;color:var(--accent);font-size:13px">Указать URL реального ЛК</summary>
<form method="post" action="/admin/setup/lk" style="margin-top:12px">
<div class="form-row" style="display:grid;grid-template-columns:200px 1fr;gap:12px;align-items:center">
<div class="card">
<h2><span class="dot {{if .Settings.LK.CallbackURL}}ok{{else}}warn{{end}}"></span>Callback в личный кабинет <span class="muted" style="font-size:12px;font-weight:400">(необязательно)</span></h2>
<p class="muted">{{if .Settings.LK.CallbackURL}}<code>{{.Settings.LK.CallbackURL}}</code>{{else}}Не настроен — уведомления в ЛК отключены. Для работы с НРД не требуется.{{end}}</p>
<form method="post" action="/admin/setup/lk" style="margin-top:12px;display:grid;gap:10px;max-width:640px">
<label>Callback URL</label>
<input type="text" name="callback_url" value="{{.Settings.LK.CallbackURL}}" placeholder="http://lk.example.com" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
<input type="text" name="callback_url" value="{{.Settings.LK.CallbackURL}}" placeholder="http://lk.example.com">
<div><button type="submit" class="btn">Сохранить и проверить</button></div>
</form>
</div>
</section>
{{/* ============ ТЕСТЫ ============ */}}
<section class="settings-section" id="sec-tests">
<h1>Тесты</h1>
<div class="card">
<h2>Тестовый пакет роботу НРД</h2>
<p class="muted">Робот <code>MC0012500000</code> эмулирует вторую сторону перевода. Выберите сценарий — bj-server отправит эталонный запрос через ИШ, ответ придёт во входящие. Требуется настроенный ИШ + профиль Валидаты.</p>
<form method="post" action="/admin/setup/test-nsd" style="margin-top:12px;display:flex;gap:8px;flex-wrap:wrap;align-items:center">
<select name="scenario">
<option value="2001">2001 — Принять все бумаги</option>
<option value="2002">2002 — Принять частично</option>
<option value="1111">1111 — Ответ с отказом</option>
<option value="3333">3333 — Робот принимающая сторона</option>
</select>
<button type="submit" class="btn">Отправить роботу</button>
</form>
<p class="muted" style="margin-top:6px;font-size:12px">Ответ робота — асинхронно (~30-60 сек) во входящие ИШ.</p>
</div>
<div class="card">
<h2>Сквозной тестовый прогон (mock/реальный)</h2>
<p class="muted">Заявка с предзаполненными данными через всю цепочку до финального статуса.</p>
<form method="post" action="/admin/setup/test-run" style="margin-top:12px"><button type="submit" class="btn">Запустить тестовую заявку</button></form>
{{if .Settings.LastTest}}
<div style="margin-top:16px;padding:14px;background:var(--card-2);border:1px solid var(--border);border-radius:10px">
<strong>Последний прогон</strong>
<table style="margin-top:8px">
<tbody>
<tr><td style="width:160px" class="muted">Статус</td><td>{{if .Settings.LastTest.OK}}<span style="color:var(--ok)">✓ успешно</span>{{else}}<span style="color:var(--err)">✗ не прошёл</span>{{end}}</td></tr>
<tr><td class="muted">FSM-статус</td><td><code>{{.Settings.LastTest.FinalStatus}}</code></td></tr>
<tr><td class="muted">ClaimID</td><td><code>{{.Settings.LastTest.ClaimID}}</code> {{if .Settings.LastTest.ClaimID}}<a href="/admin/claims/{{.Settings.LastTest.ClaimID}}">→ карточка</a>{{end}}</td></tr>
<tr><td class="muted">Сообщение</td><td>{{.Settings.LastTest.Message}}</td></tr>
</tbody>
</table>
</div>
<p class="muted" style="margin-top:8px">URL до базового хоста ЛК (без /api). При сохранении выполняется GET <code>{URL}/healthz</code>.</p>
<button type="submit" class="btn" style="background:var(--accent);color:white;border:none;padding:8px 16px;border-radius:4px;margin-top:8px">Сохранить и проверить</button>
</form>
</details>
{{end}}
</div>
</section>
{{/* ============ ОБНОВЛЕНИЯ ============ */}}
<section class="settings-section" id="sec-update">
<h1>Обновления</h1>
<div class="card">
<h2>Версия bj-server</h2>
<div style="display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin-top:6px">
<span class="stat-value" style="font-size:20px">{{.CurrentVersion}}</span>
{{if .Settings.Update.Available}}
{{if ne .Settings.Update.Available .CurrentVersion}}
<span class="hero-status warn">● Доступна {{.Settings.Update.Available}}</span>
<form method="post" action="/admin/setup/update/apply" style="margin:0" onsubmit="return confirm('Скачать и установить {{.Settings.Update.Available}}? bj-server перезапустится.');">
<button type="submit" class="btn btn-ok">⬆ Установить {{.Settings.Update.Available}}</button>
</form>
{{else}}<span class="hero-status ok">● Актуальная версия</span>{{end}}
{{end}}
<form method="post" action="/admin/setup/update/check" style="margin:0"><button type="submit" class="btn btn-secondary">Проверить обновления</button></form>
</div>
{{if .Settings.Update.Notes}}<p class="muted" style="margin-top:10px">Что нового: {{.Settings.Update.Notes}}</p>{{end}}
{{if not .Settings.Update.LastCheck.IsZero}}<p class="muted" style="margin-top:6px;font-size:12px">Последняя проверка: {{.Settings.Update.LastCheck.Format "02.01.2006 15:04"}} — {{.Settings.Update.LastResult}}</p>{{end}}
{{if and .License.Present (not .License.AllowsUpdates)}}<p class="muted" style="margin-top:6px;font-size:12px;color:var(--warn)">⚠ Текущий план «{{.License.Plan}}» не включает обновления.</p>{{end}}
</div>
<div class="card">
<h2><span class="dot {{if .Settings.Update.BaseURL}}ok{{else}}warn{{end}}"></span>Источник обновлений</h2>
<p class="muted">Артефактория раздаёт подписанные релизы. Обновления проверяются по подписи Ed25519 и sha256 — без валидной подписи установка не выполняется.</p>
<form method="post" action="/admin/setup/update" style="margin-top:12px;display:grid;gap:10px;max-width:680px">
<label>URL артефактории</label>
<input type="text" name="base_url" value="{{.Settings.Update.BaseURL}}" placeholder="https://updates.example.com">
<label>Канal</label>
<select name="channel">
<option value="stable" {{if eq .Settings.Update.Channel "stable"}}selected{{end}}>stable — стабильный</option>
<option value="beta" {{if eq .Settings.Update.Channel "beta"}}selected{{end}}>beta — предварительный</option>
</select>
<label>Публичный ключ издателя (base64 Ed25519)</label>
<input type="text" name="public_key" value="{{.Settings.Update.PublicKey}}" placeholder="зашит в релиз; переопределить здесь">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer"><input type="checkbox" name="auto_check" {{if .Settings.Update.AutoCheck}}checked{{end}} style="width:auto"> Проверять автоматически (раз в 6 часов)</label>
<div><button type="submit" class="btn">Сохранить</button></div>
</form>
</div>
</section>
{{/* ============ ЛИЦЕНЗИЯ ============ */}}
<section class="settings-section" id="sec-license">
<h1>Лицензия</h1>
<div class="card">
<h2>
{{if .License.Valid}}<span class="dot ok"></span>Активна
{{else if .License.Present}}<span class="dot err"></span>Недействительна
{{else}}<span class="dot warn"></span>Не активирована{{end}}
</h2>
{{if .License.Present}}
<table style="margin-top:8px">
<tbody>
{{if .License.Tenant}}<tr><td style="width:180px" class="muted">Организация</td><td><strong>{{.License.Tenant}}</strong></td></tr>{{end}}
{{if .License.Plan}}<tr><td class="muted">План</td><td><span class="badge confirmed">{{.License.Plan}}</span></td></tr>{{end}}
{{if not .License.ExpiresAt.IsZero}}<tr><td class="muted">Действует до</td><td>{{.License.ExpiresAt.Format "02.01.2006"}} {{if .License.Valid}}<span class="muted">(осталось {{.License.DaysLeft}} дн.)</span>{{end}}</td></tr>{{end}}
<tr><td class="muted">Обновления</td><td>{{if .License.AllowsUpdates}}<span style="color:var(--ok)">включены</span>{{else}}<span class="muted">не входят в план</span>{{end}}</td></tr>
<tr><td class="muted">Статус</td><td>{{.License.Message}}</td></tr>
</tbody>
</table>
{{else}}
<p class="muted">Лицензионный ключ не введён. Без лицензии сервис работает, но автообновления заблокированы.</p>
{{end}}
</div>
<div class="card">
<h2>Активация</h2>
<p class="muted">Вставьте лицензионный ключ, полученный от поставщика. Проверка офлайн по подписи — связь с сервером лицензий не требуется.</p>
<form method="post" action="/admin/setup/license" style="margin-top:12px;display:grid;gap:10px;max-width:720px">
<label>Лицензионный ключ</label>
<textarea name="key" rows="3" style="font-family:ui-monospace,monospace;font-size:11px" placeholder="payload.signature.keyid">{{.Settings.License.Key}}</textarea>
<details>
<summary style="cursor:pointer;color:var(--accent);font-size:13px">Публичный ключ издателя (если не зашит)</summary>
<input type="text" name="public_key" value="{{.Settings.License.PublicKey}}" placeholder="base64 Ed25519" style="margin-top:8px;width:100%">
</details>
<div><button type="submit" class="btn">Активировать</button></div>
</form>
</div>
</section>
</div>
</div>
<!-- Тестовый прогон -->
<div class="card">
<h2>Тестовый прогон сквозной заявки</h2>
<p class="muted">Создаст заявку с предзаполненными данными (инвестор Иванов И.И., 1500 акций Газпрома, ИИС T03), отправит её через всю цепочку и дождётся финального статуса. Если ИШ НРД настроен — пойдёт в реальный ИШ; иначе через mock с задержкой 3 сек.</p>
<form method="post" action="/admin/setup/test-run" style="margin-top:12px">
<button type="submit" class="btn" style="background:var(--accent);color:white;border:none;padding:10px 20px;border-radius:4px;font-weight:600">Запустить тестовую заявку</button>
</form>
{{if .Settings.LastTest}}
<div style="margin-top:16px;padding:12px;background:var(--bg);border:1px solid var(--border);border-radius:4px">
<strong>Последний прогон:</strong>
<table style="margin-top:8px">
<tr><td style="width:160px" class="muted">Статус</td><td>{{if .Settings.LastTest.OK}}<span style="color:var(--ok)">✓ успешно</span>{{else}}<span style="color:var(--err)">✗ не прошёл</span>{{end}}</td></tr>
<tr><td class="muted">Финальный FSM-статус</td><td><code>{{.Settings.LastTest.FinalStatus}}</code></td></tr>
<tr><td class="muted">ClaimID</td><td><code>{{.Settings.LastTest.ClaimID}}</code> {{if .Settings.LastTest.ClaimID}}<a href="/admin/claims/{{.Settings.LastTest.ClaimID}}">→ открыть карточку</a>{{end}}</td></tr>
<tr><td class="muted">Когда</td><td>{{.Settings.LastTest.StartedAt.Format "02.01.2006 15:04:05"}} — длительность {{.Settings.LastTest.FinishedAt.Sub .Settings.LastTest.StartedAt}}</td></tr>
<tr><td class="muted">Сообщение</td><td>{{.Settings.LastTest.Message}}</td></tr>
</table>
</div>
{{end}}
</div>
<script>
(function() {
// Переключение разделов админ-центра + запоминание выбранного (hash).
var navs = document.querySelectorAll('.settings-nav button');
var secs = document.querySelectorAll('.settings-section');
function show(id) {
navs.forEach(function(b){ b.classList.toggle('active', b.dataset.sec === id); });
secs.forEach(function(s){ s.classList.toggle('active', s.id === 'sec-' + id); });
try { history.replaceState(null, '', '#' + id); } catch(e) {}
}
navs.forEach(function(b){ b.addEventListener('click', function(){ show(b.dataset.sec); }); });
var h = (location.hash || '').replace('#','');
if (h && document.getElementById('sec-' + h)) show(h);
})();
</script>
{{end}}
@@ -22,7 +22,7 @@
<h2>Что подключается на следующих этапах</h2>
<table>
<tr><td class="muted" style="width:240px">PostgreSQL (схема m2m_core)</td><td>M2-шаг-3: pgx-репозиторий вместо MemoryRepository. Миграция готова — <code>migrations/m2m-core/001__deals.sql</code>.</td></tr>
<tr><td class="muted">crypto-service · КриптоПро JCP</td><td>M4: положить <code>jcp.jar</code> в <code>services/crypto-service/libs/</code>, выставить <code>BJ_CRYPTO_PROVIDER=cryptopro</code>, заполнить keystore профиля. Проверка — gRPC Health должна вернуть <code>provider=cryptopro, ok=true</code>.</td></tr>
<tr><td class="muted">crypto-service · Валидата PKCS#11</td><td>M4: установить АПК «Валидата Клиент L» (<code>zpki</code>, <code>zsdk</code>), выставить <code>BJ_CRYPTO_PROVIDER=validata</code> и путь к <code>libvdpkcs11.so</code>. Проверка — Health PKCS#11 должна вернуть <code>provider=validata, ok=true</code>.</td></tr>
<tr><td class="muted">nsd-adapter · ИШ НРД</td><td>M3: установить ИШ, выставить <code>BJ_NSD_PROFILE=guest-gost</code> или иной, <code>BJ_NSD_IGW_URL=http://localhost:8080</code>. Без этого сейчас используется <code>nsdadapter/mock</code> с эмуляцией ответов через 3 сек.</td></tr>
<tr><td class="muted">Реальный ЛК (ESIA Finance)</td><td>M4: согласовать <code>docs/lk-contract/v1/openapi.yaml</code> с командой ЛК, выставить <code>BJ_LK_CALLBACK_URL</code> на реальный адрес. Сейчас callback идёт в встроенный lk-emulator.</td></tr>
</table>
@@ -1,48 +1,42 @@
{{define "content"}}
<style>
.wizard-progress { display:flex; gap:6px; margin-bottom:24px; }
.wizard-step { flex:1; padding:12px 8px; border-radius:6px; background:var(--card); border:1px solid var(--border); text-align:center; position:relative; }
.wizard-step.done { background:rgba(63,191,108,0.12); border-color:var(--ok); }
.wizard-step.current { background:rgba(91,157,255,0.15); border-color:var(--accent); }
.wizard-step-num { display:block; font-size:11px; color:var(--muted); margin-bottom:4px; }
.wizard-step-name { font-size:13px; font-weight:600; }
.wizard-step.done .wizard-step-num::after { content:" ✓"; color:var(--ok); }
.wiz-head { text-align:center; padding:8px 0 4px; }
.wiz-head h1 { font-size:26px; font-weight:720; letter-spacing:-0.02em; margin:0 0 6px; }
.wiz-head p { color:var(--muted); margin:0; }
.wizard-progress { display:flex; align-items:center; justify-content:center; gap:0; margin:24px auto 28px; max-width:680px; }
.wstep { display:flex; flex-direction:column; align-items:center; gap:7px; flex:1; position:relative; }
.wstep .bub { width:34px; height:34px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:14px; font-weight:650; background:var(--card-2); border:1.5px solid var(--border-strong); color:var(--muted); z-index:1; transition:all .2s; }
.wstep .lbl { font-size:12px; color:var(--muted); font-weight:550; }
.wstep::before { content:""; position:absolute; top:17px; left:-50%; width:100%; height:2px; background:var(--border); z-index:0; }
.wstep:first-child::before { display:none; }
.wstep.done .bub { background:var(--ok); border-color:var(--ok); color:#fff; }
.wstep.done .lbl { color:var(--text-2); }
.wstep.done::before { background:var(--ok); }
.wstep.current .bub { background:var(--accent); border-color:var(--accent); color:#fff; box-shadow:0 0 0 4px var(--accent-weak); }
.wstep.current .lbl { color:var(--accent); font-weight:650; }
.tooltip { display:inline-block; background:var(--border); color:var(--muted); border-radius:50%; width:16px; height:16px; line-height:16px; text-align:center; font-size:11px; cursor:help; margin-left:4px; }
.where { font-size:12px; color:var(--accent); margin-left:8px; }
.help-block { background:rgba(91,157,255,0.07); border-left:3px solid var(--accent); padding:10px 14px; margin:10px 0; font-size:13px; }
.help-block { background:var(--accent-weak); border-left:3px solid var(--accent); padding:10px 14px; margin:10px 0; font-size:13px; border-radius:0 8px 8px 0; }
.help-block strong { color:var(--accent); }
.wiz-card { max-width:680px; margin:0 auto; }
</style>
<div class="card">
<h2>Мастер настройки</h2>
<p class="muted">Пошаговая настройка системы. Подходит для первого запуска. После каждого шага состояние сохраняется и можно вернуться позже.</p>
<div class="wiz-head">
<h1>Настройка Bridge&amp;Join</h1>
<p>Пошаговый мастер первого запуска — состояние сохраняется после каждого шага</p>
</div>
<div class="wizard-progress">
<div class="wizard-step {{if .Done.Postgres}}done{{end}} {{if eq .Step 1}}current{{end}}">
<span class="wizard-step-num">Шаг 1</span>
<span class="wizard-step-name">PostgreSQL</span>
</div>
<div class="wizard-step {{if .Done.Crypto}}done{{end}} {{if eq .Step 2}}current{{end}}">
<span class="wizard-step-num">Шаг 2</span>
<span class="wizard-step-name">КриптоПро / Рутокен</span>
</div>
<div class="wizard-step {{if .Done.Certs}}done{{end}} {{if eq .Step 3}}current{{end}}">
<span class="wizard-step-num">Шаг 3</span>
<span class="wizard-step-name">Сертификаты</span>
</div>
<div class="wizard-step {{if .Done.NSD}}done{{end}} {{if eq .Step 4}}current{{end}}">
<span class="wizard-step-num">Шаг 4</span>
<span class="wizard-step-name">Шлюз НРД</span>
</div>
<div class="wizard-step {{if .Done.TestRun}}done{{end}} {{if eq .Step 5}}current{{end}}">
<span class="wizard-step-num">Шаг 5</span>
<span class="wizard-step-name">Тестовая заявка</span>
</div>
<div class="wstep {{if .Done.Postgres}}done{{end}} {{if eq .Step 1}}current{{end}}"><span class="bub">{{if .Done.Postgres}}✓{{else}}1{{end}}</span><span class="lbl">База</span></div>
<div class="wstep {{if .Done.Crypto}}done{{end}} {{if eq .Step 2}}current{{end}}"><span class="bub">{{if .Done.Crypto}}✓{{else}}2{{end}}</span><span class="lbl">Валидата</span></div>
<div class="wstep {{if .Done.Certs}}done{{end}} {{if eq .Step 3}}current{{end}}"><span class="bub">{{if .Done.Certs}}✓{{else}}3{{end}}</span><span class="lbl">Сертификаты</span></div>
<div class="wstep {{if .Done.NSD}}done{{end}} {{if eq .Step 4}}current{{end}}"><span class="bub">{{if .Done.NSD}}✓{{else}}4{{end}}</span><span class="lbl">Шлюз НРД</span></div>
<div class="wstep {{if .Done.TestRun}}done{{end}} {{if eq .Step 5}}current{{end}}"><span class="bub">{{if .Done.TestRun}}✓{{else}}5{{end}}</span><span class="lbl">Проверка</span></div>
</div>
{{if .Flash}}<div class="card" style="border-left:3px solid var(--accent)"><p style="margin:0">{{.Flash}}</p></div>{{end}}
{{if .Flash}}<div class="card wiz-card" style="border-left:3px solid var(--accent)"><p style="margin:0">{{.Flash}}</p></div>{{end}}
<div class="wiz-card">
{{/* ============= ШАГ 1: PostgreSQL ============= */}}
{{if eq .Step 1}}
<div class="card">
@@ -82,60 +76,35 @@
</div>
{{end}}
{{/* ============= ШАГ 2: Крипто ============= */}}
{{/* ============= ШАГ 2: Крипто (Валидата) ============= */}}
{{if eq .Step 2}}
<div class="card">
<h2><span class="dot {{if .Done.Crypto}}ok{{else}}err{{end}}"></span>Шаг 2. Крипто-провайдер (КриптоПро CSP или Рутокен)</h2>
<h2><span class="dot {{if .Done.Crypto}}ok{{else}}err{{end}}"></span>Шаг 2. СКЗИ «Валидата Клиент L»</h2>
<p>СКЗИ нужен для подписи XMLDSig и проверки квитанций НРД.</p>
<div class="help-block">
<strong>Что это?</strong> КриптоПро CSP — российский криптопровайдер с поддержкой ГОСТ Р 34.10-2012. Рутокен ЭЦП 2.0 — USB-токен для безопасного хранения ключей. Можно использовать оба: CSP — для серверной части, Рутокен — для подписи действий оператора.<br>
<strong>Где взять?</strong> Дистрибутив КриптоПро CSP 5.0 R4<a href="https://www.cryptopro.ru/products/csp/downloads" target="_blank">cryptopro.ru/products/csp/downloads</a> (нужна регистрация в личном кабинете). Лицензия — там же или у дилера. Демо-лицензия на 3 месяца встроена в дистрибутив.
<strong>Что это?</strong> АПК «Валидата Клиент L» — российское СКЗИ с поддержкой ГОСТ Р 34.10-2012, поставляемое НРД для подключения к ЭДО. На Linux работает напрямую через PKCS#11 — отдельной лицензии и регистрационных данных <em>не требует</em>.<br>
<strong>Где взять?</strong> Дистрибутив для Astra Linux SE<a href="https://fs.moex.com/cdp/po/ClientL_ALSE.zip" target="_blank">fs.moex.com/cdp/po/ClientL_ALSE.zip</a>. Установка — <code>sudo dpkg -i zpki-*.deb zsdk-*.deb</code> (см. <a href="/admin/help/crypto">/admin/help/crypto</a>).
</div>
{{if not .CryptoProInstalled}}
<div style="background:var(--bg);border:1px solid var(--accent);border-radius:6px;padding:14px;margin-top:12px">
<h3 style="margin:0 0 8px 0;font-size:15px">Шаг 2a — загрузить и установить КриптоПро CSP</h3>
<p class="muted" style="margin:0 0 10px 0">Скачайте с <code>cryptopro.ru</code> архив <code>linux-amd64.tgz</code> или <code>linux-amd64.tar</code> (КриптоПро CSP 5.0 R4 для Linux) и загрузите его сюда. Bj-server сам распакует и установит нужные пакеты.</p>
<form method="post" action="/admin/setup/crypto/install" enctype="multipart/form-data" style="margin:0">
<input type="file" name="dist" accept=".tar,.tgz,.tar.gz,.rpm" required style="margin-right:8px">
<button type="submit" class="btn">Загрузить и установить</button>
</form>
</div>
{{else}}
<p style="color:var(--ok);margin-top:12px">✓ КриптоПро CSP установлен. Версия: <code>{{.CryptoProVersion}}</code></p>
{{end}}
<details style="margin-top:14px" {{if not .Done.Crypto}}open{{end}}>
<summary style="cursor:pointer;color:var(--accent)">Шаг 2b — указать провайдер и путь к PKCS#11 модулю</summary>
<summary style="cursor:pointer;color:var(--accent)">Параметры провайдера</summary>
<form method="post" action="/admin/setup/crypto" style="margin-top:12px;display:grid;gap:10px">
<div>
<label>Провайдер <span class="tooltip" title="cryptopro — КриптоПро CSP, rutoken — Рутокен ЭЦП 2.0 через драйверы CSP, stub — без криптографии (демо-режим без подписи)">?</span></label>
<label>Провайдер <span class="tooltip" title="validata — АПК «Валидата Клиент L»; stub — без криптографии (демо-режим без подписи)">?</span></label>
<select name="provider" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;width:100%">
<option value="stub" {{if eq .Settings.Crypto.Provider "stub"}}selected{{end}}>stub — без криптографии (демо)</option>
<option value="cryptopro" {{if eq .Settings.Crypto.Provider "cryptopro"}}selected{{end}}>КриптоПро CSP (серверная подпись, ключи на диске)</option>
<option value="rutoken" {{if eq .Settings.Crypto.Provider "rutoken"}}selected{{end}}>Рутокен ЭЦП 2.0 (подпись оператора)</option>
<option value="validata" {{if eq .Settings.Crypto.Provider "validata"}}selected{{end}}>Валидата Клиент L</option>
</select>
</div>
<div>
<label>Путь к модулю PKCS#11 <span class="tooltip" title="Файл libcppkcs11.so входит в пакет lsb-cprocsp-pkcs11-64. После установки КриптоПро CSP он находится в /opt/cprocsp/lib/amd64/">?</span></label>
<input type="text" name="jcp_path" value="{{if .Settings.Crypto.JCPPath}}{{.Settings.Crypto.JCPPath}}{{else}}/opt/cprocsp/lib/amd64/libcppkcs11.so{{end}}" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;width:100%">
<label>Путь к модулю PKCS#11 <span class="tooltip" title="После установки пакета zpki модуль находится в /opt/Validata/VDCSP/lib/amd64/libvdpkcs11.so">?</span></label>
<input type="text" name="module_path" value="{{if .Settings.Crypto.ModulePath}}{{.Settings.Crypto.ModulePath}}{{else}}/opt/Validata/VDCSP/lib/amd64/libvdpkcs11.so{{end}}" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;width:100%">
</div>
<button type="submit" class="btn">Сохранить</button>
</form>
</details>
{{if and .Done.Crypto (not .Settings.Crypto.LicenseKey)}}
<details open style="margin-top:14px">
<summary style="cursor:pointer;color:var(--accent)">Шаг 2c — активировать лицензию (если демо не подходит)</summary>
<form method="post" action="/admin/setup/crypto/activate" style="margin-top:12px">
<label>Серийный номер лицензии КриптоПро <span class="tooltip" title="Формат XXXXX-XXXXX-XXXXX-XXXXX-XXXXX. Выдаётся при покупке лицензии. Демо-лицензия на 3 месяца встроена в дистрибутив — её активировать не нужно.">?</span></label>
<input type="text" name="license" placeholder="XXXXX-XXXXX-XXXXX-XXXXX-XXXXX" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;width:100%;margin-top:6px">
<button type="submit" class="btn" style="margin-top:8px">Активировать</button>
</form>
</details>
{{end}}
<div style="margin-top:20px;display:flex;justify-content:space-between">
<a href="/admin/wizard?step=1" class="btn" style="background:var(--card);text-decoration:none">К шагу 1</a>
{{if .Done.Crypto}}<a href="/admin/wizard?step=3" class="btn" style="text-decoration:none">К шагу 3 →</a>{{else}}<a href="/admin/wizard?step=3&skip=crypto" class="btn" style="background:var(--card);text-decoration:none">Пропустить →</a>{{end}}
@@ -156,72 +125,17 @@
<li>В режиме <strong>ИШ НРД</strong>: подписывает <em>сам ИШ</em> — наш ключ настраивается <em>в ИШ</em>, не здесь. Bj-server нужен только для проверки квитанций НРД и (опц.) расшифровки 4BROKER01.</li>
<li>В режиме <strong>прямого ONYX без ИШ</strong>: bj-server подписывает сам — нужен наш ключ с приватной частью.</li>
</ul>
<strong>Что куда загружать (по режиму):</strong>
<table style="margin-top:6px;font-size:13px">
<thead><tr><th>Что</th><th>Зачем</th><th>Куда</th></tr></thead>
<tbody>
<tr><td>Корневой сертификат <strong>УЦ МБ</strong> (<a href="https://ca.moex.com/" target="_blank">ca.moex.com</a>)</td><td>проверка цепочки нашей подписи и подписей контрагентов</td><td><code>mroot</code></td></tr>
<tr><td>Корневой и подписной <strong>УЦ НРД</strong> (<a href="https://www.nsd.ru/workflow/system/cryptography/" target="_blank">nsd.ru/workflow/system/cryptography/</a>)</td><td>проверка квитанций от НРД</td><td><code>mroot</code> + <code>uRoot</code></td></tr>
<tr><td>Наш сертификат + ключ <em>(только если без ИШ)</em></td><td>подпись отправляемых пакетов + расшифровка 4BROKER01</td><td><code>uMy</code> — с приватным ключом</td></tr>
<tr><td>Сертификаты с Рутокена</td><td>сами появятся в таблице ниже после подключения USB</td><td>не грузить</td></tr>
</tbody>
</table>
<strong>Что куда загружать:</strong>
<ul style="margin:6px 0 6px 16px">
<li>Корневой сертификат <strong>УЦ МБ</strong> (<a href="https://ca.moex.com/" target="_blank">ca.moex.com</a>) — для проверки цепочки нашей подписи и подписей контрагентов.</li>
<li>Корневой и подписной <strong>УЦ НРД</strong> (<a href="https://www.nsd.ru/workflow/system/cryptography/" target="_blank">nsd.ru/workflow/system/cryptography/</a>) — для проверки квитанций от НРД.</li>
<li>Наш сертификат с приватным ключом <em>(только если без ИШ)</em> — для подписи пакетов и расшифровки 4BROKER01.</li>
</ul>
<p class="muted" style="margin-top:6px">Полный регламент PKI — в «Правилах ЭДО НРД» и «Руководстве по установке ИШ» (<a href="https://www.nsd.ru/ru/documents/workflow/" target="_blank">nsd.ru/ru/documents/workflow/</a>) — в наших PDF этого не описано.</p>
</div>
<h3 style="margin-top:18px">Импорт сертификата</h3>
<form method="post" action="/admin/setup/crypto/import-cert" enctype="multipart/form-data" style="margin-top:8px;display:grid;gap:8px;grid-template-columns:1fr 1fr 1fr auto;align-items:end">
<div>
<label class="muted" style="font-size:12px">Файл</label>
<input type="file" name="cert" accept=".cer,.crt,.pfx,.p12" required style="width:100%">
</div>
<div>
<label class="muted" style="font-size:12px">Хранилище</label>
<select name="store" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;width:100%">
<option value="uMy">uMy — мой (с приватным ключом)</option>
<option value="mroot">mroot — корневой УЦ</option>
<option value="uRoot">uRoot — промежуточный УЦ</option>
<option value="uCA">uCA — сертификаты УЦ НРД</option>
</select>
</div>
<div>
<label class="muted" style="font-size:12px">PIN (для .pfx)</label>
<input type="password" name="pin" placeholder="опц." style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;width:100%">
</div>
<button type="submit" class="btn">Импортировать</button>
</form>
<h3 style="margin-top:18px">Контейнеры на подключённых носителях (флешка/Рутокен)</h3>
{{if .FlashContainers}}
<p class="muted">Найдено {{len .FlashContainers}} контейнер(а) формата <code>name.000</code> на смонтированных USB-носителях. Нажмите «Скопировать в локальное хранилище» — папка будет перенесена в <code>/var/opt/cprocsp/keys/$USER/</code>, после чего контейнер виден как <code>\\.\HDIMAGE\name</code> и работает даже без вставленной флешки.</p>
<table style="margin-top:8px">
<thead><tr><th>Носитель</th><th>Имя контейнера</th><th>Файлы</th><th>Статус</th><th></th></tr></thead>
<tbody>
{{range .FlashContainers}}
<tr>
<td><code style="font-size:12px">{{.Mountpoint}}</code></td>
<td><strong>{{.Name}}</strong></td>
<td><span class="muted" style="font-size:11px">{{len .Files}} файлов</span></td>
<td>{{if .AlreadyImported}}<span style="color:var(--ok)">уже в HDIMAGE</span>{{else}}<span class="muted">только на флешке</span>{{end}}</td>
<td>
{{if not .AlreadyImported}}
<form method="post" action="/admin/setup/crypto/copy-container" style="margin:0">
<input type="hidden" name="src" value="{{.Path}}">
<button type="submit" class="btn" style="background:var(--ok);padding:6px 12px;font-size:12px">Скопировать в локальное хранилище</button>
</form>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
<p class="muted" style="margin-top:8px">После копирования: импортировать сертификат из контейнера командой <code>certmgr -inst -cont '\\.\HDIMAGE\{имя}' -store uMy</code> — это пропишет сертификат в видимое хранилище. (UI-кнопку для этого добавим следующим шагом.)</p>
{{else}}
<p class="muted">Подключённые USB-носители с контейнерами КриптоПро формата <code>name.000</code> не обнаружены. Поиск идёт в <code>/run/media/$USER/</code>, <code>/media/$USER/</code>, <code>/media/</code>, <code>/mnt/</code>. Вставьте флешку с контейнером и обновите страницу — контейнер появится в этой таблице автоматически.</p>
{{end}}
<h3 style="margin-top:18px">Авто-загрузка сертификатов УЦ НРД</h3>
<p class="muted">Самый простой способ — добавить прямые URL <code>.cer</code>-файлов УЦ НРД (с <a href="https://www.nsd.ru/workflow/system/cryptography/" target="_blank" rel="noopener">nsd.ru/workflow/system/cryptography/</a>) и включить авто-обновление. Раз в сутки система перепроверит и переустановит изменённые сертификаты.</p>
<p class="muted">Самый простой способ — добавить прямые URL <code>.cer</code>-файлов УЦ НРД (с <a href="https://www.nsd.ru/workflow/system/cryptography/" target="_blank" rel="noopener">nsd.ru/workflow/system/cryptography/</a>) и включить авто-обновление. Раз в сутки система перепроверит и сохранит изменённые сертификаты в <code>/var/lib/bj/ca-certs/</code>.</p>
<form method="post" action="/admin/setup/cacerts" style="margin-top:8px;display:grid;gap:10px">
<textarea name="urls" rows="3" placeholder="https://www.nsd.ru/path/to/root-ca.cer&#10;https://www.nsd.ru/path/to/sub-ca.cer" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;font-family:monospace;font-size:12px">{{range .Settings.CACerts.URLs}}{{.}}
{{end}}</textarea>
@@ -292,8 +206,8 @@
<input type="text" name="igw_url" id="nsd-url" value="{{.Settings.NSD.IGWBaseURL}}" placeholder="https://gost-t3.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;width:100%">
</div>
<div>
<label>Ключевой контейнер НРД <span class="tooltip" title="Имя контейнера КриптоПро с ключами ЭДО НРД (выдаются УЦ НРД). Формат: \\.\HDIMAGE\нрд-имя или нрд-имя.000">?</span></label>
<input type="text" name="key_container" value="{{.Settings.NSD.KeyContainer}}" placeholder="\\.\HDIMAGE\nrd-edo" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;width:100%">
<label>Ключевой контейнер НРД <span class="tooltip" title="Имя контейнера Валидаты с ключами ЭДО НРД (выдаются УЦ НРД)">?</span></label>
<input type="text" name="key_container" value="{{.Settings.NSD.KeyContainer}}" placeholder="nrd-edo" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;width:100%">
</div>
<button type="submit" class="btn" style="justify-self:start">Сохранить</button>
</form>
@@ -359,4 +273,5 @@
</div>
{{end}}
</div>
{{end}}

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