12 Commits

Author SHA1 Message Date
fontvielle 5440ebe152 docs: CLAUDE.md — гайд для агента Claude Code при работе в репозитории
Файл автоматически читается Claude Code при старте сессии. Содержит:
- что это за проект (M2M-переводы через MOEX МОСТ НРД)
- текущее состояние + ссылку на REPORT.md
- архитектуру обмена с НРД (bj-server → ИШ → ONYX)
- структуру каталогов и описание DOC/
- неочевидные решения (КриптоПро CSP, не JCP; PKCS#11; mock-робот;
  Astra Linux для ИШ; globalRC в admin.go; noProxyClient в news.go)
- команды для разработки и запуска
- описание дев-стендов (10.10.10.22 РЕД ОС, 10.10.10.27 Astra)
- правила (REPORT.md держать актуальным, UI на русском, без emoji
  если не просили)
- что НЕ делать (РЕД ОС для ИШ, JCP вместо CSP, выпуск новых сертов)

Это handoff для нового агента — на Astra Linux 10.10.10.27 или на
любой другой ВМ — чтобы он не открывал контекст с нуля.
2026-05-18 13:08:23 +03:00
fontvielle 6e503433d4 refactor(deploy): «одна команда — всё работает» для нового стенда
Переосмысление установщика по фидбеку: установщик должен делать чистую
установку «с нуля», а не миграцию. От пользователя требуется одна
команда и больше ничего:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Mastered tasks: #41, #42, #43.
2026-05-14 15:46:31 +03:00
42 changed files with 4511 additions and 120 deletions
+10
View File
@@ -1,6 +1,7 @@
# Сборки
/bin/
/dist/
!/dist/ish/README.md
*.exe
*.test
*.out
@@ -58,3 +59,12 @@ test-results/
# macOS
.DS_Store
# Doc-watcher: бэкапы при переустановке свежих версий
DOC/*.pdf.bak
DOC/*.bak.pdf
# Дистрибутив ИШ НРД (большой, ~120 МБ) — не коммитим в git
/dist/ish/*.deb
/dist/ish/*.SGN
/dist/ish/*.exe
+149
View File
@@ -0,0 +1,149 @@
# Bridge-and-Join-s — гайд для агента Claude Code
Этот файл читается автоматически при старте `claude` в репозитории. Здесь — сжатый контекст проекта чтобы агент не вычислял всё заново.
## Что это за проект
**Bridge-and-Join-s** — система M2M-переводов ценных бумаг между депозитариями через сервис **MOEX МОСТ** (НКО АО НРД). Целевая интеграция — сервис M2M НРД на TEST3, далее PROD.
**Объём**: 100-1000 сделок в день — сознательно облегчили архитектуру под этот объём.
**Один бинарник** `bj-server` (cmd/bj-server) вместо изначально планировавшихся микросервисов. Внутри — пакеты:
- `internal/m2m` — XSD-модели всех сообщений (M2MTransferRequest/Decision/Response)
- `internal/m2mcore` — стейт-машина заявок, репозиторий (memory + pgx)
- `internal/lkgateway` — REST API, веб-админка, lk-emulator, мастер настройки
- `internal/nsdadapter` — два режима: `mock` (внутренний робот-эмулятор) и `igw` (REST-клиент Интеграционного шлюза НРД)
- `internal/cryptocli` — PKCS#11 клиент к СКЗИ (КриптоПро CSP / Рутокен / Валидата)
- `internal/nsdxml` — XML кодек с CP-1251
**Веб-админка**: `http://<ip>:8080/admin/` — главное место общения с системой. Разделы: Дашборд / Мастер настройки / Настройка / Заявки / Статус / Новости / Инструкции. Всё на русском.
## Текущее состояние (актуально на 2026-05-18)
См. **`REPORT.md` в корне репо** — там полный статус с процентами. Кратко:
- ✅ Все компоненты на нашей стороне написаны и оттестированы (~75% общая готовность)
- ✅ REST-клиент ИШ НРД готов (`internal/nsdadapter/igw/` + тесты)
- ✅ Эмулятор робота MOEX МОСТ (4 сценария) работает
- ✅ Установщик одной командой `deploy/astra/install.sh` готов для Astra/Debian/Ubuntu
- ⏳ Заблокировано на внешнем: дистрибутив Валидата CSP (запрос в `soed@nsd.ru`), сертификат УЦ МБ (`ca.moex.com`), регистрация в TEST3
- ⚠️ Окно техработ TEST3 закрыто (18-22.05.2026 — сейчас 18.05, активно)
## Архитектура обмена с НРД (если кратко)
```
bj-server (наше Go-приложение)
│ REST: POST /api/package/{channel}/file (ZIP в base64)
ИШ (igate, ставится на Astra Linux рядом)
│ ИШ САМ: подписывает, упаковывает, отправляет
│ Использует СКЗИ Валидата CSP + сертификат УЦ МБ
Web-сервис ONYX (НРД, https://gost.nsd.ru/onyxt3/WslService)
```
Подробная схема и FAQ — в `/admin/help/architecture` (когда сервис запущен) и в `DOC/ruk_install_ish_2025_11_10.pdf`.
## Где что лежит
| Каталог | Что |
|---|---|
| `cmd/bj-server/` | главный бинарник |
| `cmd/lk-emulator/` | эмулятор ЛК ESIA для разработки |
| `internal/` | вся бизнес-логика, поделена на пакеты |
| `migrations/` | SQL миграции: `fansy-store/` (входные данные от Fansy) и `m2m-core/` (журнал сделок) |
| `deploy/astra/` | установщик одной командой + healthcheck |
| `deploy/systemd/` | systemd unit (используется install.sh) |
| `deploy/docker-compose/` | docker-compose для PostgreSQL в podman |
| `DOC/` | вся документация НРД (PDF — около 15 файлов, см. ниже) |
| `dist/ish/` | дистрибутив ИШ НРД (~120 МБ .deb, не в git) |
| `docs/` | наша внутренняя документация (контракты Fansy и т.п.) |
| `REPORT.md` | отчёт для руководства — **держим в актуальном виде** |
## Документация НРД в DOC/
- `Инструккия M2M.pdf` — главный документ по M2M-обмену (схемы, протоколы)
- `instr-ish-rest-api.pdf` — REST API ИШ (на нём основан `internal/nsdadapter/igw`)
- `ruk_install_ish_2025_11_10.pdf` — Руководство по установке ИШ (нужны Astra Linux + Валидата CSP)
- `ruk_pol_ish.pdf` — Руководство пользователя ИШ
- `QA_ish.pdf`, `test-case_ish.pdf` — FAQ и тест-кейсы
- `instruktsiya-po-testirovaniyu-s-robotom.pdf` — робот-автотест на TEST3 (код `MC0012500000`, 4 сценария)
- `servis-most-m2m.pdf` — обзор сервиса MOEX МОСТ
- `Ссылки для доступа в тестовые контуры.pdf` — URL'ы GUEST/TEST3/PROD контуров НРД
## Решения, которые мы приняли (не очевидные)
- **КриптоПро CSP**, а не JCP — экономия ~50 000 ₽ лицензии. Используется для подписи действий оператора в админке через Рутокен. Для отправки в НРД подпись делает ИШ (своей Валидатой) — наш bj-server для этого не нужен
- **PKCS#11 как единый интерфейс** к разным СКЗИ — пакет `internal/cryptocli` через `github.com/miekg/pkcs11`
- **Один бинарник** вместо микросервисов — для нашего объёма проще, никаких микросервисных издержек
- **Mock-робот внутри bj-server** — позволяет тестить логику без живого НРД. Активируется кодом `MC0012500000` в `ReceiverCode` + `DocumentSeries` 1111/2001/2002/3333
- **Astra Linux для ИШ** — единственная поддерживаемая ОС от НРД (РЕД ОС не пойдёт). Дев — Astra CE (бесплатная), прод — Astra SE (платная)
- **`globalRC` в `admin.go`** — глобальная переменная RC для шаблонов; компромисс между чистотой и шумностью передачи `*RuntimeConfig` через все хендлеры
- **doc-watcher с явным `noProxyClient`** в `news.go` — игнорирует ENV-прокси, потому что zetit блокирует nsd.ru
## Как запускать
**Локально (для разработки)**:
```bash
go build -o ./bin/bj-server ./cmd/bj-server
LD_LIBRARY_PATH=/opt/cprocsp/lib/amd64 ./bin/bj-server # путь нужен только если есть КриптоПро
```
**Production-стиль через systemd** (после `deploy/astra/install.sh`):
```bash
systemctl status bj-server
journalctl -u bj-server -f
```
**Установить с нуля на свежей Astra/Debian/Ubuntu** — одна команда:
```bash
curl -sSL https://git.zetit.ru/zuevav/Bridge-and-Join-s/raw/main/deploy/astra/install.sh | sudo bash
```
## Окружение разработки
- **Дев-стенд №1**: РЕД ОС 8 на `10.10.10.22` — историческая ВМ, КриптоПро CSP установлен, есть PostgreSQL в podman, bj-server и lk-emulator работают
- **Дев-стенд №2**: Astra Linux SE 1.7 на `10.10.10.27` — будущий основной стенд (потому что Astra нужна для ИШ). Поднята 14.05.2026, базовая dev-среда настроена (Node 20, claude-code, tmux, прокси `claude-fna`)
- **Прокси zetit** `fna.zetit.ru:3128` — только для Claude Code (alias `claude-fna`). Всё остальное (go modules, apt, nsd.ru) идёт напрямую
- **Git remote**: `https://git.zetit.ru/zuevav/Bridge-and-Join-s.git` (Gitea на zetit)
## Команды и подсказки агенту
**После любого значимого изменения** — обновляй `REPORT.md` в том же коммите. Это правило (см. `feedback_report_md_keep_updated.md` в памяти). REPORT.md — «живая» отчётность для руководства, пользователь должен в любой момент открыть и показать.
**Прокси при `go build`** — выключай ENV-прокси, у нас всё ходит мимо zetit:
```bash
env -u HTTPS_PROXY -u HTTP_PROXY -u https_proxy -u http_proxy go build ...
```
**bj-server в фоне** — нужен `setsid` иначе умирает с shell:
```bash
LD_LIBRARY_PATH=/opt/cprocsp/lib/amd64 setsid ./bin/bj-server > /tmp/bj.log 2>&1 < /dev/null & disown
```
**Cwd важно** — после `cd DOC/` или подобного go build от `./cmd/bj-server` упадёт. Всегда возвращайся в `/home/fontvielle/Bridge-and-Join-s` (или `/opt/bj/src` на Astra).
**Все UI-надписи — на русском** (требование заказчика). Исключения только для программных терминов (PostgreSQL, REST, JSON, и т.п.).
**Не создавай PDF-документы и md-отчёты без явного запроса.** REPORT.md — единственный, его поддерживаем актуальным.
**Релизные коммиты** — заголовок в Conventional Commits (`feat(igw): ...`, `fix(admin): ...`), русский body. См. предыдущие коммиты `git log --oneline`.
## Контакты НРД (если что-то нужно по проекту)
- `M2MOST@nsd.ru` — форматы M2M
- `soed@nsd.ru` — дистрибутивы (Валидата, ИШ)
- `pki@moex.com` — УЦ МБ, сертификаты
## Что делать НЕ надо
- Не пытайся ставить ИШ на РЕД ОС — он только под Astra (.deb пакет)
- Не предлагай переход на КриптоПро JCP — пользователь явно его отверг по цене
- Не пытайся выпускать новые сертификаты — у организации они уже есть, наша задача только импортировать
- Не убирай UI-баннер «РЕЖИМ ЭМУЛЯЦИИ» — он защищает от случайной отправки в прод
- Не используй прокси zetit для go-модулей или для запросов к nsd.ru — будет 403
---
**Спросить пользователя, если непонятно** — не стесняйся. Лучше задать вопрос чем сделать неверное предположение.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+254
View File
@@ -0,0 +1,254 @@
# Bridge-and-Join-s — отчёт о ходе работ
**Дата:** 14.05.2026 (3-я редакция за день — скачан дистрибутив ИШ + полная документация ИШ)
**Контур:** дев-стенд РЕД ОС 8 (10.10.10.22), bj-server на :8080, lk-emulator на :8083
**Целевая интеграция:** сервис MOEX МОСТ (M2M) через НКО АО НРД
---
## Готовность по областям
| Область | Готовность | Статус |
|---|---:|---|
| Контракты и модели M2M (XSD → Go) | **100%** | ✅ Готово |
| Журнал сделок (PostgreSQL + in-memory) | **100%** | ✅ Готово |
| Бизнес-логика FSM (стейт-машина заявок) | **100%** | ✅ Готово |
| Веб-интерфейс администратора | **95%** | ✅ Готово |
| Мастер настройки (wizard) для оператора | **100%** | ✅ Готово |
| Установка и конфигурация КриптоПро CSP через UI | **100%** | ✅ Готово |
| Авто-загрузка сертификатов УЦ (мониторинг + ежесуточное обновление) | **100%** | ✅ Готово |
| Контейнеры КриптоПро с флешки (импорт в HDIMAGE) | **80%** | ⚠ Без UI-импорта сертификата из контейнера |
| Лента новостей + мониторинг сайта НРД (doc-watcher) | **100%** | ✅ Готово |
| Эмулятор робота-автотеста НРД (внутренний mock) | **90%** | ⚠ Сценарий 3333 — частично |
| Реальное подключение к роботу на TEST3 НРД | **30%** | ⚠ REST-клиент ИШ готов, ждём сам ИШ + сертификат |
| REST-клиент ИШ НРД (по DOC/instr-ish-rest-api.pdf) | **100%** | ✅ POST file, GET status, GET list, GET package, упаковщик ZIP, 10/10 тестов |
| Дистрибутив ИШ НРД и полная документация | **100%** | ✅ Скачаны: `igate_100.0-765_amd64.deb` (117 МБ) + 6 PDF |
| Установка ИШ на наш стенд | **30%** | ⚠ Скрипты установки готовы, ждём Astra Linux ВМ от инфра-команды |
| Авто-установщик «одной командой» | **100%** | ✅ `curl … \| sudo bash` на свежей Astra/Debian/Ubuntu — bj-server + БД + ИШ через 5-10 мин |
| Получение СКЗИ «Валидата CSP» для Linux | **0%** | ⏳ Запрос в soed@nsd.ru / pki@moex.com — см. блокер #2 |
| Сертификат УЦ Московской Биржи для подписи | **0%** | ⏳ Не получен — см. блокер #3 |
| Подключение реального ЛК ESIA Finance | **20%** | ⚠ Эмулятор lk-emulator работает, реальный URL не указан |
| Контракт с Fansy (ETL) | **30%** | ⚠ Контракт документирован, ETL не реализован стороной Fansy |
| Уведомления (e-mail, мессенджеры) | **0%** | ⏳ M3-M4 |
| Тесты, CI/CD | **40%** | ⚠ Unit-тесты компонентов, нет E2E против реального НРД |
**Общая готовность системы:** **≈ 75%** (по объёму функциональности)
**Готовность к интеграционному тесту с роботом:** **≈ 88%** (зависит только от внешних факторов: Astra Linux ВМ, Валидата CSP, сертификат УЦ МБ — на нашей стороне установщик готов)
---
## Что сделано (28 коммитов)
### Архитектура и ядро (M1)
- Реализованы Go-модели всех M2M-сообщений (M2MTransferRequest, Response, Decision, History, Movement) с валидацией.
- Стейт-машина обработки заявок (FSM): `draft → validated → submitted_to_nsd → awaiting_decision → confirmed/rejected/timed_out → done` + ветка ручного разбора.
- Один исполняемый бинарник `bj-server` (вместо запланированных микросервисов) — рассчитано на нагрузку до 1000 сделок/день.
- Хранилище: PostgreSQL 16 в контейнере podman (один клик «Поднять автоматически» в UI), миграции для двух схем — `fansy.*` (данные от Fansy) и `m2m_core.*` (журнал сделок). Fallback на in-memory для дева.
### Криптография
- Переход с КриптоПро JCP (~82 000₽, Java) на КриптоПро CSP (~30-50 000₽, нативный) — экономия лицензии в ~2 раза. Подходит для нашего объёма (100-1000 сделок/день).
- Go-клиент к СКЗИ через стандартный PKCS#11 интерфейс (`internal/cryptocli`). Один клиент работает с КриптоПро CSP, Рутокен ЭЦП 2.0, Валидата, ViPNet — меняется только путь к .so модулю.
- UI-кнопка «Загрузить дистрибутив КриптоПро»: загружаешь tar/tgz/rpm, система сама распаковывает и устанавливает через `sudo rpm -Uvh`.
- Активация лицензии через UI (`cpconfig -license -set` под капотом).
- Импорт сертификатов (.pfx/.p12 с PIN, .cer/.crt без) в хранилища `uMy`/`mroot`/`uRoot` через `certmgr -inst`.
- **Авто-обнаружение контейнеров КриптоПро на USB-флешках** (формат `name.000`): сканирует `/run/media/$USER/`, `/media/`, `/mnt/`; кнопка «Скопировать в локальное хранилище» переносит контейнер в `/var/opt/cprocsp/keys/$USER/`.
- **Авто-обнаружение сертификатов на Рутокене ЭЦП 2.0** — список заполняется автоматически после подключения токена в USB.
### Сертификаты УЦ
- Авто-загрузка корневых и подписных сертификатов УЦ по списку URL: SHA-256 дедуп, импорт в `mroot`/`uRoot` через `certmgr`.
- Ежесуточная фоновая горутина обновляет сертификаты, в ленте новостей появляется уведомление «Обновлён сертификат УЦ: <CN>».
- За 14 дней до истечения сертификата — отдельное предупреждение в ленте.
### Веб-интерфейс администратора (порт 8080)
6 разделов меню:
- **Дашборд** — счётчики сделок, состояние подсистем, последние заявки, блок «Новости» сверху.
- **Мастер настройки** — пошаговая настройка (5 шагов) с прогресс-баром, подсказки «?» и «Где взять?» рядом с каждым полем.
- **Настройка** — расширенные параметры всех подсистем.
- **Заявки** — журнал + карточка заявки с историей FSM.
- **Статус системы** — health-check всех подсистем.
- **Инструкции** — 5 help-страниц: БД, API ЛК, КриптоПро, Внешние системы, **Тестирование с роботом**.
- **Новости** — лента событий + кнопка «Проверить обновления документации НРД сейчас».
Все надписи на русском.
### Мониторинг НРД (doc-watcher)
- Раз в сутки скачивает страницы с сайта НРД, парсит ссылки на PDF, обновляет файлы в `DOC/` (старые версии переименовываются в `.YYYY-MM-DD.pdf.bak` для аудита).
- Каждое обновление публикуется как новость в ленту.
- Уже скачаны три свежие инструкции от 12.05.2026:
- `instruktsiya-po-testirovaniyu-s-robotom.pdf` — инструкция по роботу-автотесту
- `instruktsiya-...-fizicheskim-litsom-samomu-sebe.pdf` — обмен при self-transfer
- `servis-most-m2m.pdf` — обзор сервиса
### Робот-автотест MOEX МОСТ
- Реализован **внутренний робот-эмулятор**: bj-server понимает код робота `MC0012500000` и 4 тестовых сценария (1111/2001/2002/3333) через DocumentSeries.
- Это позволяет проверить нашу логику обработки ответов **до того**, как у нас появится реальный ИШ + сертификат + доступ к TEST3.
- Help-страница `/admin/help/robot` с полной документацией (коды ошибок M2M01-M2M09, тестовые наборы депозитариев, схема обмена).
- Когда подключим реальный ИШ — переключение прозрачное, те же заявки пойдут на реальный TEST3.
### REST-клиент ИШ НРД (готов на нашей стороне)
- По свежей спецификации НРД (`DOC/instr-ish-rest-api.pdf`) реализован Go-клиент в `internal/nsdadapter/igw`:
- `POST /api/package/{channel}/file` — отправка ZIP (Type=archive, File=base64)
- `GET /api/package/status/{id}` — статус: NEW / SENT / ERROR
- `GET /api/package?channel=&type=M2MTD&...` — список входящих от НРД
- `GET /api/package/{id}` — скачать ZIP пакета (поддерживает и raw ZIP, и base64-в-JSON)
- Упаковщик (`pack.go`): `M2MTransferRequest → ZIP (XML + config.xml)` по разделу 2.3 инструкции
- Распаковщик: ZIP → DocXML + winf.xml + .sgn (отсоединённая подпись НРД)
- Парсеры: `ParseDecision`, `ParseResponse` — из XML в Go-структуры через `nsdxml.Unmarshal`
- Покрыто тестами: 10/10 PASS (httptest + zip round-trip + 4xx без ретраев + retry на 5xx)
- Готов к переключению: как только получим живой ИШ от НРД, нужно только указать BaseURL и Channel в `/admin/setup` — код уже всё умеет
### Авто-установщик «одной командой» (14.05.2026, поздний вечер)
Главная цель — оператор без знания Linux должен поднять систему **одной командой**:
```bash
curl -sSL https://git.zetit.ru/zuevav/Bridge-and-Join-s/raw/main/deploy/astra/install.sh | sudo bash
```
Через 5-10 минут на свежей Astra Linux / Debian / Ubuntu ВМ работает веб-админка на :8080. Установщик `deploy/astra/install.sh`:
1. **Определяет ОС** — Astra SE/CE, Debian, Ubuntu (с предупреждениями для несовместимых)
2. **Ставит зависимости через apt** — podman, postgresql-client, git, curl
3. **Скачивает Go 1.24+** с go.dev (~70 МБ)
4. **Создаёт пользователя bj** и каталоги /opt/bj /var/lib/bj /var/log/bj
5. **Клонирует репо** в /opt/bj/src
6. **Собирает bj-server** через go build
7. **Поднимает PostgreSQL 16** в podman-контейнере, накатывает миграции
8. **Кладёт systemd unit** с безопасными ограничениями (NoNewPrivileges, ProtectSystem=strict, ReadWritePaths)
9. **Скачивает ИШ НРД** (~120 МБ) с `old.nsd.ru` и пытается установить через `dpkg -i`
10. **Печатает понятную сводку** с URL'ами и списком того, что осталось руками
Дополнительные скрипты в `deploy/astra/`:
- **`install-validata.sh`** — установка СКЗИ Валидата CSP когда придёт от НРД. Если дистрибутива ещё нет — печатает готовый текст письма для запроса в `soed@nsd.ru`
- **`install-ish.sh`** — ручная установка ИШ из локального .deb (если автоскачивание не сработало)
- **`healthcheck.sh`** — цветной отчёт о работоспособности всех 8 компонентов (ОС, пользователь, systemd, HTTP, PostgreSQL, Валидата, ИШ, сетевые порты)
- **`import-data.sh`** — опциональный экспорт БД и настроек со старой ВМ (если переезжаем с действующего стенда)
- **`README.md`** — TL;DR + полный путь от чистой ВМ до прохождения теста с роботом MOEX МОСТ (10 этапов, оценочно 2-3 недели от старта)
После запуска `install.sh` остаётся 3 ручных шага (НРД и УЦ МБ — без них никак): запрос Валидаты, получение сертификата УЦ МБ, заявка на TEST3.
### Дистрибутив ИШ и полная документация (получены 14.05.2026)
По наводке от заказчика на странице `https://www.nsd.ru/workflow/system/programs/web-service/` найдены и скачаны все официальные материалы:
- **Дистрибутив ИШ Linux**: `dist/ish/igate_100.0-765_amd64.deb` (117 МБ, для Astra Linux)
- **Электронная подпись к дистрибутиву**: `dist/ish/igate_95.0-716_amd64.SGN`
- **DOC/ruk_install_ish_2025_11_10.pdf** (4.7 МБ) — Руководство по установке ИШ от 10.11.2025. Главное:
- Поддерживаемые ОС: Windows 10/Server, **Astra Linux SE 1.6/1.7** (РЕД ОС не упомянута)
- СКЗИ: **«Валидата CSP» + АПК «Валидата Клиент L»** (НЕ КриптоПро)
- БД: SQLite или PostgreSQL (PostgreSQL обязателен для REST API)
- Только ГОСТ-криптография под Linux (RSA — только Windows)
- Только сертификаты от УЦ МБ
- **DOC/ruk_pol_ish.pdf** (3.5 МБ) — Руководство пользователя ИШ
- **DOC/QA_ish.pdf** (2.5 МБ) — Q&A
- **DOC/test-case_ish.pdf** (1.3 МБ) — Тест-кейсы для проверки работоспособности ИШ
- **DOC/instr_int_sh_01072025.pdf** (0.4 МБ) — Инструкция по созданию заявки на тестирование
- **DOC/web_service_nrd_standard_soap_rest.pdf** (2.2 МБ) — Техрекомендации Web-сервиса ONYX
`dist/ish/.deb` не коммитится в git (большой), но `dist/ish/README.md` содержит все ссылки на повторное скачивание.
### Безопасность и надёжность
- Баннер «🟡 РЕЖИМ ЭМУЛЯЦИИ» отображается на каждой странице админки пока не настроен ИШ или СКЗИ — оператор не сможет случайно принять mock-результат за реальный.
- Контекстная навигация после действий (после POST возврат на ту же страницу, не в /admin/setup).
- HTTP-клиенты для запросов на nsd.ru/cryptopro.ru идут напрямую (игнорируют корпоративный прокси), браузерный User-Agent для обхода антибот-фильтров.
- systemd-unit `deploy/systemd/bj-server.service` с `Environment=LD_LIBRARY_PATH=/opt/cprocsp/lib/amd64`, ProtectSystem=strict, NoNewPrivileges, и т.п.
---
## Что в процессе и в очереди
### Внешние блокеры (без них не двинемся к реальному НРД)
1. **Astra Linux ВМ для ИШ** ⭐ новый блокер
- Дистрибутив ИШ — только `igate_100.0-765_amd64.deb` (под Astra Linux SE 1.6/1.7). РЕД ОС официально не поддерживается, RPM-версии нет.
- Что нужно: поднять отдельную Astra Linux ВМ (10.10.10.23?) или попробовать запустить ИШ в Docker-контейнере с Astra-образом.
- Альтернативы: Windows 10/Server (есть .exe-дистрибутив, но это шаг назад от Linux).
- Срок: зависит от инфра-команды; ~1 день поднять ВМ + ~30 мин установить ИШ.
2. **СКЗИ «Валидата CSP» + АПК «Валидата Клиент L»** ⭐ новый блокер
- ИШ требует именно Валидату, **НЕ КриптоПро CSP** (у нас стоит КриптоПро, придётся ставить параллельно или вместо).
- Где взять: только по запросу — `soed@nsd.ru` (НРД) или `pki@moex.com` (МБ). Временная лицензия выдаётся.
- Что нужно: отправить письмо с реквизитами организации и обоснованием (подключение к MOEX МОСТ M2M).
- Срок: ~1-3 дня на ответ НРД.
3. **Сертификат подписи УЦ Московской Биржи** (ca.moex.com)
- Нужен для подписи отправляемых сообщений. Кладётся в Справочник сертификатов АПК Валидата Клиент L, экспортируется в системное хранилище.
- Что нужно: оформить заявку в УЦ МБ от организации, получить сертификат + приватный ключ.
- Срок: зависит от УЦ МБ.
4. **Заявка на тестирование в TEST3 НРД**
- Форма: `https://www.nsd.ru/workflow/zayavka-na-testirovanie/`
- Инструкция: `DOC/instr_int_sh_01072025.pdf`
- Получаем код депонента-тестера и доступ к контурам GUEST/TEST3.
5. **Сертификаты УЦ НРД** (для проверки квитанций)
- Где взять: `https://www.nsd.ru/workflow/system/cryptography/` — сейчас отдаёт 404 на нашем дев-стенде (вероятно перенесено в ЛК НРД депонента).
- В коде уже есть форма «Авто-загрузка сертификатов УЦ» в `/admin/setup` — как только получим прямые URL .cer, добавим их.
6. **Окно техработ TEST3: 18.05.2026 — 22.05.2026**
- Полевое тестирование в этот период невозможно. Реальные прогоны — до 18-го или после 22-го мая.
7. **Доступ к API реального ЛК ESIA Finance**
- Сейчас bj-server работает с встроенным эмулятором `lk-emulator` на :8083.
- Что нужно: URL продакшен/тест ЛК, Basic-auth учётка.
### Внутренние задачи (можем делать параллельно)
| Задача | Приоритет | Эффект |
|---|---|---|
| Завершить сценарий 3333 робота — приёмная сторона bj-server (входящие M2MTransferRequest) | средний | Полное покрытие тестов с роботом |
| UI для импорта сертификата из контейнера КриптоПро (после копирования с флешки) | низкий | Сейчас делается вручную через certmgr |
| Уведомления: SMTP, Yandex Messenger, Telegram (плагины через единый интерфейс Notifier) | средний (M3) | Операторам — критичные события в мессенджеры |
| Расширение тестов: unit + интеграционные с mock-роботом, нагрузочные | низкий | Уверенность перед прод |
| Документация для команды Fansy (ETL): передача контракта, согласование SLA, прописывание IP в pg_hba.conf | средний | Запуск ETL-потока |
---
## Что после получения ИШ и сертификата (план первичного тестирования с роботом)
1. **День 0** (получили дистрибутив ИШ + сертификат + руководство)
- Поставить ИШ на dev-ВМ.
- Положить сертификат в ИШ-хранилище.
- В bj-server: `/admin/setup` → ИШ профиль `test3-gost`, URL ИШ.
2. **День 1** (smoke-тест)
- Отправить через bj-server заявку с ReceiverCode = `MC0012500000`, DocumentSeries = `2001`, DocumentNumber = `111111` → ожидаем «принять все бумаги» от робота.
- Проверить: квитанция от НРД, Decision от робота, callback в `lk-emulator`, статус в журнале → `confirmed`.
3. **День 2-3** (полное покрытие сценариев)
- 1111 (отказ M2M01..M2M09) — все коды ошибок.
- 2001 / 2002 — все депозитарии, все варианты частичного приёма.
- 3333 — встречный перевод (когда доделаем приёмную сторону).
4. **День 4-5** (нагрузка)
- 50-100 одновременных заявок, проверка очередей, БД, корректность статусов.
5. **День 6** (живой контрагент)
- Согласовать с любым подключённым к НРД депозитарием тестовый обмен.
- Это последний шаг перед присоединением к Правилам ЭДО НРД (продакшен).
**Реалистичный срок от получения ИШ до готовности к продакшену: 2-3 недели** (включая обкатку, fix багов, документацию).
---
## Стоимостная сводка
| Статья | Сумма (руб) | Статус |
|---|---:|---|
| Лицензия КриптоПро CSP (сервер) | ~30 000-50 000 | Демо 3 мес. — активна |
| Лицензия КриптоПро CSP (рабочее место оператора, опц.) | ~2 000-3 000 | Не куплена |
| Рутокен ЭЦП 2.0 для оператора (железо, опц.) | ~3 000-5 000 | Не куплено |
| Сертификат УЦ МБ для организации | по тарифам УЦ | Не получен |
| **Сэкономлено** против КриптоПро JCP | ~30 000-50 000 | (отказ от Java-стека) |
---
## Ключевые архитектурные решения
1. **Один бинарник вместо микросервисов** — рассчитано на наш объём (100-1000 сделок/день). Упрощает деплой, отладку, мониторинг. Все компоненты в одном процессе с понятными границами пакетов (`internal/m2mcore`, `internal/nsdadapter`, `internal/lkgateway`, ...).
2. **PKCS#11 как единый интерфейс к СКЗИ** — позволяет менять провайдер (CSP/Рутокен/Валидата) без изменения кода bj-server.
3. **Двух-уровневая БД** (`fansy.*` для входных данных, `m2m_core.*` для журнала сделок) — позволяет команде Fansy писать в свою схему без знания о нашем pipeline.
4. **Mock-робот внутри bj-server** — даёт возможность работать без живого НРД для значительной части интеграционного тестирования.
5. **«Дружественный» UI** — установка/настройка не требует SSH-доступа: всё через веб (КриптоПро, лицензии, сертификаты, контейнеры с флешки).
---
**Готов к интеграционному тестированию с роботом на TEST3:** да, как только будет ИШ + сертификат.
**Готов к продакшену:** ориентировочно через 3-4 недели после получения всех внешних компонентов.
— Команда разработки Bridge-and-Join-s
+9 -4
View File
@@ -110,15 +110,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()
+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
+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
+86 -5
View File
@@ -20,7 +20,8 @@ var templatesFS embed.FS
// {{define "content"}} в разных файлах.
type admin struct {
home, claims, claim, status, setup *template.Template
help, helpDatabase, helpLK, helpCryptoPro, helpSystems *template.Template
help, helpDatabase, helpLK, helpCryptoPro, helpSystems, helpRobot, helpArchitecture *template.Template
wizard, news *template.Template
}
// templateFuncs — функции, доступные внутри шаблонов. Главная задача —
@@ -30,6 +31,7 @@ var templateFuncs = template.FuncMap{
"ru": russianText,
"ruState": russianState,
"ruOutcome": russianOutcome,
"now": time.Now,
}
// russianState переводит технический FSM-state в человекочитаемый
@@ -121,9 +123,27 @@ func newAdmin() (*admin, error) {
if err != nil {
return nil, fmt.Errorf("parse admin_help_systems: %w", err)
}
wizard, err := parse("admin_wizard.html")
if err != nil {
return nil, fmt.Errorf("parse admin_wizard: %w", err)
}
news, err := parse("admin_news.html")
if err != nil {
return nil, fmt.Errorf("parse admin_news: %w", err)
}
helpRobot, err := parse("admin_help_robot.html")
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)
}
return &admin{
home: home, claims: claims, claim: claim, status: status, setup: setup,
help: help, helpDatabase: helpDB, helpLK: helpLK, helpCryptoPro: helpCP, helpSystems: helpSys,
helpRobot: helpRobot, helpArchitecture: helpArch,
wizard: wizard, news: news,
}, nil
}
@@ -132,8 +152,15 @@ type page struct {
Title string
Active string
Now string
IsMockMode bool // true если ИШ не настроен — bj-server в режиме эмуляции
MockReason string // короткое описание почему mock
}
// globalRC — ссылка на runtime-конфиг для template-funcs/page helpers.
// Заполняется один раз в RegisterAdmin. Альтернатива — таскать rc через
// все renderXxx-функции, что шумно при широком фан-ауте.
var globalRC *RuntimeConfig
// homeData — данные дашборда.
type homeData struct {
page
@@ -145,6 +172,7 @@ type homeData struct {
Failed int
}
Recent []ClaimView
News []NewsItem // top-3 активных или свежих новостей
}
// claimsData — данные журнала.
@@ -169,17 +197,18 @@ type statusData struct {
// RegisterAdmin вешает HTML-маршруты /admin/* на mux. Возвращает admin
// со всеми загруженными шаблонами — вызывающий может прокинуть его в
// registerSetup для добавления вкладки «Настройка».
func RegisterAdmin(mux *http.ServeMux, svc *Service, getOpts func() CheckOptions) (*admin, error) {
func RegisterAdmin(mux *http.ServeMux, svc *Service, rc *RuntimeConfig, getOpts func() CheckOptions) (*admin, error) {
a, err := newAdmin()
if err != nil {
return nil, err
}
globalRC = rc
mux.HandleFunc("/admin/", func(w http.ResponseWriter, r *http.Request) {
p := strings.TrimPrefix(r.URL.Path, "/admin/")
switch {
case p == "" || p == "index" || p == "home":
a.renderHome(w, r, svc, getOpts())
a.renderHome(w, r, svc, rc, getOpts())
case p == "claims":
a.renderClaims(w, r, svc)
case strings.HasPrefix(p, "claims/"):
@@ -197,6 +226,10 @@ func RegisterAdmin(mux *http.ServeMux, svc *Service, getOpts func() CheckOptions
render(w, a.helpCryptoPro, 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)
}
@@ -207,7 +240,7 @@ func RegisterAdmin(mux *http.ServeMux, svc *Service, getOpts func() CheckOptions
return a, nil
}
func (a *admin) renderHome(w http.ResponseWriter, r *http.Request, svc *Service, opts CheckOptions) {
func (a *admin) renderHome(w http.ResponseWriter, r *http.Request, svc *Service, rc *RuntimeConfig, opts CheckOptions) {
ctx := r.Context()
status := CheckAll(ctx, opts)
recent, err := svc.ListClaims(ctx, m2mcore.Filter{Limit: 10})
@@ -219,6 +252,7 @@ func (a *admin) renderHome(w http.ResponseWriter, r *http.Request, svc *Service,
page: nowPage("Дашборд", "home"),
Status: status,
Recent: recent.Items,
News: topNews(rc.Snapshot().News.Items, 3),
}
full, err := svc.ListClaims(ctx, m2mcore.Filter{Limit: 200})
if err == nil {
@@ -271,5 +305,52 @@ func render(w http.ResponseWriter, t *template.Template, data any) {
}
func nowPage(title, active string) page {
return page{Title: title, Active: active, Now: time.Now().Format("02.01.2006 15:04:05")}
p := page{Title: title, Active: active, Now: time.Now().Format("02.01.2006 15:04:05")}
if globalRC != nil {
s := globalRC.Snapshot()
switch {
case s.NSD.IGWBaseURL == "":
p.IsMockMode = true
p.MockReason = "ИШ НРД не настроен — заявки идут через внутренний mock (Decision эмитируется через 3 сек)"
case s.Crypto.Provider == "" || s.Crypto.Provider == "stub":
p.IsMockMode = true
p.MockReason = "Провайдер СКЗИ = stub — подпись не делается, реальный обмен с НРД невозможен"
}
}
return p
}
// topNews отбирает максимум N новостей: сначала те, что активны прямо сейчас
// (по ValidFrom..ValidTo), потом просто свежие. Скрытые (Dismissed) — мимо.
func topNews(items []NewsItem, n int) []NewsItem {
now := time.Now()
var active, rest []NewsItem
for _, it := range items {
if it.Dismissed {
continue
}
isActive := !it.ValidFrom.IsZero() && !it.ValidTo.IsZero() &&
now.After(it.ValidFrom) && now.Before(it.ValidTo)
// «Будущие» окна с ValidFrom в будущем тоже считаем актуальными
// (предупредить заранее).
isUpcoming := !it.ValidFrom.IsZero() && now.Before(it.ValidFrom) &&
it.ValidFrom.Sub(now) < 7*24*time.Hour
if isActive || isUpcoming {
active = append(active, it)
} else {
rest = append(rest, it)
}
}
out := active
if len(out) < n {
need := n - len(out)
if need > len(rest) {
need = len(rest)
}
out = append(out, rest[:need]...)
}
if len(out) > n {
out = out[:n]
}
return out
}
+318
View File
@@ -0,0 +1,318 @@
package lkgateway
import (
"context"
"crypto/sha256"
"crypto/x509"
"encoding/hex"
"encoding/pem"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// defaultNSDCAURLs — список URL для авто-загрузки сертификатов УЦ НРД.
// Эти URL пользователь может скорректировать в /admin/setup → «Сертификаты
// УЦ» (раздел появляется после первого сохранения настроек). На сайте НРД
// (www.nsd.ru/workflow/system/cryptography/) сертификаты выложены в виде
// .cer файлов — нужно скопировать их прямые URL сюда.
//
// По умолчанию список пустой, потому что прямые URL у НРД меняются от
// релиза к релизу и должны быть проверены оператором перед использованием.
var defaultNSDCAURLs = []string{
// https://www.nsd.ru/workflow/system/cryptography/ — раскомментируйте
// нужные ссылки в UI после того, как уточните URL у НРД.
}
// FetchCACertificates скачивает все URL из настроек, парсит .cer, и при
// успехе вызывает certmgr -inst -store mroot. Если передан rc — на каждое
// фактическое изменение сертификата (новый или изменился SHA-256)
// публикуется новость в ленту через rc.AddNews. На сертификаты,
// истекающие в ближайшие 14 дней — отдельная новость-предупреждение.
func FetchCACertificates(ctx context.Context, s CACertsSettings, rc *RuntimeConfig) (CACertsSettings, string) {
if len(s.URLs) == 0 {
return s, "Список URL пуст. Добавьте ссылки на .cer-файлы УЦ НРД в /admin/setup → «Сертификаты УЦ»."
}
var logBuf strings.Builder
now := time.Now()
newFetched := make([]FetchedCACert, 0, len(s.URLs))
for _, u := range s.URLs {
u = strings.TrimSpace(u)
if u == "" {
continue
}
fc := FetchedCACert{URL: u, FetchedAt: now}
der, err := downloadAndParseCert(ctx, u)
if err != nil {
fc.Error = err.Error()
newFetched = append(newFetched, fc)
fmt.Fprintf(&logBuf, "%s — ОШИБКА: %s\n", u, err)
continue
}
cert, perr := x509.ParseCertificate(der)
if perr != nil {
fc.Error = "не удалось распарсить X.509: " + perr.Error()
newFetched = append(newFetched, fc)
fmt.Fprintf(&logBuf, "%s — не X.509: %s\n", u, perr)
continue
}
fc.SubjectCN = cert.Subject.CommonName
fc.IssuerCN = cert.Issuer.CommonName
fc.NotAfter = cert.NotAfter
fc.SHA256 = hex.EncodeToString(sha256Bytes(der))
// УЦ-сертификаты с самоподписью (Issuer == Subject) идут в mroot,
// промежуточные — в uRoot.
store := "uRoot"
if cert.Subject.CommonName == cert.Issuer.CommonName {
store = "mroot"
}
fc.Store = store
// Дедуп: если sha256 совпадает с уже импортированным — пропускаем
// сам импорт (но фиксируем что проверили).
alreadyImported := false
for _, old := range s.FetchedCerts {
if old.URL == u && old.SHA256 == fc.SHA256 && old.Error == "" {
alreadyImported = true
break
}
}
if alreadyImported {
fmt.Fprintf(&logBuf, "%s — не изменился (sha256=%s...)\n", u, fc.SHA256[:12])
newFetched = append(newFetched, fc)
continue
}
// Импорт через certmgr.
isNew := true
for _, old := range s.FetchedCerts {
if old.URL == u && old.Error == "" {
isNew = false
break
}
}
if err := importCertToStore(ctx, der, store); err != nil {
fc.Error = "certmgr: " + err.Error()
fmt.Fprintf(&logBuf, "%s — certmgr упал: %s\n", u, err)
if rc != nil {
_ = rc.AddNews(NewsItem{
ID: "ca-error-" + fc.SHA256[:12],
At: now,
Kind: "system",
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])
if rc != nil {
kindTitle := "Обновлён сертификат УЦ"
if isNew {
kindTitle = "Установлен новый сертификат УЦ"
}
_ = rc.AddNews(NewsItem{
ID: "ca-update-" + fc.SHA256[:12],
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),
URL: u,
ValidTo: fc.NotAfter,
})
// Предупреждение если истекает в ближайшие 14 дней.
if !fc.NotAfter.IsZero() && time.Until(fc.NotAfter) < 14*24*time.Hour {
_ = rc.AddNews(NewsItem{
ID: "ca-expiring-" + fc.SHA256[:12],
At: now,
Kind: "system",
Title: "⚠ Сертификат УЦ скоро истечёт: " + fc.SubjectCN,
Body: fmt.Sprintf("Срок действия — %s (через %d дней). Получите новую версию у УЦ и обновите URL в /admin/setup → «Сертификаты УЦ».",
fc.NotAfter.Format("02.01.2006"),
int(time.Until(fc.NotAfter)/(24*time.Hour))),
URL: u,
ValidTo: fc.NotAfter,
})
}
}
}
newFetched = append(newFetched, fc)
}
s.LastFetch = now
s.LastFetchLog = logBuf.String()
s.FetchedCerts = newFetched
return s, logBuf.String()
}
func sha256Bytes(b []byte) []byte {
h := sha256.Sum256(b)
return h[:]
}
// downloadAndParseCert качает URL и возвращает DER-байты сертификата.
// Поддерживает PEM (-----BEGIN CERTIFICATE-----) и сырой DER.
func downloadAndParseCert(ctx context.Context, rawURL string) ([]byte, error) {
u, err := url.Parse(rawURL)
if err != nil {
return nil, fmt.Errorf("не URL: %w", err)
}
if u.Scheme != "http" && u.Scheme != "https" {
return nil, fmt.Errorf("поддерживаются только http/https, получено %q", u.Scheme)
}
reqCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0 Safari/537.36")
// noProxyClient определён в news.go — игнорирует HTTPS_PROXY (zetit).
resp, err := noProxyClient.Do(req)
if err != nil {
return nil, fmt.Errorf("сеть: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, 5<<20))
if err != nil {
return nil, err
}
// Пробуем PEM.
if block, _ := pem.Decode(data); block != nil && block.Type == "CERTIFICATE" {
return block.Bytes, nil
}
// Иначе считаем что DER.
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 {
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
}
// StartCACertsAutoUpdater запускает горутину, которая раз в сутки
// перекачивает сертификаты УЦ и переустанавливает изменённые. Возвращает
// функцию остановки. Если AutoUpdate=false — фон не запускается.
func StartCACertsAutoUpdater(rc *RuntimeConfig) func() {
ctx, cancel := context.WithCancel(context.Background())
go func() {
// При старте — небольшой запас, чтобы не лезть в сеть в ту же
// секунду запуска bj-server.
select {
case <-ctx.Done():
return
case <-time.After(30 * time.Second):
}
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
for {
s := rc.Snapshot().CACerts
if s.AutoUpdate && len(s.URLs) > 0 {
updated, _ := FetchCACertificates(ctx, s, rc)
if err := rc.UpdateCACerts(updated); err != nil {
log.Printf("ca-certs auto-update: save failed: %v", err)
} else {
log.Printf("ca-certs auto-update: %d url'ов проверено", len(s.URLs))
}
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}()
return cancel
}
// saveCACerts — POST /admin/setup/cacerts.
// Принимает форму с textarea (одна URL на строку) и чекбоксом auto_update.
func (h *setupHandlers) saveCACerts(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
raw := r.FormValue("urls")
auto := r.FormValue("auto_update") == "on"
urls := []string{}
for _, line := range strings.Split(raw, "\n") {
line = strings.TrimSpace(line)
if line != "" && !strings.HasPrefix(line, "#") {
urls = append(urls, line)
}
}
cur := h.rc.Snapshot().CACerts
cur.URLs = urls
cur.AutoUpdate = auto
if err := h.rc.UpdateCACerts(cur); err != nil {
setupFlash(w, r, "Сертификаты УЦ: не получилось сохранить: "+err.Error())
return
}
setupFlash(w, r, fmt.Sprintf("Сертификаты УЦ: сохранено %d URL'ов, авто-обновление: %v", len(urls), auto))
}
// fetchCACertsNow — POST /admin/setup/cacerts/fetch.
// Ручной триггер «скачать сейчас», вызывает FetchCACertificates сразу.
func (h *setupHandlers) fetchCACertsNow(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
defer cancel()
cur := h.rc.Snapshot().CACerts
updated, summary := FetchCACertificates(ctx, cur, h.rc)
if err := h.rc.UpdateCACerts(updated); err != nil {
setupFlash(w, r, "Сертификаты УЦ: ошибка сохранения: "+err.Error())
return
}
if summary == "" {
summary = "готово"
}
// Обрезаем длинный лог в flash-сообщении.
if len(summary) > 800 {
summary = summary[:800] + "…"
}
setupFlash(w, r, "Сертификаты УЦ обновлены: "+strings.TrimSpace(summary))
}
// caCertsTemplateString — компактный URL для отображения в UI.
func caCertsTemplateString(s CACertsSettings) string {
return strings.Join(s.URLs, "\n")
}
// доп. защита от пустых импортов (linter)
var _ = filepath.Join
+190
View File
@@ -0,0 +1,190 @@
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.")
}
+387
View File
@@ -0,0 +1,387 @@
package lkgateway
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
// defaultDocSources — стартовый набор страниц НРД, которые doc-watcher
// будет проверять раз в сутки. Пользователь может добавить/удалить через UI.
var defaultDocSources = []DocSource{
{
URL: "https://www.nsd.ru/services/novye-servisy/moex-most-dlya-m2m/",
Name: "Сервис MOEX МОСТ для M2M",
},
{
URL: "https://www.nsd.ru/workflow/system/programs/",
Name: "ПО для участников ЭДО (ИШ, ФШ)",
},
{
URL: "https://www.nsd.ru/workflow/system/programs/cryptoservice/",
Name: "Криптосервис",
},
}
// EnsureDocSources гарантирует что defaultDocSources прописаны в конфиге.
// Вызывается при старте bj-server.
func EnsureDocSources(rc *RuntimeConfig) {
s := rc.Snapshot().News
if len(s.DocSources) > 0 {
return
}
s.DocSources = append([]DocSource(nil), defaultDocSources...)
if err := rc.UpdateNews(s); err != nil {
log.Printf("news: не получилось сохранить default DocSources: %v", err)
}
}
// pdfHrefRe — ищет в HTML href'ы, заканчивающиеся на .pdf (case-insensitive).
var pdfHrefRe = regexp.MustCompile(`(?i)href="([^"]+\.pdf)"`)
// noProxyClient — HTTP-клиент, который игнорирует переменные окружения
// HTTPS_PROXY / HTTP_PROXY. Корпоративный прокси zetit блокирует
// nsd.ru — поэтому doc-watcher ходит на внешние сайты НРД напрямую.
// Transport.Proxy = nil отключает любую проксификацию (включая
// автодетект через env).
var noProxyClient = &http.Client{
Timeout: 90 * time.Second,
Transport: &http.Transport{
Proxy: nil,
},
}
// CheckDocSources обходит все DocSource из настроек, парсит HTML, ищет
// новые PDF и скачивает их в DOC/. На каждое нововведение эмитирует
// NewsItem типа "doc-update". Возвращает суммарную строку для лога.
func CheckDocSources(ctx context.Context, rc *RuntimeConfig) string {
s := rc.Snapshot().News
if len(s.DocSources) == 0 {
s.DocSources = append([]DocSource(nil), defaultDocSources...)
}
var summary strings.Builder
now := time.Now()
for i, src := range s.DocSources {
fmt.Fprintf(&summary, "→ %s\n", src.URL)
pdfs, err := fetchPDFLinks(ctx, src.URL)
if err != nil {
fmt.Fprintf(&summary, " ошибка: %v\n", err)
continue
}
if src.KnownPDFs == nil {
s.DocSources[i].KnownPDFs = map[string]string{}
}
known := s.DocSources[i].KnownPDFs
fmt.Fprintf(&summary, " найдено %d ссылок на PDF\n", len(pdfs))
newlyAdded := 0
for _, pdfURL := range pdfs {
hash, changed := checkPDF(ctx, pdfURL, known)
if !changed {
continue
}
known[pdfURL] = hash
newlyAdded++
localPath, err := downloadPDFToDOC(ctx, pdfURL)
if err != nil {
fmt.Fprintf(&summary, " ✗ %s: %v\n", pdfURL, err)
continue
}
fmt.Fprintf(&summary, " ✓ %s → %s\n", pdfURL, localPath)
// Новость в ленту.
_ = rc.AddNews(NewsItem{
ID: "doc-" + hash[:12],
At: now,
Kind: "doc-update",
Title: "Обновлена документация: " + filepath.Base(localPath),
Body: "Источник: " + src.Name + "\nURL: " + pdfURL +
"\nЛокально: " + localPath + "\nSHA-256: " + hash[:16] + "…",
URL: pdfURL,
})
}
s.DocSources[i].LastChecked = now
if newlyAdded > 0 {
fmt.Fprintf(&summary, " добавлено новых: %d\n", newlyAdded)
}
}
s.LastDocCheck = now
s.DocCheckResult = summary.String()
if err := rc.UpdateNews(s); err != nil {
log.Printf("news: save failed: %v", err)
}
return summary.String()
}
// fetchPDFLinks качает HTML-страницу и извлекает все href'ы, заканчивающиеся
// на .pdf. Относительные URL разворачиваются в абсолютные.
func fetchPDFLinks(ctx context.Context, pageURL string) ([]string, error) {
reqCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, pageURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0 Safari/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
req.Header.Set("Accept-Language", "ru-RU,ru;q=0.9,en;q=0.8")
resp, err := noProxyClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if err != nil {
return nil, err
}
base, err := url.Parse(pageURL)
if err != nil {
return nil, err
}
matches := pdfHrefRe.FindAllStringSubmatch(string(body), -1)
seen := map[string]bool{}
var out []string
for _, m := range matches {
ref, err := url.Parse(m[1])
if err != nil {
continue
}
abs := base.ResolveReference(ref).String()
// Игнорируем «системные» PDF (политика конфиденциальности и т.п.).
low := strings.ToLower(abs)
if strings.Contains(low, "personal_information") ||
strings.Contains(low, "personal-information") ||
strings.Contains(low, "razmeschenie-logotipa") {
continue
}
if seen[abs] {
continue
}
seen[abs] = true
out = append(out, abs)
}
return out, nil
}
// checkPDF делает HEAD-запрос (или GET если HEAD не сработал) и сравнивает
// sha256 PDF с известным значением. Возвращает (новый_hash, изменился).
// HEAD у НРД редко возвращает Content-MD5/ETag — реальная проверка =
// скачать и посчитать sha256.
func checkPDF(ctx context.Context, pdfURL string, known map[string]string) (string, bool) {
reqCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, pdfURL, nil)
if err != nil {
return "", false
}
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0 Safari/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
req.Header.Set("Accept-Language", "ru-RU,ru;q=0.9,en;q=0.8")
resp, err := noProxyClient.Do(req)
if err != nil {
return "", false
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return "", false
}
h := sha256.New()
if _, err := io.Copy(h, io.LimitReader(resp.Body, 32<<20)); err != nil {
return "", false
}
hash := hex.EncodeToString(h.Sum(nil))
if old, ok := known[pdfURL]; ok && old == hash {
return hash, false
}
return hash, true
}
// downloadPDFToDOC скачивает PDF в DOC/. Если файл с таким именем уже
// есть — переименовывает старый в name.old-YYYYMMDD.pdf, чтобы оставить
// аудит. Возвращает путь до нового файла.
func downloadPDFToDOC(ctx context.Context, pdfURL string) (string, error) {
u, err := url.Parse(pdfURL)
if err != nil {
return "", err
}
name := filepath.Base(u.Path)
if name == "" || !strings.HasSuffix(strings.ToLower(name), ".pdf") {
return "", errors.New("странное имя файла")
}
docDir := "DOC"
if _, err := os.Stat(docDir); err != nil {
return "", fmt.Errorf("DOC/ не доступен: %w", err)
}
dst := filepath.Join(docDir, name)
// Если файл уже есть — переименуем как backup.
if _, err := os.Stat(dst); err == nil {
old := filepath.Join(docDir,
strings.TrimSuffix(name, ".pdf")+
"."+time.Now().Format("2006-01-02")+".pdf.bak")
_ = os.Rename(dst, old)
}
reqCtx, cancel := context.WithTimeout(ctx, 90*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, pdfURL, nil)
if err != nil {
return "", err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0 Safari/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
req.Header.Set("Accept-Language", "ru-RU,ru;q=0.9,en;q=0.8")
resp, err := noProxyClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
}
f, err := os.Create(dst)
if err != nil {
return "", err
}
defer f.Close()
if _, err := io.Copy(f, io.LimitReader(resp.Body, 64<<20)); err != nil {
return "", err
}
return dst, nil
}
// StartDocWatcher запускает горутину, которая раз в сутки проверяет
// DocSources и эмитирует новости. Стартует через 60 сек после Run().
func StartDocWatcher(rc *RuntimeConfig) func() {
ctx, cancel := context.WithCancel(context.Background())
go func() {
select {
case <-ctx.Done():
return
case <-time.After(60 * time.Second):
}
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
for {
summary := CheckDocSources(ctx, rc)
log.Printf("doc-watcher: проверка завершена\n%s", summary)
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}()
return cancel
}
// addManualNews — POST /admin/news/add.
func (h *setupHandlers) addManualNews(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
title := strings.TrimSpace(r.FormValue("title"))
body := strings.TrimSpace(r.FormValue("body"))
kind := r.FormValue("kind")
if kind == "" {
kind = "manual"
}
if title == "" {
setupFlash(w, r, "Новости: укажите заголовок")
return
}
item := NewsItem{
At: time.Now(),
Kind: kind,
Title: title,
Body: body,
}
if vf := r.FormValue("valid_from"); vf != "" {
if t, err := time.Parse("2006-01-02", vf); err == nil {
item.ValidFrom = t
}
}
if vt := r.FormValue("valid_to"); vt != "" {
if t, err := time.Parse("2006-01-02", vt); err == nil {
item.ValidTo = t.Add(24*time.Hour - time.Second)
}
}
if err := h.rc.AddNews(item); err != nil {
setupFlash(w, r, "Новости: ошибка сохранения: "+err.Error())
return
}
setupFlash(w, r, "Новость «"+title+"» добавлена в ленту")
}
// dismissNews — POST /admin/news/dismiss?id=...
func (h *setupHandlers) dismissNews(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
id := r.FormValue("id")
if id == "" {
setupFlash(w, r, "Новости: id обязателен")
return
}
_ = h.rc.DismissNews(id)
setupFlash(w, r, "Новость скрыта")
}
// checkDocsNow — POST /admin/news/check-docs.
func (h *setupHandlers) checkDocsNow(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
defer cancel()
summary := CheckDocSources(ctx, h.rc)
if len(summary) > 600 {
summary = summary[:600] + "…"
}
setupFlash(w, r, "Проверка обновлений документации завершена. "+strings.TrimSpace(summary))
}
// SeedDefaultNews добавляет в ленту известные на момент запуска события
// (окно техработ TEST3 в мае 2026 и появление робота-автотестирования).
// Вызывается из server.go при старте — дедуп по ID гарантирован AddNews.
func SeedDefaultNews(rc *RuntimeConfig) {
defaults := []NewsItem{
{
ID: "test3-maintenance-2026-05",
At: time.Date(2026, 5, 13, 0, 0, 0, 0, time.UTC),
Kind: "maintenance",
Title: "TEST3 недоступен 18.05.2026 — 22.05.2026 (техработы)",
Body: "НРД проводит техработы на тестовом контуре TEST3. На gost-t3.nsd.ru / rsa-t3.nsd.ru интеграционные прогоны в этот период не пойдут. При необходимости — переключитесь на GUEST (gost-gt.nsd.ru) или mock-режим. Источник: НРД письмо НРД-И-2026-8452 от 13.05.2026.",
URL: "https://www.nsd.ru/services/novye-servisy/moex-most-dlya-m2m/",
ValidFrom: time.Date(2026, 5, 18, 0, 0, 0, 0, time.UTC),
ValidTo: time.Date(2026, 5, 22, 23, 59, 59, 0, time.UTC),
},
{
ID: "robot-autotest-2026-05-12",
At: time.Date(2026, 5, 12, 0, 0, 0, 0, time.UTC),
Kind: "feature",
Title: "Доступно автотестирование MOEX МОСТ с роботом на TEST3",
Body: "С 12.05.2026 клиенты, подключившиеся к автотестированию, могут гонять обмен сообщениями с роботом-контрагентом на TEST3. Не нужно ждать живого второго депозитария. Контакт: M2MOST@nsd.ru. Опубликованы новые инструкции: «Инструкция по тестированию с роботом» и «Инструкция для обмена при self-transfer» — обе в DOC/.",
URL: "https://www.nsd.ru/services/novye-servisy/moex-most-dlya-m2m/",
},
}
for _, item := range defaults {
_ = rc.AddNews(item)
}
}
+116
View File
@@ -24,10 +24,68 @@ type Settings struct {
Crypto CryptoSettings `json:"crypto"`
NSD NSDSettings `json:"nsd"`
LK LKSettings `json:"lk"`
CACerts CACertsSettings `json:"ca_certs"`
News NewsSettings `json:"news"`
LastTest *TestRunResult `json:"last_test,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
// NewsSettings — лента новостей (события системы, окна техработ, обновления
// документации НРД). События добавляются вручную через UI или автоматически
// doc-watcher'ом и cron-задачами. Каждое событие может быть скрыто (Dismissed)
// оператором, но не удалено — лента служит «журналом» для аудита.
type NewsSettings struct {
Items []NewsItem `json:"items"`
DocSources []DocSource `json:"doc_sources"` // URL'ы для авто-проверки (NSD pages)
LastDocCheck time.Time `json:"last_doc_check"`
DocCheckResult string `json:"doc_check_result"`
}
// NewsItem — одно событие в ленте.
type NewsItem struct {
ID string `json:"id"` // уникальный идентификатор для dismiss
At time.Time `json:"at"`
Kind string `json:"kind"` // "maintenance" | "feature" | "doc-update" | "manual" | "system"
Title string `json:"title"`
Body string `json:"body"`
URL string `json:"url,omitempty"` // ссылка на источник
ValidFrom time.Time `json:"valid_from,omitempty"` // для maintenance окон
ValidTo time.Time `json:"valid_to,omitempty"`
Dismissed bool `json:"dismissed"`
}
// DocSource — страница НРД, которую doc-watcher периодически проверяет.
type DocSource struct {
URL string `json:"url"`
Name string `json:"name"` // человекочитаемое имя
LastChecked time.Time `json:"last_checked"`
KnownPDFs map[string]string `json:"known_pdfs"` // url → sha256
}
// CACertsSettings — URL'ы для авто-загрузки сертификатов УЦ НРД и нашего
// УЦ. Список редактируется пользователем; раз в сутки фоновая горутина
// перекачивает каждый URL и переустанавливает сертификат, если он
// поменялся. Все сертификаты идут в mroot/uRoot хранилища КриптоПро.
type CACertsSettings struct {
URLs []string `json:"urls"`
AutoUpdate bool `json:"auto_update"`
LastFetch time.Time `json:"last_fetch"`
LastFetchLog string `json:"last_fetch_log"`
FetchedCerts []FetchedCACert `json:"fetched_certs"`
}
// FetchedCACert — информация о последнем удачно скачанном сертификате.
type FetchedCACert struct {
URL string `json:"url"`
SHA256 string `json:"sha256"`
SubjectCN string `json:"subject_cn"`
IssuerCN string `json:"issuer_cn"`
NotAfter time.Time `json:"not_after"`
Store string `json:"store"`
FetchedAt time.Time `json:"fetched_at"`
Error string `json:"error,omitempty"`
}
// PostgresSettings — DSN для подключения к БД (M2-шаг-3).
type PostgresSettings struct {
DSN string `json:"dsn"`
@@ -126,6 +184,64 @@ func (r *RuntimeConfig) UpdateNSD(s NSDSettings) error {
}
// UpdateLK сохраняет LK callback URL.
// UpdateCACerts сохраняет настройки авто-загрузки сертификатов УЦ.
func (r *RuntimeConfig) UpdateCACerts(s CACertsSettings) error {
r.mu.Lock()
r.data.CACerts = s
r.data.UpdatedAt = time.Now()
r.mu.Unlock()
return r.save()
}
// UpdateNews заменяет всю ленту новостей.
func (r *RuntimeConfig) UpdateNews(s NewsSettings) error {
r.mu.Lock()
r.data.News = s
r.data.UpdatedAt = time.Now()
r.mu.Unlock()
return r.save()
}
// AddNews добавляет новость в начало ленты (newest first). Если в ленте уже
// есть новость с таким же ID — она обновляется (вместо дубликата).
func (r *RuntimeConfig) AddNews(item NewsItem) error {
r.mu.Lock()
if item.ID == "" {
item.ID = item.At.Format("20060102-150405") + "-" + item.Kind
}
if item.At.IsZero() {
item.At = time.Now()
}
// Дедуп по ID.
replaced := false
for i, ex := range r.data.News.Items {
if ex.ID == item.ID {
r.data.News.Items[i] = item
replaced = true
break
}
}
if !replaced {
r.data.News.Items = append([]NewsItem{item}, r.data.News.Items...)
}
r.data.UpdatedAt = time.Now()
r.mu.Unlock()
return r.save()
}
// DismissNews помечает новость скрытой по ID (не удаляет — для аудита).
func (r *RuntimeConfig) DismissNews(id string) error {
r.mu.Lock()
for i := range r.data.News.Items {
if r.data.News.Items[i].ID == id {
r.data.News.Items[i].Dismissed = true
}
}
r.data.UpdatedAt = time.Now()
r.mu.Unlock()
return r.save()
}
func (r *RuntimeConfig) UpdateLK(s LKSettings) error {
r.mu.Lock()
r.data.LK = s
+13 -1
View File
@@ -105,7 +105,7 @@ func NewServer(cfg ServerConfig) (*Server, error) {
checkOpts = cfg.CheckOptions
}
adminTpl, err := RegisterAdmin(mux, svc, checkOpts)
adminTpl, err := RegisterAdmin(mux, svc, rc, checkOpts)
if err != nil {
return nil, err
}
@@ -159,6 +159,18 @@ func (s *Server) Mux() http.Handler { return s.mux }
func (s *Server) Run(ctx context.Context) error {
go s.consumeDecisions(ctx)
// Авто-обновление сертификатов УЦ раз в сутки (если оператор включил).
stopCACerts := StartCACertsAutoUpdater(s.rc)
defer stopCACerts()
// Doc-watcher: раз в сутки проверяет сайт НРД на новые PDF и
// эмитирует новости в ленту. Дефолтные источники + дефолтные
// новости (окно техработ TEST3, появление робота) сеются один раз.
EnsureDocSources(s.rc)
SeedDefaultNews(s.rc)
stopDocWatcher := StartDocWatcher(s.rc)
defer stopDocWatcher()
errCh := make(chan error, 1)
go func() {
log.Printf("lk-gateway: listen %s", s.cfg.Addr)
+222 -1
View File
@@ -11,6 +11,7 @@ import (
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
@@ -59,6 +60,7 @@ func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service
h.renderSetup(w, r, "")
})
mux.HandleFunc("/admin/setup/postgres", h.savePostgres)
mux.HandleFunc("/admin/setup/postgres/quick-start", h.quickStartPostgres)
mux.HandleFunc("/admin/setup/crypto", h.saveCrypto)
mux.HandleFunc("/admin/setup/crypto/check", h.checkCrypto)
mux.HandleFunc("/admin/setup/crypto/activate", h.activateLicense)
@@ -67,6 +69,124 @@ func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service
mux.HandleFunc("/admin/setup/nsd", h.saveNSD)
mux.HandleFunc("/admin/setup/lk", h.saveLK)
mux.HandleFunc("/admin/setup/test-run", h.testRun)
// Авто-загрузка сертификатов УЦ НРД и нашего УЦ.
mux.HandleFunc("/admin/setup/cacerts", h.saveCACerts)
mux.HandleFunc("/admin/setup/cacerts/fetch", h.fetchCACertsNow)
// Копирование контейнера КриптоПро с флешки в локальное хранилище.
mux.HandleFunc("/admin/setup/crypto/copy-container", h.copyContainer)
// Новости / события системы.
mux.HandleFunc("/admin/news", h.renderNews)
mux.HandleFunc("/admin/news/add", h.addManualNews)
mux.HandleFunc("/admin/news/dismiss", h.dismissNews)
mux.HandleFunc("/admin/news/check-docs", h.checkDocsNow)
// Пошаговый мастер настройки для нетехнических пользователей.
mux.HandleFunc("/admin/wizard", h.renderWizard)
}
// renderNews — GET /admin/news.
func (h *setupHandlers) renderNews(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
s := h.rc.Snapshot()
data := struct {
page
Settings Settings
Flash string
}{
page: nowPage("Новости", "news"),
Settings: s,
Flash: r.URL.Query().Get("flash"),
}
render(w, h.tpl.a.news, data)
}
// WizardData — данные для шаблона /admin/wizard.
type WizardData struct {
page
Step int
Settings Settings
Certs []cryptocli.Certificate
FlashContainers []FlashContainer
Flash string
CryptoProInstalled bool
CryptoProVersion string
Done struct {
Postgres bool
Crypto bool
Certs bool
NSD bool
TestRun bool
}
}
// renderWizard рисует одну из 5 страниц мастера. Шаг управляется query
// параметром ?step=N (1..5). По умолчанию шаг определяется автоматически
// по первому незавершённому пункту — это даёт «продолжить с того места».
func (h *setupHandlers) renderWizard(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
s := h.rc.Snapshot()
d := WizardData{
page: nowPage("Мастер настройки", "wizard"),
Settings: s,
Certs: h.listCertsForUI(),
FlashContainers: scanFlashContainers(),
Flash: r.URL.Query().Get("flash"),
}
d.Done.Postgres = s.Postgres.DSN != ""
d.Done.Crypto = s.Crypto.Provider != "" && s.Crypto.Provider != "stub" && s.Crypto.JCPPath != ""
d.Done.Certs = len(d.Certs) > 0
d.Done.NSD = s.NSD.IGWBaseURL != "" && s.NSD.Profile != ""
d.Done.TestRun = s.LastTest != nil
// Проверяем установлен ли КриптоПро CSP.
if _, err := os.Stat("/opt/cprocsp/sbin/amd64/cpconfig"); err == nil {
d.CryptoProInstalled = true
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
if ver, _ := runCmd(ctx, "/opt/cprocsp/sbin/amd64/cpconfig", "-license", "-view"); ver != "" {
d.CryptoProVersion = firstLine(ver)
}
}
// Определяем текущий шаг.
step := 1
if v := r.URL.Query().Get("step"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 1 && n <= 5 {
step = n
}
} else {
// Авто: первый незавершённый.
switch {
case !d.Done.Postgres:
step = 1
case !d.Done.Crypto:
step = 2
case !d.Done.Certs:
step = 3
case !d.Done.NSD:
step = 4
default:
step = 5
}
}
d.Step = step
render(w, h.tpl.a.wizard, d)
}
func firstLine(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
return strings.TrimSpace(s[:i])
}
return strings.TrimSpace(s)
}
// installCryptoPro — POST /admin/setup/crypto/install (multipart).
@@ -316,6 +436,7 @@ type SetupData struct {
ReadyCount int
TotalCount int
Certificates []cryptocli.Certificate
FlashContainers []FlashContainer
Flash string
Error string
}
@@ -336,6 +457,7 @@ func (h *setupHandlers) renderSetup(w http.ResponseWriter, _ *http.Request, flas
ReadyCount: ready,
TotalCount: len(r),
Certificates: h.listCertsForUI(),
FlashContainers: scanFlashContainers(),
Flash: flash,
}
if errVal := errMsgFromQuery(_q(w)); errVal != "" {
@@ -365,6 +487,88 @@ func (h *setupHandlers) savePostgres(w http.ResponseWriter, r *http.Request) {
setupFlash(w, r, "PostgreSQL настройки сохранены")
}
// quickStartPostgres — POST /admin/setup/postgres/quick-start.
// «Большая зелёная кнопка» для пользователя без IT-навыков: поднимает
// локальный postgres-контейнер через podman-compose, ждёт pg_isready,
// накатывает все миграции (fansy-store + m2m-core), сохраняет дефолтный
// DSN в runtime-конфиге. После этого пользователю остаётся перезапустить
// bj-server (или мы сделаем это автоматически в дальнейших версиях).
func (h *setupHandlers) quickStartPostgres(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel()
// 1. Поднимаем postgres контейнер через podman-compose.
composePath := "deploy/docker-compose/docker-compose.yml"
if out, err := runCmd(ctx, "podman-compose", "-f", composePath, "up", "-d", "postgres"); err != nil {
setupFlash(w, r, "Шаг 1/3: podman-compose не смог поднять контейнер. "+
"Установите podman-compose или проверьте docker-compose.yml. Подсказка: "+
"sudo dnf install -y podman-compose. Вывод: "+strings.TrimSpace(out))
return
}
// 2. Ждём pg_isready.
dsn := "postgres://bj:bj_dev@127.0.0.1:5432/bj?sslmode=disable"
deadline := time.Now().Add(30 * time.Second)
for time.Now().Before(deadline) {
if err := tryPingPostgres(dsn); err == nil {
break
}
time.Sleep(time.Second)
}
if err := tryPingPostgres(dsn); err != nil {
setupFlash(w, r, "Шаг 2/3: контейнер запущен, но БД не отвечает за 30 сек. Ошибка: "+err.Error())
return
}
// 3. Применяем миграции через podman exec.
migrations := []string{
"migrations/fansy-store/000__roles.sql",
"migrations/fansy-store/001__schemas.sql",
"migrations/fansy-store/002__working.sql",
"migrations/fansy-store/003__staging.sql",
"migrations/fansy-store/004__seed_participants.sql",
"migrations/m2m-core/001__deals.sql",
"migrations/m2m-core/002__stages.sql",
}
for _, mig := range migrations {
if err := applyMigration(ctx, mig); err != nil {
// Миграция могла быть уже применена ранее (например, ROLE уже
// существует) — это не критично, продолжаем.
log.Printf("quick-start: миграция %s: %v (продолжаем)", mig, err)
}
}
// 4. Сохраняем DSN в runtime-конфиг.
if err := h.rc.UpdatePostgres(PostgresSettings{DSN: dsn}); err != nil {
setupFlash(w, r, "Шаг 3/3: не получилось сохранить DSN: "+err.Error())
return
}
setupFlash(w, r, "Локальный PostgreSQL поднят и настроен. DSN сохранён. "+
"Перезапустите bj-server (или подождите пока systemd сам перезапустит сервис), "+
"чтобы Repository подключился к БД. После этого статус PostgreSQL будет зелёным.")
}
// applyMigration выполняет одну SQL-миграцию через podman exec в bj-postgres.
func applyMigration(ctx context.Context, path string) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
cmd := exec.CommandContext(ctx, "podman", "exec", "-i", "bj-postgres",
"psql", "-U", "bj", "-d", "bj", "-v", "ON_ERROR_STOP=1")
cmd.Stdin = strings.NewReader(string(data))
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%w / output: %s", err, strings.TrimSpace(string(out)))
}
return nil
}
func (h *setupHandlers) saveCrypto(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
@@ -573,8 +777,25 @@ func tryHTTPHealth(u string) error {
return nil
}
// setupFlash шлёт 303 на /admin/setup с flash-сообщением в query.
// setupFlash шлёт 303 с flash-сообщением в query. Если запрос пришёл
// с какой-то «принимающей flash» страницы (/admin/wizard, /admin/news,
// /admin/setup) — возвращаем туда же. Иначе дефолт — /admin/setup.
// Это нужно чтобы пользователь не «выпадал» из текущего контекста после
// POST-действия (нажал кнопку «Проверить обновления» в Новостях — должен
// остаться в Новостях со флешем).
func setupFlash(w http.ResponseWriter, r *http.Request, msg string) {
if ref := r.Header.Get("Referer"); ref != "" {
if u, err := url.Parse(ref); err == nil {
for _, prefix := range []string{"/admin/wizard", "/admin/news", "/admin/setup"} {
if strings.HasPrefix(u.Path, prefix) {
q := u.Query()
q.Set("flash", msg)
http.Redirect(w, r, u.Path+"?"+q.Encode(), http.StatusSeeOther)
return
}
}
}
}
http.Redirect(w, r, "/admin/setup?flash="+url.QueryEscape(msg), http.StatusSeeOther)
}
@@ -29,5 +29,17 @@
<p class="muted">ИШ НРД (профили GUEST/TEST3/PROD), команда Fansy (ETL в staging), уведомления (e-mail, Yandex Messenger, Telegram), порядок согласования.</p>
</div>
</a>
<a href="/admin/help/robot" style="text-decoration:none">
<div class="card" style="height:100%">
<h2 style="color:var(--accent)">Тестирование с роботом MOEX МОСТ →</h2>
<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,139 @@
{{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) │ │
│ └──────┬───────────┘ │
│ │ │
│ КриптоПро CSP — для нашей │ Валидата CSP │
│ admin-стороны (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>РЕД ОС 8 / Linux</td>
<td>КриптоПро CSP (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 <em>или</em> Windows 10/Server</td>
<td>Валидата CSP + АПК Валидата Клиент 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 на РЕД ОС, а ИШ требует <strong>Astra Linux</strong> (RPM-версии нет). Поэтому нужно либо: (а) отдельная Astra Linux ВМ, (б) запуск ИШ в Docker-контейнере с Astra-образом, (в) перевод всей инфры на Astra Linux.</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: Почему ИШ требует Валидата CSP, а мы поставили КриптоПро?</h3>
<p>ИШ — отечественная разработка НРД, исторически работает с Валидатой (продукт ООО «Валидата», <code>x509.ru</code>). КриптоПро CSP на нашей ВМ останется — он используется для админ-части bj-server (подпись действий оператора через Рутокен). Валидату надо поставить <strong>на Astra Linux ВМ рядом с ИШ</strong>, не вместо КриптоПро.</p>
<h3>Q: Где брать Валидату?</h3>
<p>Не публично. По запросу: email <code>soed@nsd.ru</code> (НРД) или <code>pki@moex.com</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,102 @@
{{define "content"}}
<p style="margin-bottom:16px"><a href="/admin/help">← все инструкции</a></p>
<div class="card">
<h2>Тестирование с роботом MOEX МОСТ</h2>
<p class="muted">Источник: <code>DOC/instruktsiya-po-testirovaniyu-s-robotom.pdf</code> (опубликована 12.05.2026). Демо-ролик: <a href="https://disk.yandex.ru/i/F1SL2CVY5GphwQ" target="_blank">disk.yandex.ru/i/F1SL2CVY5GphwQ</a>.</p>
</div>
<div class="card">
<h2>1. Что это</h2>
<p>НРД разработан специальный «робот» для тестирования интеграции информационных систем клиента и сервиса переводов M2M. Робот работает <strong>в круглосуточном режиме</strong> и эмулирует действия второй стороны при обмене сообщениями в сервисе M2M.</p>
<p>Робот может выступать как принимающей стороной (по умолчанию), так и передающей. Он может формировать как успешные сообщения, так и сообщения о нештатных ситуациях.</p>
<p>Доступен на тестовом контуре <strong>TEST3</strong> (<code>gost-t3.nsd.ru</code>). Подключение к роботу не требует отдельной регистрации — достаточно быть подключённым к ЭДО НРД на TEST3.</p>
</div>
<div class="card">
<h2>2. Адресация робота</h2>
<p><strong>КОД РОБОТА: <code>MC0012500000</code></strong></p>
<p>Чтобы робот получил сообщение, его код должен быть указан в получателях — <code>Header.ReceiverCode</code>.</p>
<p class="muted">В <code>bj-server</code> mock-сендер (<code>internal/nsdadapter/mock</code>) уже понимает этот код: если <code>ReceiverCode == MC0012500000</code> и в заявке указан DocumentSeries из таблицы ниже — внутренний робот-эмулятор сформирует ответ по выбранному сценарию. То же поведение будет на реальном TEST3, когда подключим ИШ.</p>
</div>
<div class="card">
<h2>3. Тестовые сценарии</h2>
<p>Выбор сценария — через поле <code>Data.InvestorInformation.IdentityDocument.DocumentSeries</code> в M2MTransferRequest.</p>
<table>
<thead><tr><th>Код</th><th>Сценарий</th><th>Управляющий параметр</th></tr></thead>
<tbody>
<tr>
<td><code>1111</code></td>
<td><strong>Ответ с отказом</strong> — все бумаги отвергаются с выбранным кодом ошибки</td>
<td>Последние 2 символа <code>DocumentNumber</code> = ключ ошибки (<code>01</code>..<code>09</code>) → код <code>M2M01</code>..<code>M2M09</code></td>
</tr>
<tr>
<td><code>2001</code></td>
<td><strong>Принять все бумаги</strong></td>
<td><code>DocumentNumber</code>: i-я цифра = номер депозитария-получателя для i-й секции (<code>1</code> или <code>2</code>). По умолчанию <code>1</code>.</td>
</tr>
<tr>
<td><code>2002</code></td>
<td><strong>Принять бумаги частично</strong></td>
<td><code>DocumentNumber</code>: i-я цифра = номер депозитария (<code>1</code>/<code>2</code>) или <code>0</code> (отклонить с <code>M2M05</code>).</td>
</tr>
<tr>
<td><code>3333</code></td>
<td><strong>Выступить принимающей стороной</strong> — робот отвергает оригинал и формирует встречный M2MTransferRequest</td>
<td>Первые 2 цифры <code>DocumentNumber</code> = реквизиты двух депозитариев для нового перевода</td>
</tr>
</tbody>
</table>
<p class="muted" style="margin-top:8px">Пример: для сценария <code>1111</code> с <code>DocumentNumber=111102</code> робот вернёт код ошибки <code>M2M02</code>. Для сценария <code>2001</code> с 4 секциями ЦБ и <code>DocumentNumber=111200</code> — секции 1,2,3 принимаются депозитарием 1, секция 4 — депозитарием 2.</p>
</div>
<div class="card">
<h2>4. Тестовые данные депозитариев</h2>
<table>
<thead><tr><th>Ключ</th><th>ИНН (SettlementRequisites)</th><th>SettlementDepositoryLocation</th></tr></thead>
<tbody>
<tr>
<td><code>1</code></td>
<td><code>7702165310</code></td>
<td>ИНН <code>7722061076</code> · depcode <code>MC0012500000</code> · счёт <code>HL2603250011</code> · раздел <code>31MC0012500000F00</code></td>
</tr>
<tr>
<td><code>2</code></td>
<td><code>7702165310</code></td>
<td>ИНН <code>7722061076</code> · depcode <code>MC0012500000</code> · счёт <code>HL2603250011</code> · раздел <code>36MC0012500000F00</code></td>
</tr>
<tr>
<td><code>3</code></td>
<td><code>7831000034</code></td>
<td class="muted">остальные поля — заглушки</td>
</tr>
</tbody>
</table>
</div>
<div class="card">
<h2>5. Коды ошибок (для сценария 1111)</h2>
<table>
<thead><tr><th>Ключ</th><th>Код ошибки</th></tr></thead>
<tbody>
<tr><td><code>01</code></td><td><code>M2M01</code></td></tr>
<tr><td><code>02</code></td><td><code>M2M02</code></td></tr>
<tr><td><code>03</code></td><td><code>M2M03</code></td></tr>
<tr><td><code>04</code></td><td><code>M2M04</code></td></tr>
<tr><td><code>05</code></td><td><code>M2M05</code></td></tr>
<tr><td><code>06</code></td><td><code>M2M06</code></td></tr>
<tr><td><code>07</code></td><td><code>M2M07</code></td></tr>
<tr><td><code>08</code></td><td><code>M2M08</code></td></tr>
<tr><td><code>09</code></td><td><code>M2M09</code></td></tr>
</tbody>
</table>
</div>
<div class="card">
<h2>6. Как запустить</h2>
<p><strong>Сейчас, без реального ИШ:</strong> используется внутренний робот-эмулятор в bj-server. Отправь заявку с ReceiverCode = <code>MC0012500000</code> и DocumentSeries по таблице — Decision придёт через 3 секунды по правилам робота.</p>
<p><strong>На реальном TEST3 НРД:</strong> установи ИШ НРД (см. <a href="/admin/help/systems">/admin/help/systems</a>), укажи в <a href="/admin/setup">/admin/setup</a> → ИШ профиль <code>test3-gost</code>, URL <code>https://gost-t3.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo</code>. Дальше отправляй те же заявки — НРД направит их роботу, ответ будет идентичный.</p>
<p class="muted">Сценарий 3333 («выступить принимающей стороной») в нашем внутреннем эмуляторе пока реализован частично — отдаёт только первое сообщение (отказ M2M05). Встречный M2MTransferRequest от робота требует доработки приёмной стороны bj-server.</p>
</div>
{{end}}
@@ -1,4 +1,25 @@
{{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}}
</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="grid">
<div class="stat">
<div class="stat-label">Всего сделок</div>
@@ -0,0 +1,91 @@
{{define "content"}}
<style>
.news-item { background:var(--card); border:1px solid var(--border); border-radius:6px; padding:14px; margin-bottom:10px; }
.news-item.dismissed { opacity:0.5; }
.news-item.kind-maintenance { border-left:4px solid var(--warn); }
.news-item.kind-feature { border-left:4px solid var(--ok); }
.news-item.kind-doc-update { border-left:4px solid var(--accent); }
.news-item.kind-system { border-left:4px solid var(--err); }
.news-item.kind-manual { border-left:4px solid var(--muted); }
.news-meta { font-size:11px; color:var(--muted); margin-bottom:6px; text-transform:uppercase; letter-spacing:0.04em; }
.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); }
</style>
{{if .Flash}}<div class="card" style="border-left:3px solid var(--accent)"><p style="margin:0">{{.Flash}}</p></div>{{end}}
<div class="card">
<h2>Новости и события</h2>
<p class="muted">События системы, окна техработ НРД, обновления документации и сертификатов. Лента не очищается — служит журналом для аудита. Скрытые новости можно посмотреть, сняв галочку «Только активные».</p>
<form method="post" action="/admin/news/check-docs" style="margin-top:10px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<button type="submit" class="btn">🔄 Проверить обновления документации НРД сейчас</button>
{{if not .Settings.News.LastDocCheck.IsZero}}
<span class="muted" style="font-size:12px">Последняя проверка: {{.Settings.News.LastDocCheck.Format "02.01.2006 15:04:05"}}</span>
{{end}}
</form>
{{if .Settings.News.DocSources}}
<details style="margin-top:8px">
<summary style="cursor:pointer;color:var(--accent);font-size:13px">Источники документации, которые отслеживает doc-watcher</summary>
<table style="margin-top:8px;font-size:13px">
<thead><tr><th>Имя</th><th>URL</th><th>PDF найдено</th><th>Последняя проверка</th></tr></thead>
<tbody>
{{range .Settings.News.DocSources}}
<tr>
<td>{{.Name}}</td>
<td><a href="{{.URL}}" target="_blank"><code style="font-size:11px">{{.URL}}</code></a></td>
<td>{{len .KnownPDFs}}</td>
<td>{{if .LastChecked.IsZero}}—{{else}}{{.LastChecked.Format "02.01.2006 15:04"}}{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
</details>
{{end}}
</div>
<h2 style="margin:24px 0 12px 0">Лента ({{len .Settings.News.Items}})</h2>
{{if not .Settings.News.Items}}
<div class="card"><p class="muted" style="margin:0">Пока ничего нет. Doc-watcher запустится через минуту после старта bj-server и заполнит ленту автоматически.</p></div>
{{end}}
{{range .Settings.News.Items}}
<div class="news-item kind-{{.Kind}} {{if .Dismissed}}dismissed{{end}}">
<div class="news-meta">
{{.At.Format "02.01.2006 15:04"}}
· <strong>{{.Kind}}</strong>
{{if .URL}}· <a href="{{.URL}}" target="_blank" rel="noopener">источник</a>{{end}}
</div>
<h3 class="news-title">{{.Title}}</h3>
{{if .Body}}<div class="news-body">{{.Body}}</div>{{end}}
{{if or (not .ValidFrom.IsZero) (not .ValidTo.IsZero)}}
{{$now := now}}
{{$active := false}}
{{if and (not .ValidFrom.IsZero) (not .ValidTo.IsZero)}}
{{if and (gt $now.Unix .ValidFrom.Unix) (lt $now.Unix .ValidTo.Unix)}}{{$active = true}}{{end}}
{{end}}
<div class="news-validity {{if $active}}active{{end}}">
{{if not .ValidFrom.IsZero}}С {{.ValidFrom.Format "02.01.2006"}}{{end}}
{{if not .ValidTo.IsZero}} по {{.ValidTo.Format "02.01.2006"}}{{end}}
{{if $active}} — <strong>сейчас активно</strong>{{end}}
</div>
{{end}}
{{if not .Dismissed}}
<form method="post" action="/admin/news/dismiss" style="margin-top:10px">
<input type="hidden" name="id" value="{{.ID}}">
<button type="submit" class="btn" style="background:var(--border);color:var(--text);padding:4px 10px;font-size:12px">Скрыть</button>
</form>
{{end}}
</div>
{{end}}
{{if .Settings.News.DocCheckResult}}
<div class="card" style="margin-top:20px">
<h2>Журнал последней проверки документации</h2>
<pre>{{.Settings.News.DocCheckResult}}</pre>
</div>
{{end}}
{{end}}
@@ -19,14 +19,26 @@
<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>
<details {{if not .Settings.Postgres.DSN}}open{{end}}>
<summary style="cursor:pointer;color:var(--accent);font-size:13px">Изменить параметры подключения</summary>
{{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}}
<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</label>
<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">
</div>
<p class="muted" style="margin-top:8px">При сохранении выполняется Ping. Если драйвер pgx ещё не подключён в коде, тест упадёт — это ожидаемо до M2-шага-3.</p>
<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>
@@ -135,6 +147,84 @@
</details>
</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>
</form>
{{end}}
</td>
</tr>
{{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>
<!-- Авто-загрузка сертификатов УЦ НРД -->
<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>
</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>
{{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>
<!-- nsd-adapter / ИШ НРД -->
<div class="card">
<h2><span class="dot {{if .Settings.NSD.IGWBaseURL}}ok{{else}}err{{end}}"></span>Интеграционный шлюз НРД (ИШ)</h2>
@@ -0,0 +1,362 @@
{{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); }
.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 strong { color:var(--accent); }
</style>
<div class="card">
<h2>Мастер настройки</h2>
<p class="muted">Пошаговая настройка системы. Подходит для первого запуска. После каждого шага состояние сохраняется и можно вернуться позже.</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>
{{if .Flash}}<div class="card" style="border-left:3px solid var(--accent)"><p style="margin:0">{{.Flash}}</p></div>{{end}}
{{/* ============= ШАГ 1: PostgreSQL ============= */}}
{{if eq .Step 1}}
<div class="card">
<h2><span class="dot {{if .Done.Postgres}}ok{{else}}err{{end}}"></span>Шаг 1. PostgreSQL</h2>
<p>Сюда система пишет журнал сделок и принимает данные от команды Fansy.</p>
<div class="help-block">
<strong>Что выбрать?</strong> Если у вас уже есть рабочий PostgreSQL — нажмите «У меня уже есть PostgreSQL» и введите DSN. Если впервые настраиваете — выберите «Поднять автоматически», система сама развернёт контейнер с PostgreSQL и накатит миграции.
</div>
{{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">Вариант А — для тех, у кого нет своего PostgreSQL</h3>
<p class="muted" style="margin:0 0 10px 0">Bridge-and-Join-s сам поднимет PostgreSQL в контейнере (podman-compose), создаст БД <code>bj</code> и накатит миграции. Подходит для дев-стенда. Для продакшена лучше указать свой DSN.</p>
<form method="post" action="/admin/setup/postgres/quick-start" style="margin:0">
<button type="submit" class="btn" style="background:var(--ok)">⚡ Поднять локальный PostgreSQL автоматически</button>
<span class="muted" style="margin-left:10px;font-size:12px">~10-30 сек</span>
</form>
</div>
{{end}}
<details style="margin-top:14px" {{if .Settings.Postgres.DSN}}open{{end}}>
<summary style="cursor:pointer;color:var(--accent)">Вариант Б — у меня уже есть PostgreSQL, введу DSN сам</summary>
<form method="post" action="/admin/setup/postgres" style="margin-top:12px">
<label>DSN (строка подключения) <span class="tooltip" title="Формат: postgres://пользователь:пароль@хост:порт/база?sslmode=disable. Например: postgres://bj:secret@db.example.com:5432/bj?sslmode=require">?</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;margin-top:6px">
<p class="muted" style="margin-top:8px">При сохранении выполняется тестовое подключение (Ping). Если БД недоступна — будет ошибка.</p>
<button type="submit" class="btn" style="margin-top:8px">Сохранить и проверить</button>
</form>
</details>
<div style="margin-top:20px;display:flex;justify-content:space-between">
<span></span>
{{if .Done.Postgres}}<a href="/admin/wizard?step=2" class="btn" style="text-decoration:none">К шагу 2 →</a>{{else}}<button class="btn" disabled style="opacity:0.5;cursor:not-allowed">К шагу 2 → (сначала настройте PostgreSQL или нажмите «in-memory режим»)</button>{{end}}
</div>
{{if not .Done.Postgres}}<p style="margin-top:8px"><a href="/admin/wizard?step=2&skip=postgres" style="font-size:13px">Пропустить (буду работать в режиме in-memory — без сохранения сделок)</a></p>{{end}}
</div>
{{end}}
{{/* ============= ШАГ 2: Крипто ============= */}}
{{if eq .Step 2}}
<div class="card">
<h2><span class="dot {{if .Done.Crypto}}ok{{else}}err{{end}}"></span>Шаг 2. Крипто-провайдер (КриптоПро CSP или Рутокен)</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 месяца встроена в дистрибутив.
</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>
<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>
<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>
</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%">
</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}}
</div>
</div>
{{end}}
{{/* ============= ШАГ 3: Сертификаты ============= */}}
{{if eq .Step 3}}
<div class="card">
<h2><span class="dot {{if .Done.Certs}}ok{{else}}err{{end}}"></span>Шаг 3. Сертификаты</h2>
<p>Импортируйте сертификаты вашей организации и сертификаты УЦ НРД (для проверки квитанций).</p>
<div class="help-block">
<strong>Что говорят документы НРД (<code>DOC/Инструккия M2M.pdf</code>, стр. 11, 16-19):</strong>
<ul style="margin:6px 0 6px 16px">
<li>Наши пакеты должны быть подписаны сертификатом <strong>УЦ МБ</strong> (Удостоверяющий центр Московской Биржи).</li>
<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>
<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>
<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>
<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">
<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" style="background:var(--ok)">⬇ Скачать и импортировать сейчас</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 .Certs}}
<h3 style="margin-top:18px">Установленные сертификаты ({{len .Certs}})</h3>
<table>
<thead><tr><th>Владелец</th><th>Издатель</th><th>Действителен до</th><th>ИНН</th><th>Ключ</th></tr></thead>
<tbody>
{{range .Certs}}
<tr>
<td>{{.SubjectCN}}</td>
<td>{{.IssuerCN}}</td>
<td>{{.NotAfter.Format "02.01.2006"}}</td>
<td>{{if .INN}}<code>{{.INN}}</code>{{else}}—{{end}}</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" style="margin-top:12px">Пока сертификаты не импортированы.</p>
{{end}}
<div style="margin-top:20px;display:flex;justify-content:space-between">
<a href="/admin/wizard?step=2" class="btn" style="background:var(--card);text-decoration:none">К шагу 2</a>
<a href="/admin/wizard?step=4" class="btn" style="text-decoration:none">К шагу 4 →</a>
</div>
</div>
{{end}}
{{/* ============= ШАГ 4: НРД ============= */}}
{{if eq .Step 4}}
<div class="card">
<h2><span class="dot {{if .Done.NSD}}ok{{else}}err{{end}}"></span>Шаг 4. Интеграционный шлюз НРД</h2>
<p>Адрес web-сервиса ONYX и имя ключевого контейнера НРД.</p>
<div class="help-block">
<strong>Что это?</strong> Интеграционный шлюз (ИШ) НРД — это компонент, через который наши M2M-сообщения отправляются в НРД. У НРД есть 4 контура: <em>GUEST</em> (для разработки) и <em>TEST3</em> (предпродакшен), каждый в варианте ГОСТ или RSA.<br>
<strong>Где взять?</strong> Дистрибутив ИШ и инструкции — на сайте НРД <a href="https://www.nsd.ru/workflow/system/programs/" target="_blank">nsd.ru/workflow/system/programs/</a>. Доступ к тестовым контурам выдаётся НРД по заявке (см. <code>DOC/instr_podkl_stend_v3.pdf</code>).
</div>
<form method="post" action="/admin/setup/nsd" style="margin-top:12px;display:grid;gap:10px">
<div>
<label>Профиль <span class="tooltip" title="GUEST — гостевой контур для разработчиков (gost-gt.nsd.ru), TEST3 — тестовый предпродакшен (gost-t3.nsd.ru), prod — рабочий контур">?</span></label>
<select name="profile" id="nsd-profile" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;width:100%">
<option value="test3-gost" {{if eq .Settings.NSD.Profile "test3-gost"}}selected{{end}}>TEST3 · ГОСТ (рекомендуется для теста)</option>
<option value="test3-rsa" {{if eq .Settings.NSD.Profile "test3-rsa"}}selected{{end}}>TEST3 · RSA</option>
<option value="guest-gost" {{if eq .Settings.NSD.Profile "guest-gost"}}selected{{end}}>GUEST · ГОСТ</option>
<option value="guest-rsa" {{if eq .Settings.NSD.Profile "guest-rsa"}}selected{{end}}>GUEST · RSA</option>
<option value="prod" {{if eq .Settings.NSD.Profile "prod"}}selected{{end}}>prod — рабочий контур (осторожно)</option>
</select>
</div>
<div>
<label>URL ONYX <span class="tooltip" title="Базовый URL веб-сервиса ONYX. При выборе профиля выше — заполняется автоматически.">?</span></label>
<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%">
</div>
<button type="submit" class="btn" style="justify-self:start">Сохранить</button>
</form>
<script>
// Автозаполнение URL по выбранному профилю
document.getElementById('nsd-profile').addEventListener('change', function(e){
var urls = {
'test3-gost': 'https://gost-t3.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo',
'test3-rsa': 'https://rsa-t3.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo',
'guest-gost': 'https://gost-gt.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo',
'guest-rsa': 'https://rsa-gt.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo',
'prod': ''
};
var u = document.getElementById('nsd-url');
if (urls[e.target.value]) u.value = urls[e.target.value];
});
</script>
<div style="margin-top:20px;display:flex;justify-content:space-between">
<a href="/admin/wizard?step=3" class="btn" style="background:var(--card);text-decoration:none">К шагу 3</a>
{{if .Done.NSD}}<a href="/admin/wizard?step=5" class="btn" style="text-decoration:none">К шагу 5 →</a>{{else}}<a href="/admin/wizard?step=5&skip=nsd" class="btn" style="background:var(--card);text-decoration:none">Пропустить (mock-режим) →</a>{{end}}
</div>
</div>
{{end}}
{{/* ============= ШАГ 5: Тест-ран ============= */}}
{{if eq .Step 5}}
<div class="card">
<h2><span class="dot {{if .Done.TestRun}}ok{{else}}err{{end}}"></span>Шаг 5. Тестовая заявка</h2>
<p>Прогон полного цикла: создание заявки → валидация → подпись → отправка в НРД (или mock) → ожидание Decision → подтверждение.</p>
<div class="help-block">
<strong>Что произойдёт?</strong> Система создаст тестовую M2M-сделку, проведёт её через всю стейт-машину, и покажет результат каждого этапа. Если ИШ НРД не настроен — сработает mock (синтетический Decision через 3 секунды).
</div>
<form method="post" action="/admin/setup/test-run" style="margin-top:12px">
<button type="submit" class="btn" style="background:var(--ok);font-size:15px;padding:10px 20px">▶ Запустить тестовую заявку</button>
</form>
{{if .Settings.LastTest}}
<h3 style="margin-top:18px">Последний прогон: {{.Settings.LastTest.StartedAt.Format "02.01.2006 15:04:05"}}</h3>
<table>
<tr><td class="muted">Заявка</td><td><a href="/admin/claims/{{.Settings.LastTest.ClaimID}}">{{.Settings.LastTest.ClaimID}}</a></td></tr>
<tr><td class="muted">Финальное состояние</td><td>{{ruState .Settings.LastTest.FinalStatus}}</td></tr>
<tr><td class="muted">Результат</td><td>{{if .Settings.LastTest.OK}}<span style="color:var(--ok)">успех</span>{{else}}<span style="color:var(--err)">ошибка</span>{{end}}</td></tr>
{{if .Settings.LastTest.Message}}<tr><td class="muted">Сообщение</td><td>{{.Settings.LastTest.Message}}</td></tr>{{end}}
</table>
{{end}}
<h3 style="margin-top:18px">Итоговая сводка</h3>
<table>
<tr><td class="muted">PostgreSQL</td><td>{{if .Done.Postgres}}<span style="color:var(--ok)">настроен</span>{{else}}<span class="muted">in-memory</span>{{end}}</td></tr>
<tr><td class="muted">Крипто-провайдер</td><td>{{if .Done.Crypto}}<span style="color:var(--ok)">{{.Settings.Crypto.Provider}}</span>{{else}}<span style="color:var(--err)">не настроен</span>{{end}}</td></tr>
<tr><td class="muted">Сертификатов установлено</td><td>{{len .Certs}}</td></tr>
<tr><td class="muted">ИШ НРД</td><td>{{if .Done.NSD}}<span style="color:var(--ok)">{{.Settings.NSD.Profile}}</span>{{else}}<span class="muted">mock-режим</span>{{end}}</td></tr>
<tr><td class="muted">Тестовый прогон</td><td>{{if .Done.TestRun}}<span style="color:var(--ok)">пройден</span>{{else}}<span class="muted">не запускался</span>{{end}}</td></tr>
</table>
<div style="margin-top:20px;display:flex;justify-content:space-between">
<a href="/admin/wizard?step=4" class="btn" style="background:var(--card);text-decoration:none">К шагу 4</a>
<a href="/admin/" class="btn" style="text-decoration:none">Перейти к дашборду</a>
</div>
</div>
{{end}}
{{end}}
@@ -45,13 +45,25 @@ button:hover, .btn:hover { opacity: .9; }
<h1>lk-gateway</h1>
<nav>
<a href="/admin/" class="{{if eq .Active "home"}}active{{end}}">Дашборд</a>
<a href="/admin/wizard" class="{{if eq .Active "wizard"}}active{{end}}">Мастер настройки</a>
<a href="/admin/setup" class="{{if eq .Active "setup"}}active{{end}}">Настройка</a>
<a href="/admin/news" class="{{if eq .Active "news"}}active{{end}}">Новости</a>
<a href="/admin/claims" class="{{if eq .Active "claims"}}active{{end}}">Заявки</a>
<a href="/admin/status" class="{{if eq .Active "status"}}active{{end}}">Статус системы</a>
<a href="/admin/help" class="{{if eq .Active "help"}}active{{end}}">Инструкции</a>
</nav>
<span class="muted" style="margin-left:auto">{{.Now}}</span>
</header>
{{if .IsMockMode}}
<div style="background:rgba(232,177,58,0.15);border-bottom:2px solid var(--warn);padding:10px 24px;display:flex;align-items:center;gap:12px;font-size:13px">
<span style="font-size:18px">🟡</span>
<div>
<strong style="color:var(--warn)">РЕЖИМ ЭМУЛЯЦИИ</strong> — реального обмена с НРД нет.
<span class="muted" style="margin-left:6px">{{.MockReason}}</span>
</div>
<a href="/admin/wizard" style="margin-left:auto;font-size:13px">Настроить →</a>
</div>
{{end}}
<main>
{{template "content" .}}
</main>
+222 -52
View File
@@ -1,6 +1,20 @@
// Package igw — REST-клиент Интеграционного шлюза (ИШ) НРД.
// Тело пакета передаётся base64 в JSON; ИШ сам подписывает и
// упаковывает в ZIP-пакет ЭДО по правилам НРД.
// Документ-источник: DOC/instr-ish-rest-api.pdf (НРД, 2026).
//
// ИШ — серверное ПО НРД, которое:
// - принимает от нас сырой XML/ZIP M2M-документа;
// - сам подписывает его сертификатом УЦ МБ (ключ настроен в ИШ);
// - формирует пакет ЭДО по Правилам ЭДО НРД;
// - отправляет в НРД через Web-сервис ONYX;
// - принимает входящие пакеты M2MTD/M2MER от НРД;
// - проверяет подпись НРД (поле signs.status = VALID/INVALID);
// - выдаёт всё это клиенту через REST API.
//
// REST-эндпоинты (все по 200/JSON):
// POST /api/package/{channel}/file — отправить ZIP, вернёт id
// GET /api/package/status/{id} — статус: NEW | SENT | ERROR
// GET /api/package?channel=&type=... — список входящих
// GET /api/package/{id} — тело пакета (ZIP в base64)
package igw
import (
@@ -13,6 +27,7 @@ import (
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
@@ -53,21 +68,42 @@ func NewClient(baseURL string, opts ...Option) *Client {
return c
}
// SendPackage отправляет пакет в указанный канал ЭДО. Возвращает
// идентификатор пакета, присвоенный ИШ.
func (c *Client) SendPackage(ctx context.Context, channel, packageType string, body []byte) (string, error) {
// --------------- POST /api/package/{channel}/file ---------------
// sendBody — тело запроса на отправку пакета. По спецификации НРД
// (instr-ish-rest-api.pdf, раздел 2.5.1) поля Type/File.
type sendBody struct {
Type string `json:"Type"`
File string `json:"File"`
}
// sendResponse — ответ ИШ на отправку. Спецификация: 200 + JSON с ID.
// В новом документе НРД сам формат JSON не зафиксирован детально, поэтому
// принимаем три популярные формы: {id:..}, {package_id:..}, {ID:..}.
type sendResponse struct {
ID json.Number `json:"id,omitempty"`
PackageID json.Number `json:"package_id,omitempty"`
IDAlt json.Number `json:"ID,omitempty"`
}
// SendPackage отправляет ZIP-архив (M2MTransferRequest.xml + config.xml)
// в указанный канал ЭДО ИШ. Сигнатура совместима с предыдущей версией —
// packageType остался параметром для backward-compat, но в новом API
// он внутри ZIP'а (в config.xml/<package>), не в HTTP-теле.
//
// Возвращает идентификатор пакета (как строку — может быть числом или
// UUID, зависит от версии ИШ).
func (c *Client) SendPackage(ctx context.Context, channel, packageType string, zipBody []byte) (string, error) {
if channel == "" {
return "", errors.New("igw: channel пустой")
}
if packageType == "" {
return "", errors.New("igw: packageType пустой")
_ = packageType // не используется в новом API, кладётся внутрь ZIP/config.xml
if len(zipBody) == 0 {
return "", errors.New("igw: zipBody пустой")
}
payload := struct {
PackageType string `json:"package_type"`
Body string `json:"body"`
}{
PackageType: packageType,
Body: base64.StdEncoding.EncodeToString(body),
payload := sendBody{
Type: "archive",
File: base64.StdEncoding.EncodeToString(zipBody),
}
raw, err := json.Marshal(payload)
if err != nil {
@@ -80,27 +116,36 @@ func (c *Client) SendPackage(ctx context.Context, channel, packageType string, b
}
defer resp.Body.Close()
var out struct {
PackageID string `json:"package_id"`
}
var out sendResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return "", fmt.Errorf("igw: decode SendPackage response: %w", err)
}
if out.PackageID == "" {
return "", errors.New("igw: пустой package_id в ответе ИШ")
for _, v := range []json.Number{out.ID, out.PackageID, out.IDAlt} {
if s := string(v); s != "" {
return s, nil
}
return out.PackageID, nil
}
return "", errors.New("igw: пустой id в ответе ИШ")
}
// Status — состояние пакета у ИШ.
// --------------- GET /api/package/status/{id} ---------------
// Status — состояние отправленного пакета (раздел 2.5.2 инструкции).
// Status может быть NEW (новый), SENT (отправлен), ERROR (ошибка).
type Status struct {
PackageID string `json:"package_id"`
State string `json:"state"`
UpdatedAt time.Time `json:"updated_at"`
ErrorCode string `json:"error_code,omitempty"`
ErrorText string `json:"error_text,omitempty"`
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
}
// Status-константы для удобства.
const (
StatusNew = "NEW"
StatusSent = "SENT"
StatusError = "ERROR"
)
// GetStatus возвращает текущее состояние пакета по идентификатору.
func (c *Client) GetStatus(ctx context.Context, packageID string) (Status, error) {
if packageID == "" {
@@ -112,41 +157,79 @@ func (c *Client) GetStatus(ctx context.Context, packageID string) (Status, error
return Status{}, err
}
defer resp.Body.Close()
var s Status
if err := json.NewDecoder(resp.Body).Decode(&s); err != nil {
// Дублируем альтернативные имена полей (id|ID, status|state) — на
// случай различий между версиями ИШ.
var raw map[string]json.RawMessage
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
return Status{}, fmt.Errorf("igw: decode Status: %w", err)
}
s := Status{}
pickStr(raw, []string{"id", "ID", "package_id"}, &s.ID)
pickStr(raw, []string{"name"}, &s.Name)
pickStr(raw, []string{"status", "state"}, &s.Status)
pickStr(raw, []string{"error", "error_text", "error_code"}, &s.Error)
return s, nil
}
// Package — описание входящего пакета.
// --------------- GET /api/package?channel=&type=... ---------------
// ListFilter — параметры фильтрации входящих пакетов (раздел 2.6).
type ListFilter struct {
Channel string // обязательный — код канала ЭДО
Date time.Time // опц., YYYY-MM-DD
SinceID int // опц., скип до этого id
Count int // опц., лимит
Type string // опц., "M2MTD" | "M2MER" (без #)
ExcludeErrors bool // опц., исключать пакеты с ошибкой
}
// Sign — подпись пакета. ИШ сам проверяет и выдаёт результат: VALID/INVALID.
type Sign struct {
Serial string `json:"serial"`
Subject string `json:"subject"`
Description string `json:"description"`
Status string `json:"status"` // "VALID" | "INVALID"
}
// File — файл внутри пакета.
type File struct {
ID int `json:"id"`
Name string `json:"name"`
}
// Package — описание входящего пакета по списку (раздел 2.6).
type Package struct {
PackageID string `json:"package_id"`
PackageType string `json:"package_type"`
Channel string `json:"channel"`
ReceivedAt time.Time `json:"received_at"`
Body string `json:"body,omitempty"` // base64
ID int `json:"id"`
Name string `json:"name"`
Type string `json:"type"` // "M2MTD" | "M2MER"
State string `json:"state"` // "RECEIVED" | "ERROR" | "DELETED"
Files []File `json:"files,omitempty"`
Signs []Sign `json:"signs,omitempty"`
}
// DecodeBody возвращает декодированное содержимое пакета.
func (p Package) DecodeBody() ([]byte, error) {
if p.Body == "" {
return nil, nil
// ListIncoming возвращает список входящих пакетов от НРД по фильтрам.
// Если filter.Type не задан — возвращает оба типа M2MTD + M2MER.
func (c *Client) ListIncoming(ctx context.Context, filter ListFilter) ([]Package, error) {
if filter.Channel == "" {
return nil, errors.New("igw: ListFilter.Channel обязателен")
}
return base64.StdEncoding.DecodeString(p.Body)
}
// ListIncoming возвращает список входящих пакетов по фильтрам.
func (c *Client) ListIncoming(ctx context.Context, channel string, since time.Time, packageType string) ([]Package, error) {
q := url.Values{}
if channel != "" {
q.Set("channel", channel)
q.Set("channel", filter.Channel)
if !filter.Date.IsZero() {
q.Set("date", filter.Date.UTC().Format("2006-01-02"))
}
if !since.IsZero() {
q.Set("date", since.UTC().Format(time.RFC3339))
if filter.SinceID > 0 {
q.Set("id", strconv.Itoa(filter.SinceID))
}
if packageType != "" {
q.Set("type", packageType)
if filter.Count > 0 {
q.Set("count", strconv.Itoa(filter.Count))
}
if filter.Type != "" {
q.Set("type", filter.Type)
}
if filter.ExcludeErrors {
q.Set("excludeErrors", "true")
}
path := "/api/package?" + q.Encode()
resp, err := c.doRetry(ctx, http.MethodGet, path, nil, "")
@@ -154,15 +237,83 @@ func (c *Client) ListIncoming(ctx context.Context, channel string, since time.Ti
return nil, err
}
defer resp.Body.Close()
var out struct {
// ИШ может вернуть либо массив, либо {items: [...]}. Поддержим оба.
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("igw: read ListIncoming: %w", err)
}
body = bytes.TrimSpace(body)
if len(body) == 0 {
return nil, nil
}
if body[0] == '[' {
var arr []Package
if err := json.Unmarshal(body, &arr); err != nil {
return nil, fmt.Errorf("igw: decode ListIncoming (array): %w", err)
}
return arr, nil
}
var wrap struct {
Items []Package `json:"items"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("igw: decode ListIncoming: %w", err)
if err := json.Unmarshal(body, &wrap); err != nil {
return nil, fmt.Errorf("igw: decode ListIncoming (object): %w", err)
}
return out.Items, nil
return wrap.Items, nil
}
// --------------- GET /api/package/{id} ---------------
// GetPackage возвращает содержимое пакета по ID — ZIP-архив с файлами
// документа и отсоединёнными подписями. По спецификации (раздел 2.6.2)
// ИШ отвечает 200 + body = base64-encoded ZIP. Метод декодирует base64
// и возвращает сырые ZIP-байты.
func (c *Client) GetPackage(ctx context.Context, packageID int) ([]byte, error) {
if packageID <= 0 {
return nil, errors.New("igw: packageID должен быть > 0")
}
path := "/api/package/" + strconv.Itoa(packageID)
resp, err := c.doRetry(ctx, http.MethodGet, path, nil, "")
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("igw: read GetPackage: %w", err)
}
// ИШ возвращает либо чистый ZIP (Content-Type: application/zip),
// либо JSON с base64. Проверим по сигнатуре ZIP (PK\x03\x04).
if len(body) >= 4 && body[0] == 'P' && body[1] == 'K' &&
body[2] == 0x03 && body[3] == 0x04 {
return body, nil
}
// Иначе пробуем base64 (с JSON-обёрткой или без).
stripped := bytes.TrimSpace(body)
if len(stripped) > 1 && stripped[0] == '"' && stripped[len(stripped)-1] == '"' {
stripped = stripped[1 : len(stripped)-1]
}
// JSON-объект {"file":"..."} или {"body":"..."}
if len(stripped) > 0 && stripped[0] == '{' {
var obj map[string]string
if err := json.Unmarshal(stripped, &obj); err == nil {
for _, k := range []string{"file", "File", "body", "Body"} {
if v, ok := obj[k]; ok {
return base64.StdEncoding.DecodeString(v)
}
}
}
}
decoded, err := base64.StdEncoding.DecodeString(string(stripped))
if err == nil && len(decoded) >= 4 && decoded[0] == 'P' && decoded[1] == 'K' {
return decoded, nil
}
return body, nil
}
// --------------- общая HTTP-логика ---------------
// doRetry выполняет HTTP-запрос с ретраями на сетевые ошибки и 5xx.
func (c *Client) doRetry(ctx context.Context, method, path string, body io.Reader, contentType string) (*http.Response, error) {
var lastErr error
@@ -182,7 +333,7 @@ func (c *Client) doRetry(ctx context.Context, method, path string, body io.Reade
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Accept", "application/json, */*")
resp, err := c.httpClient.Do(req)
switch {
case err != nil:
@@ -207,3 +358,22 @@ func (c *Client) doRetry(ctx context.Context, method, path string, body io.Reade
}
return nil, fmt.Errorf("igw: исчерпаны ретраи: %w", lastErr)
}
// pickStr заполняет dest первым непустым значением из raw по списку ключей.
func pickStr(raw map[string]json.RawMessage, keys []string, dest *string) {
for _, k := range keys {
if v, ok := raw[k]; ok {
var s string
if err := json.Unmarshal(v, &s); err == nil && s != "" {
*dest = s
return
}
// Может быть числом — превращаем в строку.
var n json.Number
if err := json.Unmarshal(v, &n); err == nil && string(n) != "" {
*dest = string(n)
return
}
}
}
}
+107 -34
View File
@@ -2,32 +2,53 @@ package igw_test
import (
"context"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/igw"
)
// TestSendPackageHappyPath — отправка ZIP, ИШ возвращает id.
// Сценарий по DOC/instr-ish-rest-api.pdf раздел 2.5.1.
func TestSendPackageHappyPath(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/package/TEST3/file" {
if r.URL.Path != "/api/package/CH1/file" {
t.Errorf("неожиданный путь %q", r.URL.Path)
}
// Проверим что тело — это {Type: "archive", File: base64}.
var body struct {
Type string `json:"Type"`
File string `json:"File"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode body: %v", err)
}
if body.Type != "archive" {
t.Errorf("Type = %q, ожидалось archive", body.Type)
}
if body.File == "" {
t.Errorf("File пустой")
}
if _, err := base64.StdEncoding.DecodeString(body.File); err != nil {
t.Errorf("File не base64: %v", err)
}
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]string{"package_id": "pkg-123"})
_ = json.NewEncoder(w).Encode(map[string]any{"id": 123})
}))
defer srv.Close()
c := igw.NewClient(srv.URL, igw.WithRetry(0, time.Millisecond))
id, err := c.SendPackage(context.Background(), "TEST3", "#M2MTR", []byte("<xml/>"))
id, err := c.SendPackage(context.Background(), "CH1", "#M2MTR", []byte("PK\x03\x04zipbody"))
if err != nil {
t.Fatal(err)
}
if id != "pkg-123" {
t.Errorf("package_id = %q, ожидалось %q", id, "pkg-123")
if id != "123" {
t.Errorf("id = %q, ожидалось 123", id)
}
}
@@ -40,17 +61,17 @@ func TestSendPackageRetryOn500(t *testing.T) {
return
}
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]string{"package_id": "pkg-retry"})
_ = json.NewEncoder(w).Encode(map[string]any{"id": 999})
}))
defer srv.Close()
c := igw.NewClient(srv.URL, igw.WithRetry(3, time.Millisecond))
id, err := c.SendPackage(context.Background(), "TEST3", "#M2MTR", []byte("x"))
id, err := c.SendPackage(context.Background(), "CH1", "#M2MTR", []byte("x"))
if err != nil {
t.Fatal(err)
}
if id != "pkg-retry" {
t.Errorf("ожидалось pkg-retry, получено %q", id)
if id != "999" {
t.Errorf("id = %q, ожидалось 999", id)
}
if calls < 2 {
t.Errorf("ожидалось хотя бы 2 попытки, получено %d", calls)
@@ -67,7 +88,7 @@ func TestSendPackage4xxNoRetry(t *testing.T) {
defer srv.Close()
c := igw.NewClient(srv.URL, igw.WithRetry(3, time.Millisecond))
_, err := c.SendPackage(context.Background(), "TEST3", "#M2MTR", []byte("x"))
_, err := c.SendPackage(context.Background(), "CH1", "#M2MTR", []byte("x"))
if err == nil {
t.Fatal("ожидалась ошибка на 400")
}
@@ -76,60 +97,112 @@ func TestSendPackage4xxNoRetry(t *testing.T) {
}
}
// TestGetStatus — формат ответа по разделу 2.5.2 инструкции:
// {id: 123, name: "#M2MTR...zip", status: SENT|NEW|ERROR, error: "..."}.
func TestGetStatus(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/package/status/pkg-1" {
if r.URL.Path != "/api/package/status/123" {
t.Errorf("неожиданный путь %q", r.URL.Path)
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"package_id":"pkg-1","state":"delivered","updated_at":"2026-03-02T14:30:00Z"}`))
_, _ = w.Write([]byte(`{"id":123,"name":"#M2MTR20260320140624.zip","status":"SENT"}`))
}))
defer srv.Close()
c := igw.NewClient(srv.URL, igw.WithRetry(0, time.Millisecond))
st, err := c.GetStatus(context.Background(), "pkg-1")
st, err := c.GetStatus(context.Background(), "123")
if err != nil {
t.Fatal(err)
}
if st.State != "delivered" {
t.Errorf("state = %q, ожидалось delivered", st.State)
if st.Status != "SENT" {
t.Errorf("status = %q, ожидалось SENT", st.Status)
}
if !strings.Contains(st.Name, "M2MTR") {
t.Errorf("name = %q, ожидалось содержать M2MTR", st.Name)
}
}
// TestListIncoming — формат ответа по разделу 2.6: массив пакетов с полями
// channel/id/name/type/state/files/signs.
func TestListIncoming(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !contains(r.URL.RawQuery, "channel=TEST3") {
t.Errorf("в query нет channel: %s", r.URL.RawQuery)
q := r.URL.Query()
if q.Get("channel") != "CH1" {
t.Errorf("channel = %q, ожидалось CH1", q.Get("channel"))
}
if q.Get("type") != "M2MTD" {
t.Errorf("type = %q, ожидалось M2MTD", q.Get("type"))
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"items":[{"package_id":"p1","package_type":"#M2MTD","channel":"TEST3","received_at":"2026-03-02T14:00:00Z","body":""}]}`))
_, _ = w.Write([]byte(`[{
"channel":"CH1",
"id":22423,
"name":"#M2MTD20260320140624.ZIP",
"type":"M2MTD",
"state":"RECEIVED",
"files":[{"id":30112,"name":"M2MTD20260320140624.XML"}],
"signs":[{"serial":"40:50:14","subject":"INN=007702165310,CN=НРД","status":"VALID"}]
}]`))
}))
defer srv.Close()
c := igw.NewClient(srv.URL, igw.WithRetry(0, time.Millisecond))
pkgs, err := c.ListIncoming(context.Background(), "TEST3", time.Now().Add(-time.Hour), "#M2MTD")
pkgs, err := c.ListIncoming(context.Background(), igw.ListFilter{
Channel: "CH1",
Type: "M2MTD",
})
if err != nil {
t.Fatal(err)
}
if len(pkgs) != 1 || pkgs[0].PackageType != "#M2MTD" {
t.Errorf("неожиданный результат: %+v", pkgs)
if len(pkgs) != 1 {
t.Fatalf("ожидался 1 пакет, получено %d", len(pkgs))
}
body, err := pkgs[0].DecodeBody()
p := pkgs[0]
if p.ID != 22423 {
t.Errorf("ID = %d, ожидалось 22423", p.ID)
}
if p.State != "RECEIVED" {
t.Errorf("State = %q, ожидалось RECEIVED", p.State)
}
if len(p.Signs) != 1 || p.Signs[0].Status != "VALID" {
t.Errorf("Signs неверные: %+v", p.Signs)
}
}
// TestGetPackage — скачивание содержимого. ИШ может возвращать либо чистый
// ZIP, либо JSON с base64-полем. Тестируем оба случая.
func TestGetPackageRawZIP(t *testing.T) {
zipBytes := []byte("PK\x03\x04zip-content-here")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/package/22423" {
t.Errorf("неожиданный путь %q", r.URL.Path)
}
w.Header().Set("Content-Type", "application/zip")
_, _ = w.Write(zipBytes)
}))
defer srv.Close()
c := igw.NewClient(srv.URL, igw.WithRetry(0, time.Millisecond))
body, err := c.GetPackage(context.Background(), 22423)
if err != nil {
t.Errorf("DecodeBody: %v", err)
t.Fatal(err)
}
if body != nil {
t.Errorf("ожидалось пустое тело")
if string(body) != string(zipBytes) {
t.Errorf("body = %q, ожидалось %q", body, zipBytes)
}
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && (indexOf(s, substr) >= 0)
}
func indexOf(s, substr string) int {
for i := 0; i+len(substr) <= len(s); i++ {
if s[i:i+len(substr)] == substr {
return i
func TestGetPackageBase64InJSON(t *testing.T) {
zipBytes := []byte("PK\x03\x04zip-from-base64")
encoded := base64.StdEncoding.EncodeToString(zipBytes)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"file":"` + encoded + `"}`))
}))
defer srv.Close()
c := igw.NewClient(srv.URL, igw.WithRetry(0, time.Millisecond))
body, err := c.GetPackage(context.Background(), 1)
if err != nil {
t.Fatal(err)
}
if string(body) != string(zipBytes) {
t.Errorf("decoded = %q, ожидалось %q", body, zipBytes)
}
return -1
}
+173
View File
@@ -0,0 +1,173 @@
// pack.go — упаковщик/распаковщик ZIP-пакетов для ИШ НРД.
//
// Формат отправляемого пакета (раздел 2.3 инструкции):
// ZIP-архив содержит:
// - <doc>.xml — сам документ (M2MTransferRequest.xml)
// - config.xml — настроечный файл с указанием name и package
//
// Пример config.xml:
// <config>
// <name>doc.xml</name>
// <package>#M2MTR</package>
// </config>
//
// Формат входящего пакета (раздел 2.4 + раздел 2.6):
// ZIP-архив содержит:
// - <doc>.xml — сам документ (M2MTransferDecision.xml или M2MTransferResponse.xml)
// - winf.xml — транзитный конверт ЭДО НРД
// - <doc>.xml.sgn — отсоединённая подпись НРД (опц.)
//
// ИШ сам формирует пакет ЭДО (подписывает, добавляет winf.xml и т.д.).
// Наша задача — собрать ZIP с XML+config.xml и отправить.
package igw
import (
"archive/zip"
"bytes"
"encoding/xml"
"errors"
"fmt"
"io"
"path/filepath"
"strings"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdxml"
)
// config — содержимое config.xml внутри пакета.
type config struct {
XMLName xml.Name `xml:"config"`
Name string `xml:"name"`
Package string `xml:"package"`
}
// PackRequest упаковывает M2MTransferRequest в ZIP-архив для ИШ.
// docFileName — имя XML внутри архива (например "M2MTransferRequest.xml").
// Возвращает байты ZIP, готовые к отправке через POST /api/package/{channel}/file.
func PackRequest(req *m2m.M2MTransferRequest, docFileName string) ([]byte, error) {
if req == nil {
return nil, errors.New("igw: PackRequest: req=nil")
}
if docFileName == "" {
docFileName = "M2MTransferRequest.xml"
}
xmlBytes, err := nsdxml.Marshal(req)
if err != nil {
return nil, fmt.Errorf("igw: marshal M2MTransferRequest: %w", err)
}
return packZIP(xmlBytes, docFileName, "#M2MTR")
}
// PackXML упаковывает произвольный XML в ZIP с config.xml. Тип пакета
// (например "#M2MTR" / "#M2MTD") задаётся явно. Полезно когда XML уже
// собран снаружи (тесты, эталонные сообщения из инструкции).
func PackXML(xmlBytes []byte, docFileName, packageType string) ([]byte, error) {
if len(xmlBytes) == 0 {
return nil, errors.New("igw: PackXML: xmlBytes пустой")
}
if !strings.HasPrefix(packageType, "#") {
return nil, fmt.Errorf("igw: packageType должен начинаться с #, получено %q", packageType)
}
if docFileName == "" {
docFileName = "doc.xml"
}
return packZIP(xmlBytes, docFileName, packageType)
}
func packZIP(xmlBytes []byte, docFileName, packageType string) ([]byte, error) {
var buf bytes.Buffer
w := zip.NewWriter(&buf)
// 1. сам документ
fw, err := w.Create(docFileName)
if err != nil {
return nil, fmt.Errorf("igw: create %s in zip: %w", docFileName, err)
}
if _, err := fw.Write(xmlBytes); err != nil {
return nil, err
}
// 2. config.xml
cfg := config{Name: docFileName, Package: packageType}
cfgBytes, err := xml.MarshalIndent(cfg, "", " ")
if err != nil {
return nil, fmt.Errorf("igw: marshal config.xml: %w", err)
}
cfgWriter, err := w.Create("config.xml")
if err != nil {
return nil, fmt.Errorf("igw: create config.xml in zip: %w", err)
}
if _, err := cfgWriter.Write(cfgBytes); err != nil {
return nil, err
}
if err := w.Close(); err != nil {
return nil, fmt.Errorf("igw: close zip: %w", err)
}
return buf.Bytes(), nil
}
// UnpackedPackage — содержимое распакованного ZIP'а с входящим пакетом.
type UnpackedPackage struct {
DocXML []byte // первый XML, который не winf.xml и не config.xml
WinfXML []byte // транзитный конверт ЭДО (опц., присутствует у входящих от НРД)
Signature []byte // .sgn файл (отсоединённая подпись), опц.
Filenames []string
}
// UnpackPackage распаковывает ZIP-архив от ИШ и возвращает структурированно
// тело документа + winf.xml + отсоединённую подпись.
func UnpackPackage(zipBytes []byte) (*UnpackedPackage, error) {
r, err := zip.NewReader(bytes.NewReader(zipBytes), int64(len(zipBytes)))
if err != nil {
return nil, fmt.Errorf("igw: zip reader: %w", err)
}
out := &UnpackedPackage{}
for _, f := range r.File {
out.Filenames = append(out.Filenames, f.Name)
rc, err := f.Open()
if err != nil {
return nil, fmt.Errorf("igw: open %s in zip: %w", f.Name, err)
}
data, err := io.ReadAll(rc)
rc.Close()
if err != nil {
return nil, fmt.Errorf("igw: read %s from zip: %w", f.Name, err)
}
low := strings.ToLower(filepath.Base(f.Name))
switch {
case low == "winf.xml":
out.WinfXML = data
case low == "config.xml":
// config.xml в исходящих, во входящих обычно отсутствует — игнорируем
case strings.HasSuffix(low, ".sgn"):
out.Signature = data
case strings.HasSuffix(low, ".xml") && out.DocXML == nil:
out.DocXML = data
}
}
if out.DocXML == nil {
return nil, fmt.Errorf("igw: в ZIP нет основного .xml документа (файлы: %v)", out.Filenames)
}
return out, nil
}
// ParseDecision разбирает DocXML входящего пакета M2MTD в m2m.M2MTransferDecision.
func ParseDecision(docXML []byte) (*m2m.M2MTransferDecision, error) {
var d m2m.M2MTransferDecision
if err := nsdxml.Unmarshal(docXML, &d); err != nil {
return nil, fmt.Errorf("igw: parse M2MTransferDecision: %w", err)
}
return &d, nil
}
// ParseResponse разбирает DocXML входящего пакета M2MER в m2m.M2MTransferResponse.
func ParseResponse(docXML []byte) (*m2m.M2MTransferResponse, error) {
var r m2m.M2MTransferResponse
if err := nsdxml.Unmarshal(docXML, &r); err != nil {
return nil, fmt.Errorf("igw: parse M2MTransferResponse: %w", err)
}
return &r, nil
}
+114
View File
@@ -0,0 +1,114 @@
package igw_test
import (
"archive/zip"
"bytes"
"io"
"strings"
"testing"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/igw"
)
// TestPackXML_StructureMatchesSpec — после упаковки в ZIP должны быть
// ровно два файла: doc.xml + config.xml. Config содержит <name> и
// <package>. Это структура из раздела 2.3 инструкции НРД.
func TestPackXML_StructureMatchesSpec(t *testing.T) {
xmlBody := []byte(`<?xml version="1.0" encoding="windows-1251"?><rt:M2MTransferRequest/>`)
zipBytes, err := igw.PackXML(xmlBody, "M2MTransferRequest.xml", "#M2MTR")
if err != nil {
t.Fatal(err)
}
r, err := zip.NewReader(bytes.NewReader(zipBytes), int64(len(zipBytes)))
if err != nil {
t.Fatal(err)
}
if len(r.File) != 2 {
t.Fatalf("в ZIP должно быть 2 файла, получено %d", len(r.File))
}
got := map[string][]byte{}
for _, f := range r.File {
rc, _ := f.Open()
b, _ := io.ReadAll(rc)
rc.Close()
got[f.Name] = b
}
if _, ok := got["M2MTransferRequest.xml"]; !ok {
t.Errorf("в ZIP нет M2MTransferRequest.xml. Файлы: %v", keys(got))
}
cfg, ok := got["config.xml"]
if !ok {
t.Fatalf("в ZIP нет config.xml. Файлы: %v", keys(got))
}
cfgStr := string(cfg)
if !strings.Contains(cfgStr, "<name>M2MTransferRequest.xml</name>") {
t.Errorf("config.xml не содержит правильное <name>: %s", cfgStr)
}
if !strings.Contains(cfgStr, "<package>#M2MTR</package>") {
t.Errorf("config.xml не содержит правильное <package>: %s", cfgStr)
}
}
func TestPackXML_RejectsBadPackageType(t *testing.T) {
_, err := igw.PackXML([]byte("<x/>"), "doc.xml", "M2MTR")
if err == nil {
t.Fatal("ожидалась ошибка для packageType без #")
}
}
// TestUnpackPackage_FindsXMLAndWinf — распаковка эмулирует входящий ZIP
// от ИШ: M2MTD.xml + winf.xml + .sgn. Проверяем что UnpackPackage
// корректно раскладывает по полям.
func TestUnpackPackage_FindsXMLAndWinf(t *testing.T) {
docBody := []byte("<dn:M2MTransferDecision/>")
winfBody := []byte("<winf/>")
sgnBody := []byte("BINARY-SIGN-BLOB")
var buf bytes.Buffer
w := zip.NewWriter(&buf)
must := func(name string, data []byte) {
fw, _ := w.Create(name)
_, _ = fw.Write(data)
}
must("M2MTD20260320140624.XML", docBody)
must("winf.xml", winfBody)
must("M2MTD20260320140624.XML.sgn", sgnBody)
_ = w.Close()
pkg, err := igw.UnpackPackage(buf.Bytes())
if err != nil {
t.Fatal(err)
}
if string(pkg.DocXML) != string(docBody) {
t.Errorf("DocXML mismatch")
}
if string(pkg.WinfXML) != string(winfBody) {
t.Errorf("WinfXML mismatch")
}
if string(pkg.Signature) != string(sgnBody) {
t.Errorf("Signature mismatch")
}
if len(pkg.Filenames) != 3 {
t.Errorf("ожидалось 3 файла в Filenames, получено %d", len(pkg.Filenames))
}
}
func TestUnpackPackage_EmptyXML(t *testing.T) {
var buf bytes.Buffer
w := zip.NewWriter(&buf)
fw, _ := w.Create("winf.xml")
_, _ = fw.Write([]byte("<winf/>"))
_ = w.Close()
_, err := igw.UnpackPackage(buf.Bytes())
if err == nil {
t.Fatal("ожидалась ошибка когда в ZIP нет основного .xml")
}
}
func keys(m map[string][]byte) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
+223
View File
@@ -0,0 +1,223 @@
// robot.go — реализация поведения робота-автотеста НРД (MOEX МОСТ).
// Документ-источник: DOC/instruktsiya-po-testirovaniyu-s-robotom.pdf.
//
// Когда mock.Sender видит Header.ReceiverCode == RobotCode, он не
// использует default-логику (confirm/reject из Config), а формирует
// Decision по тестовому сценарию, выбранному отправителем через поле
// Data.InvestorInformation.IdentityDocument.DocumentSeries:
//
// 1111 — «Ответ с отказом». Все бумаги отвергаются с кодом ошибки,
// выбранным по двум последним символам DocumentNumber
// (01..09 → M2M01..M2M09).
// 2001 — «Принять все бумаги». Все бумаги подтверждаются. i-я позиция
// в DocumentNumber определяет номер депозитария-получателя
// (1 или 2 — реквизиты из набора депозитариев).
// 2002 — «Принять бумаги частично». i-я позиция = номер депозитария,
// если 0 — бумага отклоняется с кодом M2M05.
// 3333 — «Выступить принимающей стороной». Робот отвергает оригинал
// с M2M05 и (в реальности) формирует встречный M2MTransferRequest.
// В нашем mock'е пока эмитим только первое сообщение — встречный
// Request требует доработки приёмной стороны bj-server.
package mock
import (
"strconv"
"strings"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdxml"
)
// RobotCode — код депозитария-робота НРД. Документация: «Для того, чтобы
// робот получил сообщение, код робота должен быть указан в получателях —
// Header.ReceiverCode. КОД РОБОТА: MC0012500000.»
const RobotCode m2m.DeponentCode = "MC0012500000"
// Robot-сценарии (значения DocumentSeries).
const (
ScenarioReject = "1111"
ScenarioAcceptAll = "2001"
ScenarioAcceptPart = "2002"
ScenarioBeReceiver = "3333"
)
// robotDepositary — набор тестовых реквизитов депозитариев робота из
// «Набор данных депозитариев» в инструкции. Индексация с 1.
var robotDepositary = []struct {
INN string
DepCode m2m.DeponentCode
Account string
Section string
}{
{}, // индекс 0 — заглушка, чтобы индексация с 1 работала
{
INN: "7702165310",
DepCode: "MC0012500000",
Account: "HL2603250011",
Section: "31MC0012500000F00",
},
{
INN: "7702165310",
DepCode: "MC0012500000",
Account: "HL2603250011",
Section: "36MC0012500000F00",
},
}
// IsRobotTarget — true если заявка адресована роботу (по ReceiverCode).
func IsRobotTarget(req *m2m.M2MTransferRequest) bool {
if req == nil {
return false
}
return req.Header.ReceiverCode == RobotCode
}
// robotScenario извлекает выбранный сценарий из DocumentSeries.
// Если DocumentSeries не задан или содержит неизвестное значение —
// возвращает пустую строку (mock будет использовать default-логику).
func robotScenario(req *m2m.M2MTransferRequest) string {
if req.Data.InvestorInformation.IdentityDocument.DocumentSeries == nil {
return ""
}
s := string(*req.Data.InvestorInformation.IdentityDocument.DocumentSeries)
switch s {
case ScenarioReject, ScenarioAcceptAll, ScenarioAcceptPart, ScenarioBeReceiver:
return s
}
return ""
}
// simulateRobotDecision формирует Decision согласно выбранному
// сценарию робота. Возвращает nil если ReceiverCode != RobotCode или
// DocumentSeries не задан — в этом случае caller должен пойти по
// default-логике.
func simulateRobotDecision(req *m2m.M2MTransferRequest) *m2m.M2MTransferDecision {
if !IsRobotTarget(req) {
return nil
}
scenario := robotScenario(req)
if scenario == "" {
return nil
}
docNum := string(req.Data.InvestorInformation.IdentityDocument.DocumentNumber)
decision := &m2m.M2MTransferDecision{
Header: m2m.DecisionHeader{
GUID: req.Header.GUID,
CreationTimestamp: nsdxml.Now(),
SenderCode: req.Header.ReceiverCode, // робот = отправитель Decision
ReceiverCode: req.Header.SenderCode,
CostInfo: m2m.CostInfo{No: &m2m.CostInfoNo{}},
},
Data: m2m.DecisionData{
ReceivingDepository: req.Data.ReceivingDepository,
},
}
switch scenario {
case ScenarioReject:
// Все бумаги отвергаются с кодом, определённым последними 2
// символами DocumentNumber: «01» → M2M01, «02» → M2M02 и т.д.
errKey := lastTwoChars(docNum)
errCode := "M2M" + errKey
for _, sec := range req.Data.TransferredSecurities.Securities {
decision.Data.Securities = append(decision.Data.Securities,
m2m.DecisionSecurity{
ReferenceID: sec.ReferenceID,
TransferDecision: m2m.DecisionTransfer{
Rejection: &m2m.Rejection{Codes: []string{errCode}},
},
})
}
case ScenarioAcceptAll:
// Все бумаги подтверждаются. Депозитарий-получатель для каждой
// секции — по позиции в DocumentNumber: i-й символ = номер
// депозитария из robotDepositary. По умолчанию депозитарий 1.
for i, sec := range req.Data.TransferredSecurities.Securities {
depIdx := pickDepositary(docNum, i)
decision.Data.Securities = append(decision.Data.Securities,
m2m.DecisionSecurity{
ReferenceID: sec.ReferenceID,
TransferDecision: m2m.DecisionTransfer{
Confirmation: &m2m.Confirmation{
SettlementAccount: sec.SettlementAccount[0],
},
},
})
_ = depIdx // в этой версии депозитарий не подставляется в Confirmation
// (модель Confirmation минимальна), но индекс прочитан корректно.
}
case ScenarioAcceptPart:
// Частичный приём. i-я позиция = номер депозитария (1 или 2) или
// 0 — отклонить с M2M05.
for i, sec := range req.Data.TransferredSecurities.Securities {
depIdx := pickDepositary(docNum, i)
ds := m2m.DecisionSecurity{ReferenceID: sec.ReferenceID}
if depIdx == 0 {
ds.TransferDecision = m2m.DecisionTransfer{
Rejection: &m2m.Rejection{Codes: []string{"M2M05"}},
}
} else {
ds.TransferDecision = m2m.DecisionTransfer{
Confirmation: &m2m.Confirmation{
SettlementAccount: sec.SettlementAccount[0],
},
}
}
decision.Data.Securities = append(decision.Data.Securities, ds)
}
case ScenarioBeReceiver:
// Отвергаем оригинальный запрос с M2M05. (Второе сообщение —
// встречный M2MTransferRequest — будет реализовано когда у
// bj-server появится приёмная сторона.)
for _, sec := range req.Data.TransferredSecurities.Securities {
decision.Data.Securities = append(decision.Data.Securities,
m2m.DecisionSecurity{
ReferenceID: sec.ReferenceID,
TransferDecision: m2m.DecisionTransfer{
Rejection: &m2m.Rejection{Codes: []string{"M2M05"}},
},
})
}
}
return decision
}
// lastTwoChars возвращает последние 2 символа строки или "07" если строка
// короче (07 — типовой код «отказ принимающей стороны»).
func lastTwoChars(s string) string {
if len(s) < 2 {
return "07"
}
tail := s[len(s)-2:]
// Проверим что это цифры — иначе fallback.
if _, err := strconv.Atoi(tail); err != nil {
return "07"
}
return tail
}
// pickDepositary возвращает номер депозитария (1..2 или 0 для отказа)
// из позиции i строки docNum. Цифра > длины списка → депозитарий 1.
func pickDepositary(docNum string, i int) int {
docNum = strings.TrimSpace(docNum)
if i >= len(docNum) {
return 1
}
n, err := strconv.Atoi(docNum[i : i+1])
if err != nil {
return 1
}
if n == 0 {
return 0
}
if n >= len(robotDepositary) {
return 1
}
return n
}
+29
View File
@@ -170,6 +170,35 @@ func (s *Sender) emitDecision(ctx context.Context, req *m2m.M2MTransferRequest,
return
}
// Робот-автотест НРД: если ReceiverCode == MC0012500000 и DocumentSeries
// задан (1111/2001/2002/3333) — формируем ответ по сценарию из
// «Инструкции по тестированию с роботом» (DOC/instruktsiya-po-...pdf).
// Это позволяет проверить нашу логику обработки ответов до того, как
// у нас будет реальный ИШ + сертификат + доступ к TEST3.
if decision := simulateRobotDecision(req); decision != nil {
s.mu.Lock()
// Грубая статистика: считаем «робот-ответ» как Confirmed если хоть
// одна бумага подтверждена, иначе Rejected.
hasConfirm := false
for _, ds := range decision.Data.Securities {
if ds.TransferDecision.Confirmation != nil {
hasConfirm = true
break
}
}
if hasConfirm {
s.stats.Confirmed++
} else {
s.stats.Rejected++
}
s.mu.Unlock()
select {
case <-ctx.Done():
case s.decisions <- decision:
}
return
}
decision := &m2m.M2MTransferDecision{
Header: m2m.DecisionHeader{
GUID: req.Header.GUID,