Compare commits
12 Commits
0ef75e05e8
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5440ebe152 | |||
| 6e503433d4 | |||
| bac55cbdfd | |||
| 7a7aa0cf6c | |||
| de41aea00c | |||
| 5fa6ea6ab1 | |||
| 1ffe62133c | |||
| 19a2b6dda4 | |||
| 93f3ec240c | |||
| f1e05c0ca3 | |||
| 2142c4f586 | |||
| cb0f7efd4c |
+10
@@ -1,6 +1,7 @@
|
|||||||
# Сборки
|
# Сборки
|
||||||
/bin/
|
/bin/
|
||||||
/dist/
|
/dist/
|
||||||
|
!/dist/ish/README.md
|
||||||
*.exe
|
*.exe
|
||||||
*.test
|
*.test
|
||||||
*.out
|
*.out
|
||||||
@@ -58,3 +59,12 @@ test-results/
|
|||||||
|
|
||||||
# macOS
|
# macOS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# Doc-watcher: бэкапы при переустановке свежих версий
|
||||||
|
DOC/*.pdf.bak
|
||||||
|
DOC/*.bak.pdf
|
||||||
|
|
||||||
|
# Дистрибутив ИШ НРД (большой, ~120 МБ) — не коммитим в git
|
||||||
|
/dist/ish/*.deb
|
||||||
|
/dist/ish/*.SGN
|
||||||
|
/dist/ish/*.exe
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Спросить пользователя, если непонятно** — не стесняйся. Лучше задать вопрос чем сделать неверное предположение.
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
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.
@@ -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
|
||||||
@@ -110,15 +110,20 @@ func runNSDPoller(ctx context.Context, profileName string) {
|
|||||||
return
|
return
|
||||||
case <-t.C:
|
case <-t.C:
|
||||||
for _, kind := range nsdadapter.IncomingPackageKinds() {
|
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 {
|
if err != nil {
|
||||||
log.Printf("%s: NSD poller ListIncoming(%s, %s): %v", serviceName, profile.Channel, kind, err)
|
log.Printf("%s: NSD poller ListIncoming(%s, %s): %v", serviceName, profile.Channel, kind, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, p := range pkgs {
|
for _, p := range pkgs {
|
||||||
log.Printf("%s: NSD входящий пакет %s типа %s (канал %s, получен %s)",
|
log.Printf("%s: NSD входящий пакет id=%d (%s) типа %s, канал %s, state %s",
|
||||||
serviceName, p.PackageID, p.PackageType, p.Channel, p.ReceivedAt.Format(time.RFC3339))
|
serviceName, p.ID, p.Name, p.Type, p.Channel, p.State)
|
||||||
// TODO(M3): парсить тело пакета, передавать в lkgateway.Service.ApplyDecision
|
// TODO(M3): GetPackage(p.ID) → unpack ZIP → парсить XML →
|
||||||
|
// передавать в lkgateway.Service.ApplyDecision
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
since = time.Now().UTC()
|
since = time.Now().UTC()
|
||||||
|
|||||||
@@ -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 МОСТ.** На нашей стороне всё уже готово — задержки только во внешних запросах.
|
||||||
Executable
+114
@@ -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 "================================================================"
|
||||||
Executable
+134
@@ -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 "================================================================"
|
||||||
Executable
+109
@@ -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 "================================================================"
|
||||||
Executable
+89
@@ -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 "================================================================"
|
||||||
Executable
+384
@@ -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
|
||||||
Vendored
+62
@@ -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
|
||||||
@@ -20,7 +20,8 @@ var templatesFS embed.FS
|
|||||||
// {{define "content"}} в разных файлах.
|
// {{define "content"}} в разных файлах.
|
||||||
type admin struct {
|
type admin struct {
|
||||||
home, claims, claim, status, setup *template.Template
|
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 — функции, доступные внутри шаблонов. Главная задача —
|
// templateFuncs — функции, доступные внутри шаблонов. Главная задача —
|
||||||
@@ -30,6 +31,7 @@ var templateFuncs = template.FuncMap{
|
|||||||
"ru": russianText,
|
"ru": russianText,
|
||||||
"ruState": russianState,
|
"ruState": russianState,
|
||||||
"ruOutcome": russianOutcome,
|
"ruOutcome": russianOutcome,
|
||||||
|
"now": time.Now,
|
||||||
}
|
}
|
||||||
|
|
||||||
// russianState переводит технический FSM-state в человекочитаемый
|
// russianState переводит технический FSM-state в человекочитаемый
|
||||||
@@ -121,19 +123,44 @@ func newAdmin() (*admin, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("parse admin_help_systems: %w", err)
|
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{
|
return &admin{
|
||||||
home: home, claims: claims, claim: claim, status: status, setup: setup,
|
home: home, claims: claims, claim: claim, status: status, setup: setup,
|
||||||
help: help, helpDatabase: helpDB, helpLK: helpLK, helpCryptoPro: helpCP, helpSystems: helpSys,
|
help: help, helpDatabase: helpDB, helpLK: helpLK, helpCryptoPro: helpCP, helpSystems: helpSys,
|
||||||
|
helpRobot: helpRobot, helpArchitecture: helpArch,
|
||||||
|
wizard: wizard, news: news,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// page — общий "конверт" данных для всех шаблонов.
|
// page — общий "конверт" данных для всех шаблонов.
|
||||||
type page struct {
|
type page struct {
|
||||||
Title string
|
Title string
|
||||||
Active string
|
Active string
|
||||||
Now string
|
Now string
|
||||||
|
IsMockMode bool // true если ИШ не настроен — bj-server в режиме эмуляции
|
||||||
|
MockReason string // короткое описание почему mock
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// globalRC — ссылка на runtime-конфиг для template-funcs/page helpers.
|
||||||
|
// Заполняется один раз в RegisterAdmin. Альтернатива — таскать rc через
|
||||||
|
// все renderXxx-функции, что шумно при широком фан-ауте.
|
||||||
|
var globalRC *RuntimeConfig
|
||||||
|
|
||||||
// homeData — данные дашборда.
|
// homeData — данные дашборда.
|
||||||
type homeData struct {
|
type homeData struct {
|
||||||
page
|
page
|
||||||
@@ -145,6 +172,7 @@ type homeData struct {
|
|||||||
Failed int
|
Failed int
|
||||||
}
|
}
|
||||||
Recent []ClaimView
|
Recent []ClaimView
|
||||||
|
News []NewsItem // top-3 активных или свежих новостей
|
||||||
}
|
}
|
||||||
|
|
||||||
// claimsData — данные журнала.
|
// claimsData — данные журнала.
|
||||||
@@ -169,17 +197,18 @@ type statusData struct {
|
|||||||
// RegisterAdmin вешает HTML-маршруты /admin/* на mux. Возвращает admin
|
// RegisterAdmin вешает HTML-маршруты /admin/* на mux. Возвращает admin
|
||||||
// со всеми загруженными шаблонами — вызывающий может прокинуть его в
|
// со всеми загруженными шаблонами — вызывающий может прокинуть его в
|
||||||
// registerSetup для добавления вкладки «Настройка».
|
// 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()
|
a, err := newAdmin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
globalRC = rc
|
||||||
|
|
||||||
mux.HandleFunc("/admin/", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/admin/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
p := strings.TrimPrefix(r.URL.Path, "/admin/")
|
p := strings.TrimPrefix(r.URL.Path, "/admin/")
|
||||||
switch {
|
switch {
|
||||||
case p == "" || p == "index" || p == "home":
|
case p == "" || p == "index" || p == "home":
|
||||||
a.renderHome(w, r, svc, getOpts())
|
a.renderHome(w, r, svc, rc, getOpts())
|
||||||
case p == "claims":
|
case p == "claims":
|
||||||
a.renderClaims(w, r, svc)
|
a.renderClaims(w, r, svc)
|
||||||
case strings.HasPrefix(p, "claims/"):
|
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"))
|
render(w, a.helpCryptoPro, nowPage("КриптоПро", "help"))
|
||||||
case p == "help/systems":
|
case p == "help/systems":
|
||||||
render(w, a.helpSystems, nowPage("Внешние системы", "help"))
|
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:
|
default:
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
}
|
}
|
||||||
@@ -207,7 +240,7 @@ func RegisterAdmin(mux *http.ServeMux, svc *Service, getOpts func() CheckOptions
|
|||||||
return a, nil
|
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()
|
ctx := r.Context()
|
||||||
status := CheckAll(ctx, opts)
|
status := CheckAll(ctx, opts)
|
||||||
recent, err := svc.ListClaims(ctx, m2mcore.Filter{Limit: 10})
|
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"),
|
page: nowPage("Дашборд", "home"),
|
||||||
Status: status,
|
Status: status,
|
||||||
Recent: recent.Items,
|
Recent: recent.Items,
|
||||||
|
News: topNews(rc.Snapshot().News.Items, 3),
|
||||||
}
|
}
|
||||||
full, err := svc.ListClaims(ctx, m2mcore.Filter{Limit: 200})
|
full, err := svc.ListClaims(ctx, m2mcore.Filter{Limit: 200})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -271,5 +305,52 @@ func render(w http.ResponseWriter, t *template.Template, data any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func nowPage(title, active string) page {
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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.")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,10 +24,68 @@ type Settings struct {
|
|||||||
Crypto CryptoSettings `json:"crypto"`
|
Crypto CryptoSettings `json:"crypto"`
|
||||||
NSD NSDSettings `json:"nsd"`
|
NSD NSDSettings `json:"nsd"`
|
||||||
LK LKSettings `json:"lk"`
|
LK LKSettings `json:"lk"`
|
||||||
|
CACerts CACertsSettings `json:"ca_certs"`
|
||||||
|
News NewsSettings `json:"news"`
|
||||||
LastTest *TestRunResult `json:"last_test,omitempty"`
|
LastTest *TestRunResult `json:"last_test,omitempty"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
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).
|
// PostgresSettings — DSN для подключения к БД (M2-шаг-3).
|
||||||
type PostgresSettings struct {
|
type PostgresSettings struct {
|
||||||
DSN string `json:"dsn"`
|
DSN string `json:"dsn"`
|
||||||
@@ -126,6 +184,64 @@ func (r *RuntimeConfig) UpdateNSD(s NSDSettings) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UpdateLK сохраняет LK callback URL.
|
// 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 {
|
func (r *RuntimeConfig) UpdateLK(s LKSettings) error {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
r.data.LK = s
|
r.data.LK = s
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ func NewServer(cfg ServerConfig) (*Server, error) {
|
|||||||
checkOpts = cfg.CheckOptions
|
checkOpts = cfg.CheckOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
adminTpl, err := RegisterAdmin(mux, svc, checkOpts)
|
adminTpl, err := RegisterAdmin(mux, svc, rc, checkOpts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -159,6 +159,18 @@ func (s *Server) Mux() http.Handler { return s.mux }
|
|||||||
func (s *Server) Run(ctx context.Context) error {
|
func (s *Server) Run(ctx context.Context) error {
|
||||||
go s.consumeDecisions(ctx)
|
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)
|
errCh := make(chan error, 1)
|
||||||
go func() {
|
go func() {
|
||||||
log.Printf("lk-gateway: listen %s", s.cfg.Addr)
|
log.Printf("lk-gateway: listen %s", s.cfg.Addr)
|
||||||
|
|||||||
+236
-15
@@ -11,6 +11,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -59,6 +60,7 @@ func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service
|
|||||||
h.renderSetup(w, r, "")
|
h.renderSetup(w, r, "")
|
||||||
})
|
})
|
||||||
mux.HandleFunc("/admin/setup/postgres", h.savePostgres)
|
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", h.saveCrypto)
|
||||||
mux.HandleFunc("/admin/setup/crypto/check", h.checkCrypto)
|
mux.HandleFunc("/admin/setup/crypto/check", h.checkCrypto)
|
||||||
mux.HandleFunc("/admin/setup/crypto/activate", h.activateLicense)
|
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/nsd", h.saveNSD)
|
||||||
mux.HandleFunc("/admin/setup/lk", h.saveLK)
|
mux.HandleFunc("/admin/setup/lk", h.saveLK)
|
||||||
mux.HandleFunc("/admin/setup/test-run", h.testRun)
|
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).
|
// installCryptoPro — POST /admin/setup/crypto/install (multipart).
|
||||||
@@ -311,13 +431,14 @@ func (h *setupHandlers) checkCrypto(w http.ResponseWriter, r *http.Request) {
|
|||||||
// SetupData — данные для шаблона admin_setup.html.
|
// SetupData — данные для шаблона admin_setup.html.
|
||||||
type SetupData struct {
|
type SetupData struct {
|
||||||
page
|
page
|
||||||
Settings Settings
|
Settings Settings
|
||||||
Readiness []Readiness
|
Readiness []Readiness
|
||||||
ReadyCount int
|
ReadyCount int
|
||||||
TotalCount int
|
TotalCount int
|
||||||
Certificates []cryptocli.Certificate
|
Certificates []cryptocli.Certificate
|
||||||
Flash string
|
FlashContainers []FlashContainer
|
||||||
Error string
|
Flash string
|
||||||
|
Error string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *setupHandlers) renderSetup(w http.ResponseWriter, _ *http.Request, flash string) {
|
func (h *setupHandlers) renderSetup(w http.ResponseWriter, _ *http.Request, flash string) {
|
||||||
@@ -330,13 +451,14 @@ func (h *setupHandlers) renderSetup(w http.ResponseWriter, _ *http.Request, flas
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
data := SetupData{
|
data := SetupData{
|
||||||
page: nowPage("Настройка", "setup"),
|
page: nowPage("Настройка", "setup"),
|
||||||
Settings: s,
|
Settings: s,
|
||||||
Readiness: r,
|
Readiness: r,
|
||||||
ReadyCount: ready,
|
ReadyCount: ready,
|
||||||
TotalCount: len(r),
|
TotalCount: len(r),
|
||||||
Certificates: h.listCertsForUI(),
|
Certificates: h.listCertsForUI(),
|
||||||
Flash: flash,
|
FlashContainers: scanFlashContainers(),
|
||||||
|
Flash: flash,
|
||||||
}
|
}
|
||||||
if errVal := errMsgFromQuery(_q(w)); errVal != "" {
|
if errVal := errMsgFromQuery(_q(w)); errVal != "" {
|
||||||
data.Error = errVal
|
data.Error = errVal
|
||||||
@@ -365,6 +487,88 @@ func (h *setupHandlers) savePostgres(w http.ResponseWriter, r *http.Request) {
|
|||||||
setupFlash(w, r, "PostgreSQL настройки сохранены")
|
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) {
|
func (h *setupHandlers) saveCrypto(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||||
@@ -573,8 +777,25 @@ func tryHTTPHealth(u string) error {
|
|||||||
return nil
|
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) {
|
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)
|
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>
|
<p class="muted">ИШ НРД (профили GUEST/TEST3/PROD), команда Fansy (ETL в staging), уведомления (e-mail, Yandex Messenger, Telegram), порядок согласования.</p>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</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>
|
</div>
|
||||||
{{end}}
|
{{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"}}
|
{{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="grid">
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="stat-label">Всего сделок</div>
|
<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>
|
<h2><span class="dot {{if .Settings.Postgres.DSN}}ok{{else}}err{{end}}"></span>PostgreSQL</h2>
|
||||||
<p class="muted">Принимающая БД (fansy-store) и журнал сделок m2m-core. Сейчас:
|
<p class="muted">Принимающая БД (fansy-store) и журнал сделок m2m-core. Сейчас:
|
||||||
{{if .Settings.Postgres.DSN}}<code>настроено</code>{{else}}<code>in-memory</code> (M2-демо){{end}}.</p>
|
{{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">
|
<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">
|
<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">
|
<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>
|
</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>
|
<button type="submit" class="btn" style="background:var(--accent);color:white;border:none;padding:8px 16px;border-radius:4px;margin-top:8px">Сохранить и проверить</button>
|
||||||
</form>
|
</form>
|
||||||
</details>
|
</details>
|
||||||
@@ -135,6 +147,84 @@
|
|||||||
</details>
|
</details>
|
||||||
</div>
|
</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 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 / ИШ НРД -->
|
<!-- nsd-adapter / ИШ НРД -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2><span class="dot {{if .Settings.NSD.IGWBaseURL}}ok{{else}}err{{end}}"></span>Интеграционный шлюз НРД (ИШ)</h2>
|
<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 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>
|
<h1>lk-gateway</h1>
|
||||||
<nav>
|
<nav>
|
||||||
<a href="/admin/" class="{{if eq .Active "home"}}active{{end}}">Дашборд</a>
|
<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/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/claims" class="{{if eq .Active "claims"}}active{{end}}">Заявки</a>
|
||||||
<a href="/admin/status" class="{{if eq .Active "status"}}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>
|
<a href="/admin/help" class="{{if eq .Active "help"}}active{{end}}">Инструкции</a>
|
||||||
</nav>
|
</nav>
|
||||||
<span class="muted" style="margin-left:auto">{{.Now}}</span>
|
<span class="muted" style="margin-left:auto">{{.Now}}</span>
|
||||||
</header>
|
</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>
|
<main>
|
||||||
{{template "content" .}}
|
{{template "content" .}}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,6 +1,20 @@
|
|||||||
// Package igw — REST-клиент Интеграционного шлюза (ИШ) НРД.
|
// Package igw — REST-клиент Интеграционного шлюза (ИШ) НРД.
|
||||||
// Тело пакета передаётся base64 в JSON; ИШ сам подписывает и
|
// Документ-источник: DOC/instr-ish-rest-api.pdf (НРД, 2026).
|
||||||
// упаковывает в ZIP-пакет ЭДО по правилам НРД.
|
//
|
||||||
|
// ИШ — серверное ПО НРД, которое:
|
||||||
|
// - принимает от нас сырой 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
|
package igw
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -13,6 +27,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -53,21 +68,42 @@ func NewClient(baseURL string, opts ...Option) *Client {
|
|||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendPackage отправляет пакет в указанный канал ЭДО. Возвращает
|
// --------------- POST /api/package/{channel}/file ---------------
|
||||||
// идентификатор пакета, присвоенный ИШ.
|
|
||||||
func (c *Client) SendPackage(ctx context.Context, channel, packageType string, body []byte) (string, error) {
|
// 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 == "" {
|
if channel == "" {
|
||||||
return "", errors.New("igw: channel пустой")
|
return "", errors.New("igw: channel пустой")
|
||||||
}
|
}
|
||||||
if packageType == "" {
|
_ = packageType // не используется в новом API, кладётся внутрь ZIP/config.xml
|
||||||
return "", errors.New("igw: packageType пустой")
|
if len(zipBody) == 0 {
|
||||||
|
return "", errors.New("igw: zipBody пустой")
|
||||||
}
|
}
|
||||||
payload := struct {
|
payload := sendBody{
|
||||||
PackageType string `json:"package_type"`
|
Type: "archive",
|
||||||
Body string `json:"body"`
|
File: base64.StdEncoding.EncodeToString(zipBody),
|
||||||
}{
|
|
||||||
PackageType: packageType,
|
|
||||||
Body: base64.StdEncoding.EncodeToString(body),
|
|
||||||
}
|
}
|
||||||
raw, err := json.Marshal(payload)
|
raw, err := json.Marshal(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -80,27 +116,36 @@ func (c *Client) SendPackage(ctx context.Context, channel, packageType string, b
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
var out struct {
|
var out sendResponse
|
||||||
PackageID string `json:"package_id"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||||
return "", fmt.Errorf("igw: decode SendPackage response: %w", err)
|
return "", fmt.Errorf("igw: decode SendPackage response: %w", err)
|
||||||
}
|
}
|
||||||
if out.PackageID == "" {
|
for _, v := range []json.Number{out.ID, out.PackageID, out.IDAlt} {
|
||||||
return "", errors.New("igw: пустой package_id в ответе ИШ")
|
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 {
|
type Status struct {
|
||||||
PackageID string `json:"package_id"`
|
ID string `json:"id"`
|
||||||
State string `json:"state"`
|
Name string `json:"name"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
Status string `json:"status"`
|
||||||
ErrorCode string `json:"error_code,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
ErrorText string `json:"error_text,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Status-константы для удобства.
|
||||||
|
const (
|
||||||
|
StatusNew = "NEW"
|
||||||
|
StatusSent = "SENT"
|
||||||
|
StatusError = "ERROR"
|
||||||
|
)
|
||||||
|
|
||||||
// GetStatus возвращает текущее состояние пакета по идентификатору.
|
// GetStatus возвращает текущее состояние пакета по идентификатору.
|
||||||
func (c *Client) GetStatus(ctx context.Context, packageID string) (Status, error) {
|
func (c *Client) GetStatus(ctx context.Context, packageID string) (Status, error) {
|
||||||
if packageID == "" {
|
if packageID == "" {
|
||||||
@@ -112,41 +157,79 @@ func (c *Client) GetStatus(ctx context.Context, packageID string) (Status, error
|
|||||||
return Status{}, err
|
return Status{}, err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
var s Status
|
// Дублируем альтернативные имена полей (id|ID, status|state) — на
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&s); err != nil {
|
// случай различий между версиями ИШ.
|
||||||
|
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)
|
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
|
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 {
|
type Package struct {
|
||||||
PackageID string `json:"package_id"`
|
Channel string `json:"channel"`
|
||||||
PackageType string `json:"package_type"`
|
ID int `json:"id"`
|
||||||
Channel string `json:"channel"`
|
Name string `json:"name"`
|
||||||
ReceivedAt time.Time `json:"received_at"`
|
Type string `json:"type"` // "M2MTD" | "M2MER"
|
||||||
Body string `json:"body,omitempty"` // base64
|
State string `json:"state"` // "RECEIVED" | "ERROR" | "DELETED"
|
||||||
|
Files []File `json:"files,omitempty"`
|
||||||
|
Signs []Sign `json:"signs,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DecodeBody возвращает декодированное содержимое пакета.
|
// ListIncoming возвращает список входящих пакетов от НРД по фильтрам.
|
||||||
func (p Package) DecodeBody() ([]byte, error) {
|
// Если filter.Type не задан — возвращает оба типа M2MTD + M2MER.
|
||||||
if p.Body == "" {
|
func (c *Client) ListIncoming(ctx context.Context, filter ListFilter) ([]Package, error) {
|
||||||
return nil, nil
|
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{}
|
q := url.Values{}
|
||||||
if channel != "" {
|
q.Set("channel", filter.Channel)
|
||||||
q.Set("channel", channel)
|
if !filter.Date.IsZero() {
|
||||||
|
q.Set("date", filter.Date.UTC().Format("2006-01-02"))
|
||||||
}
|
}
|
||||||
if !since.IsZero() {
|
if filter.SinceID > 0 {
|
||||||
q.Set("date", since.UTC().Format(time.RFC3339))
|
q.Set("id", strconv.Itoa(filter.SinceID))
|
||||||
}
|
}
|
||||||
if packageType != "" {
|
if filter.Count > 0 {
|
||||||
q.Set("type", packageType)
|
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()
|
path := "/api/package?" + q.Encode()
|
||||||
resp, err := c.doRetry(ctx, http.MethodGet, path, nil, "")
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
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"`
|
Items []Package `json:"items"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
if err := json.Unmarshal(body, &wrap); err != nil {
|
||||||
return nil, fmt.Errorf("igw: decode ListIncoming: %w", err)
|
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.
|
// doRetry выполняет HTTP-запрос с ретраями на сетевые ошибки и 5xx.
|
||||||
func (c *Client) doRetry(ctx context.Context, method, path string, body io.Reader, contentType string) (*http.Response, error) {
|
func (c *Client) doRetry(ctx context.Context, method, path string, body io.Reader, contentType string) (*http.Response, error) {
|
||||||
var lastErr error
|
var lastErr error
|
||||||
@@ -182,7 +333,7 @@ func (c *Client) doRetry(ctx context.Context, method, path string, body io.Reade
|
|||||||
if contentType != "" {
|
if contentType != "" {
|
||||||
req.Header.Set("Content-Type", 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)
|
resp, err := c.httpClient.Do(req)
|
||||||
switch {
|
switch {
|
||||||
case err != nil:
|
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)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,32 +2,53 @@ package igw_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/igw"
|
"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) {
|
func TestSendPackageHappyPath(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
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)
|
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)
|
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()
|
defer srv.Close()
|
||||||
|
|
||||||
c := igw.NewClient(srv.URL, igw.WithRetry(0, time.Millisecond))
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if id != "pkg-123" {
|
if id != "123" {
|
||||||
t.Errorf("package_id = %q, ожидалось %q", id, "pkg-123")
|
t.Errorf("id = %q, ожидалось 123", id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,17 +61,17 @@ func TestSendPackageRetryOn500(t *testing.T) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusOK)
|
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()
|
defer srv.Close()
|
||||||
|
|
||||||
c := igw.NewClient(srv.URL, igw.WithRetry(3, time.Millisecond))
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if id != "pkg-retry" {
|
if id != "999" {
|
||||||
t.Errorf("ожидалось pkg-retry, получено %q", id)
|
t.Errorf("id = %q, ожидалось 999", id)
|
||||||
}
|
}
|
||||||
if calls < 2 {
|
if calls < 2 {
|
||||||
t.Errorf("ожидалось хотя бы 2 попытки, получено %d", calls)
|
t.Errorf("ожидалось хотя бы 2 попытки, получено %d", calls)
|
||||||
@@ -67,7 +88,7 @@ func TestSendPackage4xxNoRetry(t *testing.T) {
|
|||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
c := igw.NewClient(srv.URL, igw.WithRetry(3, time.Millisecond))
|
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 {
|
if err == nil {
|
||||||
t.Fatal("ожидалась ошибка на 400")
|
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) {
|
func TestGetStatus(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
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)
|
t.Errorf("неожиданный путь %q", r.URL.Path)
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusOK)
|
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()
|
defer srv.Close()
|
||||||
c := igw.NewClient(srv.URL, igw.WithRetry(0, time.Millisecond))
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if st.State != "delivered" {
|
if st.Status != "SENT" {
|
||||||
t.Errorf("state = %q, ожидалось delivered", st.State)
|
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) {
|
func TestListIncoming(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if !contains(r.URL.RawQuery, "channel=TEST3") {
|
q := r.URL.Query()
|
||||||
t.Errorf("в query нет channel: %s", r.URL.RawQuery)
|
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.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()
|
defer srv.Close()
|
||||||
c := igw.NewClient(srv.URL, igw.WithRetry(0, time.Millisecond))
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(pkgs) != 1 || pkgs[0].PackageType != "#M2MTD" {
|
if len(pkgs) != 1 {
|
||||||
t.Errorf("неожиданный результат: %+v", pkgs)
|
t.Fatalf("ожидался 1 пакет, получено %d", len(pkgs))
|
||||||
}
|
}
|
||||||
body, err := pkgs[0].DecodeBody()
|
p := pkgs[0]
|
||||||
if err != nil {
|
if p.ID != 22423 {
|
||||||
t.Errorf("DecodeBody: %v", err)
|
t.Errorf("ID = %d, ожидалось 22423", p.ID)
|
||||||
}
|
}
|
||||||
if body != nil {
|
if p.State != "RECEIVED" {
|
||||||
t.Errorf("ожидалось пустое тело")
|
t.Errorf("State = %q, ожидалось RECEIVED", p.State)
|
||||||
|
}
|
||||||
|
if len(p.Signs) != 1 || p.Signs[0].Status != "VALID" {
|
||||||
|
t.Errorf("Signs неверные: %+v", p.Signs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func contains(s, substr string) bool {
|
// TestGetPackage — скачивание содержимого. ИШ может возвращать либо чистый
|
||||||
return len(s) >= len(substr) && (indexOf(s, substr) >= 0)
|
// ZIP, либо JSON с base64-полем. Тестируем оба случая.
|
||||||
}
|
func TestGetPackageRawZIP(t *testing.T) {
|
||||||
|
zipBytes := []byte("PK\x03\x04zip-content-here")
|
||||||
func indexOf(s, substr string) int {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
for i := 0; i+len(substr) <= len(s); i++ {
|
if r.URL.Path != "/api/package/22423" {
|
||||||
if s[i:i+len(substr)] == substr {
|
t.Errorf("неожиданный путь %q", r.URL.Path)
|
||||||
return i
|
|
||||||
}
|
}
|
||||||
|
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.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(body) != string(zipBytes) {
|
||||||
|
t.Errorf("body = %q, ожидалось %q", body, zipBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -170,6 +170,35 @@ func (s *Sender) emitDecision(ctx context.Context, req *m2m.M2MTransferRequest,
|
|||||||
return
|
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{
|
decision := &m2m.M2MTransferDecision{
|
||||||
Header: m2m.DecisionHeader{
|
Header: m2m.DecisionHeader{
|
||||||
GUID: req.Header.GUID,
|
GUID: req.Header.GUID,
|
||||||
|
|||||||
Reference in New Issue
Block a user