1 Commits

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

Это handoff для нового агента — на Astra Linux 10.10.10.27 или на
любой другой ВМ — чтобы он не открывал контекст с нуля.
2026-05-18 13:08:23 +03:00
112 changed files with 1865 additions and 10871 deletions
+149
View File
@@ -0,0 +1,149 @@
# Bridge-and-Join-s — гайд для агента Claude Code
Этот файл читается автоматически при старте `claude` в репозитории. Здесь — сжатый контекст проекта чтобы агент не вычислял всё заново.
## Что это за проект
**Bridge-and-Join-s** — система M2M-переводов ценных бумаг между депозитариями через сервис **MOEX МОСТ** (НКО АО НРД). Целевая интеграция — сервис M2M НРД на TEST3, далее PROD.
**Объём**: 100-1000 сделок в день — сознательно облегчили архитектуру под этот объём.
**Один бинарник** `bj-server` (cmd/bj-server) вместо изначально планировавшихся микросервисов. Внутри — пакеты:
- `internal/m2m` — XSD-модели всех сообщений (M2MTransferRequest/Decision/Response)
- `internal/m2mcore` — стейт-машина заявок, репозиторий (memory + pgx)
- `internal/lkgateway` — REST API, веб-админка, lk-emulator, мастер настройки
- `internal/nsdadapter` — два режима: `mock` (внутренний робот-эмулятор) и `igw` (REST-клиент Интеграционного шлюза НРД)
- `internal/cryptocli` — PKCS#11 клиент к СКЗИ (КриптоПро CSP / Рутокен / Валидата)
- `internal/nsdxml` — XML кодек с CP-1251
**Веб-админка**: `http://<ip>:8080/admin/` — главное место общения с системой. Разделы: Дашборд / Мастер настройки / Настройка / Заявки / Статус / Новости / Инструкции. Всё на русском.
## Текущее состояние (актуально на 2026-05-18)
См. **`REPORT.md` в корне репо** — там полный статус с процентами. Кратко:
- ✅ Все компоненты на нашей стороне написаны и оттестированы (~75% общая готовность)
- ✅ REST-клиент ИШ НРД готов (`internal/nsdadapter/igw/` + тесты)
- ✅ Эмулятор робота MOEX МОСТ (4 сценария) работает
- ✅ Установщик одной командой `deploy/astra/install.sh` готов для Astra/Debian/Ubuntu
- ⏳ Заблокировано на внешнем: дистрибутив Валидата CSP (запрос в `soed@nsd.ru`), сертификат УЦ МБ (`ca.moex.com`), регистрация в TEST3
- ⚠️ Окно техработ TEST3 закрыто (18-22.05.2026 — сейчас 18.05, активно)
## Архитектура обмена с НРД (если кратко)
```
bj-server (наше Go-приложение)
│ REST: POST /api/package/{channel}/file (ZIP в base64)
ИШ (igate, ставится на Astra Linux рядом)
│ ИШ САМ: подписывает, упаковывает, отправляет
│ Использует СКЗИ Валидата CSP + сертификат УЦ МБ
Web-сервис ONYX (НРД, https://gost.nsd.ru/onyxt3/WslService)
```
Подробная схема и FAQ — в `/admin/help/architecture` (когда сервис запущен) и в `DOC/ruk_install_ish_2025_11_10.pdf`.
## Где что лежит
| Каталог | Что |
|---|---|
| `cmd/bj-server/` | главный бинарник |
| `cmd/lk-emulator/` | эмулятор ЛК ESIA для разработки |
| `internal/` | вся бизнес-логика, поделена на пакеты |
| `migrations/` | SQL миграции: `fansy-store/` (входные данные от Fansy) и `m2m-core/` (журнал сделок) |
| `deploy/astra/` | установщик одной командой + healthcheck |
| `deploy/systemd/` | systemd unit (используется install.sh) |
| `deploy/docker-compose/` | docker-compose для PostgreSQL в podman |
| `DOC/` | вся документация НРД (PDF — около 15 файлов, см. ниже) |
| `dist/ish/` | дистрибутив ИШ НРД (~120 МБ .deb, не в git) |
| `docs/` | наша внутренняя документация (контракты Fansy и т.п.) |
| `REPORT.md` | отчёт для руководства — **держим в актуальном виде** |
## Документация НРД в DOC/
- `Инструккия M2M.pdf` — главный документ по M2M-обмену (схемы, протоколы)
- `instr-ish-rest-api.pdf` — REST API ИШ (на нём основан `internal/nsdadapter/igw`)
- `ruk_install_ish_2025_11_10.pdf` — Руководство по установке ИШ (нужны Astra Linux + Валидата CSP)
- `ruk_pol_ish.pdf` — Руководство пользователя ИШ
- `QA_ish.pdf`, `test-case_ish.pdf` — FAQ и тест-кейсы
- `instruktsiya-po-testirovaniyu-s-robotom.pdf` — робот-автотест на TEST3 (код `MC0012500000`, 4 сценария)
- `servis-most-m2m.pdf` — обзор сервиса MOEX МОСТ
- `Ссылки для доступа в тестовые контуры.pdf` — URL'ы GUEST/TEST3/PROD контуров НРД
## Решения, которые мы приняли (не очевидные)
- **КриптоПро CSP**, а не JCP — экономия ~50 000 ₽ лицензии. Используется для подписи действий оператора в админке через Рутокен. Для отправки в НРД подпись делает ИШ (своей Валидатой) — наш bj-server для этого не нужен
- **PKCS#11 как единый интерфейс** к разным СКЗИ — пакет `internal/cryptocli` через `github.com/miekg/pkcs11`
- **Один бинарник** вместо микросервисов — для нашего объёма проще, никаких микросервисных издержек
- **Mock-робот внутри bj-server** — позволяет тестить логику без живого НРД. Активируется кодом `MC0012500000` в `ReceiverCode` + `DocumentSeries` 1111/2001/2002/3333
- **Astra Linux для ИШ** — единственная поддерживаемая ОС от НРД (РЕД ОС не пойдёт). Дев — Astra CE (бесплатная), прод — Astra SE (платная)
- **`globalRC` в `admin.go`** — глобальная переменная RC для шаблонов; компромисс между чистотой и шумностью передачи `*RuntimeConfig` через все хендлеры
- **doc-watcher с явным `noProxyClient`** в `news.go` — игнорирует ENV-прокси, потому что zetit блокирует nsd.ru
## Как запускать
**Локально (для разработки)**:
```bash
go build -o ./bin/bj-server ./cmd/bj-server
LD_LIBRARY_PATH=/opt/cprocsp/lib/amd64 ./bin/bj-server # путь нужен только если есть КриптоПро
```
**Production-стиль через systemd** (после `deploy/astra/install.sh`):
```bash
systemctl status bj-server
journalctl -u bj-server -f
```
**Установить с нуля на свежей Astra/Debian/Ubuntu** — одна команда:
```bash
curl -sSL https://git.zetit.ru/zuevav/Bridge-and-Join-s/raw/main/deploy/astra/install.sh | sudo bash
```
## Окружение разработки
- **Дев-стенд №1**: РЕД ОС 8 на `10.10.10.22` — историческая ВМ, КриптоПро CSP установлен, есть PostgreSQL в podman, bj-server и lk-emulator работают
- **Дев-стенд №2**: Astra Linux SE 1.7 на `10.10.10.27` — будущий основной стенд (потому что Astra нужна для ИШ). Поднята 14.05.2026, базовая dev-среда настроена (Node 20, claude-code, tmux, прокси `claude-fna`)
- **Прокси zetit** `fna.zetit.ru:3128` — только для Claude Code (alias `claude-fna`). Всё остальное (go modules, apt, nsd.ru) идёт напрямую
- **Git remote**: `https://git.zetit.ru/zuevav/Bridge-and-Join-s.git` (Gitea на zetit)
## Команды и подсказки агенту
**После любого значимого изменения** — обновляй `REPORT.md` в том же коммите. Это правило (см. `feedback_report_md_keep_updated.md` в памяти). REPORT.md — «живая» отчётность для руководства, пользователь должен в любой момент открыть и показать.
**Прокси при `go build`** — выключай ENV-прокси, у нас всё ходит мимо zetit:
```bash
env -u HTTPS_PROXY -u HTTP_PROXY -u https_proxy -u http_proxy go build ...
```
**bj-server в фоне** — нужен `setsid` иначе умирает с shell:
```bash
LD_LIBRARY_PATH=/opt/cprocsp/lib/amd64 setsid ./bin/bj-server > /tmp/bj.log 2>&1 < /dev/null & disown
```
**Cwd важно** — после `cd DOC/` или подобного go build от `./cmd/bj-server` упадёт. Всегда возвращайся в `/home/fontvielle/Bridge-and-Join-s` (или `/opt/bj/src` на Astra).
**Все UI-надписи — на русском** (требование заказчика). Исключения только для программных терминов (PostgreSQL, REST, JSON, и т.п.).
**Не создавай PDF-документы и md-отчёты без явного запроса.** REPORT.md — единственный, его поддерживаем актуальным.
**Релизные коммиты** — заголовок в Conventional Commits (`feat(igw): ...`, `fix(admin): ...`), русский body. См. предыдущие коммиты `git log --oneline`.
## Контакты НРД (если что-то нужно по проекту)
- `M2MOST@nsd.ru` — форматы M2M
- `soed@nsd.ru` — дистрибутивы (Валидата, ИШ)
- `pki@moex.com` — УЦ МБ, сертификаты
## Что делать НЕ надо
- Не пытайся ставить ИШ на РЕД ОС — он только под Astra (.deb пакет)
- Не предлагай переход на КриптоПро JCP — пользователь явно его отверг по цене
- Не пытайся выпускать новые сертификаты — у организации они уже есть, наша задача только импортировать
- Не убирай UI-баннер «РЕЖИМ ЭМУЛЯЦИИ» — он защищает от случайной отправки в прод
- Не используй прокси zetit для go-модулей или для запросов к nsd.ru — будет 403
---
**Спросить пользователя, если непонятно** — не стесняйся. Лучше задать вопрос чем сделать неверное предположение.
-8
View File
@@ -1,8 +0,0 @@
Обязательно заметить в сообщениях:
- {GUID_ПЕРЕВОДА} - идентификтор перевода. Всегда меняйте его перед отправкой
- {ВАШ_ДЕПКОД} - ваш депозитарный код
- {ВАШ_ИНН} - ваш инн
- {ВАШ_ДЕПОЗИТАРНЫЙ_СЧЕТ} - депозитарный счет, с которого переводятся бумаги
- {ВАШ_РАЗДЕЛ_ДЕПОЗИТАРНОГО_СЧЕТА} - раздел депозитарного счета, с которого переводятся бумаги
Если не заменить на ваши значение - сообщение не пройдет проверку формата.
@@ -1,4 +0,0 @@
<config>
<name>request.xml</name>
<package>#M2MTR</package>
</config>
@@ -1,119 +0,0 @@
<?xml version="1.0" encoding="windows-1251"?>
<rt:M2MTransferRequest xmlns:m2m="http://nsd.ru/schemas/m2m/types" xmlns:rt="http://nsd.ru/schemas/m2m/request" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://nsd.ru/schemas/m2m/request M2MTransferRequest.xsd">
<rt:Header>
<m2m:GUID>{GUIF_吓信挛睦}</m2m:GUID>
<m2m:CreationTimestamp>2026-04-30T01:01:01(萄)</m2m:CreationTimestamp>
<m2m:SenderCode>{吕豞呐鲜文}</m2m:SenderCode>
<m2m:ReceiverCode>MC0012500000</m2m:ReceiverCode>
<m2m:CostInfo>
<m2m:Yes>
<m2m:Code>{吕豞呐鲜文}</m2m:Code>
</m2m:Yes>
</m2m:CostInfo>
</rt:Header>
<rt:Data>
<m2m:IsM2M>true</m2m:IsM2M>
<m2m:InvestorInformation>
<m2m:LastName>翌腙桧</m2m:LastName>
<m2m:FirstName>丸觇蜞</m2m:FirstName>
<m2m:MiddleName>理囹铍</m2m:MiddleName>
<m2m:IdentityDocument>
<m2m:DocumentType>21</m2m:DocumentType>
<m2m:DocumentSeries>1111</m2m:DocumentSeries>
<m2m:DocumentNumber>111101</m2m:DocumentNumber>
</m2m:IdentityDocument>
</m2m:InvestorInformation>
<m2m:TransferringDepository>
<m2m:INN>{吕豞韧蛚</m2m:INN>
</m2m:TransferringDepository>
<m2m:ReceivingDepository>
<m2m:INN>7722061076</m2m:INN>
</m2m:ReceivingDepository>
<m2m:TransferredSecurities>
<m2m:Security>
<m2m:ReferenceId>M2M2233116169101</m2m:ReferenceId>
<m2m:SecurityCode>RU0007661625</m2m:SecurityCode>
<m2m:SecurityDetails>
<m2m:ISIN>RU0007661625</m2m:ISIN>
</m2m:SecurityDetails>
<m2m:Quantity>
<m2m:Whole>1</m2m:Whole>
</m2m:Quantity>
<m2m:SettlementAccount>
<m2m:SettlementRequisites>
<m2m:INN>7702165310</m2m:INN>
</m2m:SettlementRequisites>
<m2m:SettlementLocation>
<m2m:DeponentCode>{吕豞呐鲜文}</m2m:DeponentCode>
<m2m:AccountId>{吕豞呐衔侨依型凵_炎乓}</m2m:AccountId>
<m2m:SectionId>{吕豞欣悄潘_呐衔侨依型蚊蝊炎乓纝</m2m:SectionId>
</m2m:SettlementLocation>
</m2m:SettlementAccount>
<m2m:IsolationStatus>SGDN</m2m:IsolationStatus>
</m2m:Security>
<m2m:Security>
<m2m:ReferenceId>M2M2233116869102</m2m:ReferenceId>
<m2m:SecurityCode>RU000A0JP5V6</m2m:SecurityCode>
<m2m:SecurityDetails>
<m2m:ISIN>RU000A0JP5V6</m2m:ISIN>
</m2m:SecurityDetails>
<m2m:Quantity>
<m2m:Whole>1</m2m:Whole>
</m2m:Quantity>
<m2m:SettlementAccount>
<m2m:SettlementRequisites>
<m2m:INN>7702165310</m2m:INN>
</m2m:SettlementRequisites>
<m2m:SettlementLocation>
<m2m:DeponentCode>{吕豞呐鲜文}</m2m:DeponentCode>
<m2m:AccountId>{吕豞呐衔侨依型凵_炎乓}</m2m:AccountId>
<m2m:SectionId>{吕豞欣悄潘_呐衔侨依型蚊蝊炎乓纝</m2m:SectionId>
</m2m:SettlementLocation>
</m2m:SettlementAccount>
<m2m:IsolationStatus>SGDN</m2m:IsolationStatus>
</m2m:Security>
<m2m:Security>
<m2m:ReferenceId>M2M2211116869103</m2m:ReferenceId>
<m2m:SecurityCode>RU000A0JPKH7</m2m:SecurityCode>
<m2m:SecurityDetails>
<m2m:ISIN>RU000A0JPKH7</m2m:ISIN>
</m2m:SecurityDetails>
<m2m:Quantity>
<m2m:Whole>1</m2m:Whole>
</m2m:Quantity>
<m2m:SettlementAccount>
<m2m:SettlementRequisites>
<m2m:INN>7831000034</m2m:INN>
</m2m:SettlementRequisites>
<m2m:SettlementLocation>
<m2m:DeponentCode>{吕豞呐鲜文}</m2m:DeponentCode>
<m2m:AccountId>{吕豞呐衔侨依型凵_炎乓}</m2m:AccountId>
<m2m:SectionId>{吕豞欣悄潘_呐衔侨依型蚊蝊炎乓纝</m2m:SectionId>
</m2m:SettlementLocation>
</m2m:SettlementAccount>
<m2m:IsolationStatus>SGDN</m2m:IsolationStatus>
</m2m:Security>
<m2m:Security>
<m2m:ReferenceId>M2M2233116819104</m2m:ReferenceId>
<m2m:SecurityCode>RU000A0JPGP8</m2m:SecurityCode>
<m2m:SecurityDetails>
<m2m:ISIN>RU000A0JPGP8</m2m:ISIN>
</m2m:SecurityDetails>
<m2m:Quantity>
<m2m:Whole>1</m2m:Whole>
</m2m:Quantity>
<m2m:SettlementAccount>
<m2m:SettlementRequisites>
<m2m:INN>7831000034</m2m:INN>
</m2m:SettlementRequisites>
<m2m:SettlementLocation>
<m2m:DeponentCode>{吕豞呐鲜文}</m2m:DeponentCode>
<m2m:AccountId>{吕豞呐衔侨依型凵_炎乓}</m2m:AccountId>
<m2m:SectionId>{吕豞欣悄潘_呐衔侨依型蚊蝊炎乓纝</m2m:SectionId>
</m2m:SettlementLocation>
</m2m:SettlementAccount>
<m2m:IsolationStatus>SGDN</m2m:IsolationStatus>
</m2m:Security>
</m2m:TransferredSecurities>
</rt:Data>
</rt:M2MTransferRequest>
@@ -1,4 +0,0 @@
<config>
<name>request.xml</name>
<package>#M2MTR</package>
</config>
@@ -1,119 +0,0 @@
<?xml version="1.0" encoding="windows-1251"?>
<rt:M2MTransferRequest xmlns:m2m="http://nsd.ru/schemas/m2m/types" xmlns:rt="http://nsd.ru/schemas/m2m/request" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://nsd.ru/schemas/m2m/request M2MTransferRequest.xsd">
<rt:Header>
<m2m:GUID>{GUIF_吓信挛睦}</m2m:GUID>
<m2m:CreationTimestamp>2026-04-30T01:01:01(萄)</m2m:CreationTimestamp>
<m2m:SenderCode>{吕豞呐鲜文}</m2m:SenderCode>
<m2m:ReceiverCode>MC0012500000</m2m:ReceiverCode>
<m2m:CostInfo>
<m2m:Yes>
<m2m:Code>{吕豞呐鲜文}</m2m:Code>
</m2m:Yes>
</m2m:CostInfo>
</rt:Header>
<rt:Data>
<m2m:IsM2M>true</m2m:IsM2M>
<m2m:InvestorInformation>
<m2m:LastName>翌腙桧</m2m:LastName>
<m2m:FirstName>丸觇蜞</m2m:FirstName>
<m2m:MiddleName>理囹铍</m2m:MiddleName>
<m2m:IdentityDocument>
<m2m:DocumentType>21</m2m:DocumentType>
<m2m:DocumentSeries>1111</m2m:DocumentSeries>
<m2m:DocumentNumber>111105</m2m:DocumentNumber>
</m2m:IdentityDocument>
</m2m:InvestorInformation>
<m2m:TransferringDepository>
<m2m:INN>{吕豞韧蛚</m2m:INN>
</m2m:TransferringDepository>
<m2m:ReceivingDepository>
<m2m:INN>7722061076</m2m:INN>
</m2m:ReceivingDepository>
<m2m:TransferredSecurities>
<m2m:Security>
<m2m:ReferenceId>M2M2233116169101</m2m:ReferenceId>
<m2m:SecurityCode>RU0007661625</m2m:SecurityCode>
<m2m:SecurityDetails>
<m2m:ISIN>RU0007661625</m2m:ISIN>
</m2m:SecurityDetails>
<m2m:Quantity>
<m2m:Whole>1</m2m:Whole>
</m2m:Quantity>
<m2m:SettlementAccount>
<m2m:SettlementRequisites>
<m2m:INN>7702165310</m2m:INN>
</m2m:SettlementRequisites>
<m2m:SettlementLocation>
<m2m:DeponentCode>{吕豞呐鲜文}</m2m:DeponentCode>
<m2m:AccountId>{吕豞呐衔侨依型凵_炎乓}</m2m:AccountId>
<m2m:SectionId>{吕豞欣悄潘_呐衔侨依型蚊蝊炎乓纝</m2m:SectionId>
</m2m:SettlementLocation>
</m2m:SettlementAccount>
<m2m:IsolationStatus>SGDN</m2m:IsolationStatus>
</m2m:Security>
<m2m:Security>
<m2m:ReferenceId>M2M2233116869102</m2m:ReferenceId>
<m2m:SecurityCode>RU000A0JP5V6</m2m:SecurityCode>
<m2m:SecurityDetails>
<m2m:ISIN>RU000A0JP5V6</m2m:ISIN>
</m2m:SecurityDetails>
<m2m:Quantity>
<m2m:Whole>1</m2m:Whole>
</m2m:Quantity>
<m2m:SettlementAccount>
<m2m:SettlementRequisites>
<m2m:INN>7702165310</m2m:INN>
</m2m:SettlementRequisites>
<m2m:SettlementLocation>
<m2m:DeponentCode>{吕豞呐鲜文}</m2m:DeponentCode>
<m2m:AccountId>{吕豞呐衔侨依型凵_炎乓}</m2m:AccountId>
<m2m:SectionId>{吕豞欣悄潘_呐衔侨依型蚊蝊炎乓纝</m2m:SectionId>
</m2m:SettlementLocation>
</m2m:SettlementAccount>
<m2m:IsolationStatus>SGDN</m2m:IsolationStatus>
</m2m:Security>
<m2m:Security>
<m2m:ReferenceId>M2M2211116869103</m2m:ReferenceId>
<m2m:SecurityCode>RU000A0JPKH7</m2m:SecurityCode>
<m2m:SecurityDetails>
<m2m:ISIN>RU000A0JPKH7</m2m:ISIN>
</m2m:SecurityDetails>
<m2m:Quantity>
<m2m:Whole>1</m2m:Whole>
</m2m:Quantity>
<m2m:SettlementAccount>
<m2m:SettlementRequisites>
<m2m:INN>7831000034</m2m:INN>
</m2m:SettlementRequisites>
<m2m:SettlementLocation>
<m2m:DeponentCode>{吕豞呐鲜文}</m2m:DeponentCode>
<m2m:AccountId>{吕豞呐衔侨依型凵_炎乓}</m2m:AccountId>
<m2m:SectionId>{吕豞欣悄潘_呐衔侨依型蚊蝊炎乓纝</m2m:SectionId>
</m2m:SettlementLocation>
</m2m:SettlementAccount>
<m2m:IsolationStatus>SGDN</m2m:IsolationStatus>
</m2m:Security>
<m2m:Security>
<m2m:ReferenceId>M2M2233116819104</m2m:ReferenceId>
<m2m:SecurityCode>RU000A0JPGP8</m2m:SecurityCode>
<m2m:SecurityDetails>
<m2m:ISIN>RU000A0JPGP8</m2m:ISIN>
</m2m:SecurityDetails>
<m2m:Quantity>
<m2m:Whole>1</m2m:Whole>
</m2m:Quantity>
<m2m:SettlementAccount>
<m2m:SettlementRequisites>
<m2m:INN>7831000034</m2m:INN>
</m2m:SettlementRequisites>
<m2m:SettlementLocation>
<m2m:DeponentCode>{吕豞呐鲜文}</m2m:DeponentCode>
<m2m:AccountId>{吕豞呐衔侨依型凵_炎乓}</m2m:AccountId>
<m2m:SectionId>{吕豞欣悄潘_呐衔侨依型蚊蝊炎乓纝</m2m:SectionId>
</m2m:SettlementLocation>
</m2m:SettlementAccount>
<m2m:IsolationStatus>SGDN</m2m:IsolationStatus>
</m2m:Security>
</m2m:TransferredSecurities>
</rt:Data>
</rt:M2MTransferRequest>
-8
View File
@@ -1,8 +0,0 @@
Обязательно заметить в сообщениях:
- {GUID_ПЕРЕВОДА} - идентификтор перевода. Всегда меняйте его перед отправкой
- {ВАШ_ДЕПКОД} - ваш депозитарный код
- {ВАШ_ИНН} - ваш инн
- {ВАШ_ДЕПОЗИТАРНЫЙ_СЧЕТ} - депозитарный счет, с которого переводятся бумаги
- {ВАШ_РАЗДЕЛ_ДЕПОЗИТАРНОГО_СЧЕТА} - раздел депозитарного счета, с которого переводятся бумаги
Если не заменить на ваши значение - сообщение не пройдет проверку формата.
@@ -1,4 +0,0 @@
<config>
<name>request.xml</name>
<package>#M2MTR</package>
</config>
@@ -1,119 +0,0 @@
<?xml version="1.0" encoding="windows-1251"?>
<rt:M2MTransferRequest xmlns:m2m="http://nsd.ru/schemas/m2m/types" xmlns:rt="http://nsd.ru/schemas/m2m/request" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://nsd.ru/schemas/m2m/request M2MTransferRequest.xsd">
<rt:Header>
<m2m:GUID>{GUIF_吓信挛睦}</m2m:GUID>
<m2m:CreationTimestamp>2026-04-30T01:01:01(萄)</m2m:CreationTimestamp>
<m2m:SenderCode>{吕豞呐鲜文}</m2m:SenderCode>
<m2m:ReceiverCode>MC0012500000</m2m:ReceiverCode>
<m2m:CostInfo>
<m2m:Yes>
<m2m:Code>{吕豞呐鲜文}</m2m:Code>
</m2m:Yes>
</m2m:CostInfo>
</rt:Header>
<rt:Data>
<m2m:IsM2M>true</m2m:IsM2M>
<m2m:InvestorInformation>
<m2m:LastName>翌腙桧</m2m:LastName>
<m2m:FirstName>丸觇蜞</m2m:FirstName>
<m2m:MiddleName>理囹铍</m2m:MiddleName>
<m2m:IdentityDocument>
<m2m:DocumentType>21</m2m:DocumentType>
<m2m:DocumentSeries>2001</m2m:DocumentSeries>
<m2m:DocumentNumber>111111</m2m:DocumentNumber>
</m2m:IdentityDocument>
</m2m:InvestorInformation>
<m2m:TransferringDepository>
<m2m:INN>{吕豞韧蛚</m2m:INN>
</m2m:TransferringDepository>
<m2m:ReceivingDepository>
<m2m:INN>7722061076</m2m:INN>
</m2m:ReceivingDepository>
<m2m:TransferredSecurities>
<m2m:Security>
<m2m:ReferenceId>M2M2233116169101</m2m:ReferenceId>
<m2m:SecurityCode>RU0007661625</m2m:SecurityCode>
<m2m:SecurityDetails>
<m2m:ISIN>RU0007661625</m2m:ISIN>
</m2m:SecurityDetails>
<m2m:Quantity>
<m2m:Whole>1</m2m:Whole>
</m2m:Quantity>
<m2m:SettlementAccount>
<m2m:SettlementRequisites>
<m2m:INN>7702165310</m2m:INN>
</m2m:SettlementRequisites>
<m2m:SettlementLocation>
<m2m:DeponentCode>{吕豞呐鲜文}</m2m:DeponentCode>
<m2m:AccountId>{吕豞呐衔侨依型凵_炎乓}</m2m:AccountId>
<m2m:SectionId>{吕豞欣悄潘_呐衔侨依型蚊蝊炎乓纝</m2m:SectionId>
</m2m:SettlementLocation>
</m2m:SettlementAccount>
<m2m:IsolationStatus>SGDN</m2m:IsolationStatus>
</m2m:Security>
<m2m:Security>
<m2m:ReferenceId>M2M2233116869102</m2m:ReferenceId>
<m2m:SecurityCode>RU000A0JP5V6</m2m:SecurityCode>
<m2m:SecurityDetails>
<m2m:ISIN>RU000A0JP5V6</m2m:ISIN>
</m2m:SecurityDetails>
<m2m:Quantity>
<m2m:Whole>1</m2m:Whole>
</m2m:Quantity>
<m2m:SettlementAccount>
<m2m:SettlementRequisites>
<m2m:INN>7702165310</m2m:INN>
</m2m:SettlementRequisites>
<m2m:SettlementLocation>
<m2m:DeponentCode>{吕豞呐鲜文}</m2m:DeponentCode>
<m2m:AccountId>{吕豞呐衔侨依型凵_炎乓}</m2m:AccountId>
<m2m:SectionId>{吕豞欣悄潘_呐衔侨依型蚊蝊炎乓纝</m2m:SectionId>
</m2m:SettlementLocation>
</m2m:SettlementAccount>
<m2m:IsolationStatus>SGDN</m2m:IsolationStatus>
</m2m:Security>
<m2m:Security>
<m2m:ReferenceId>M2M2211116869103</m2m:ReferenceId>
<m2m:SecurityCode>RU000A0JPKH7</m2m:SecurityCode>
<m2m:SecurityDetails>
<m2m:ISIN>RU000A0JPKH7</m2m:ISIN>
</m2m:SecurityDetails>
<m2m:Quantity>
<m2m:Whole>1</m2m:Whole>
</m2m:Quantity>
<m2m:SettlementAccount>
<m2m:SettlementRequisites>
<m2m:INN>7831000034</m2m:INN>
</m2m:SettlementRequisites>
<m2m:SettlementLocation>
<m2m:DeponentCode>{吕豞呐鲜文}</m2m:DeponentCode>
<m2m:AccountId>{吕豞呐衔侨依型凵_炎乓}</m2m:AccountId>
<m2m:SectionId>{吕豞欣悄潘_呐衔侨依型蚊蝊炎乓纝</m2m:SectionId>
</m2m:SettlementLocation>
</m2m:SettlementAccount>
<m2m:IsolationStatus>SGDN</m2m:IsolationStatus>
</m2m:Security>
<m2m:Security>
<m2m:ReferenceId>M2M2233116819104</m2m:ReferenceId>
<m2m:SecurityCode>RU000A0JPGP8</m2m:SecurityCode>
<m2m:SecurityDetails>
<m2m:ISIN>RU000A0JPGP8</m2m:ISIN>
</m2m:SecurityDetails>
<m2m:Quantity>
<m2m:Whole>1</m2m:Whole>
</m2m:Quantity>
<m2m:SettlementAccount>
<m2m:SettlementRequisites>
<m2m:INN>7831000034</m2m:INN>
</m2m:SettlementRequisites>
<m2m:SettlementLocation>
<m2m:DeponentCode>{吕豞呐鲜文}</m2m:DeponentCode>
<m2m:AccountId>{吕豞呐衔侨依型凵_炎乓}</m2m:AccountId>
<m2m:SectionId>{吕豞欣悄潘_呐衔侨依型蚊蝊炎乓纝</m2m:SectionId>
</m2m:SettlementLocation>
</m2m:SettlementAccount>
<m2m:IsolationStatus>SGDN</m2m:IsolationStatus>
</m2m:Security>
</m2m:TransferredSecurities>
</rt:Data>
</rt:M2MTransferRequest>
@@ -1,4 +0,0 @@
<config>
<name>request.xml</name>
<package>#M2MTR</package>
</config>
@@ -1,119 +0,0 @@
<?xml version="1.0" encoding="windows-1251"?>
<rt:M2MTransferRequest xmlns:m2m="http://nsd.ru/schemas/m2m/types" xmlns:rt="http://nsd.ru/schemas/m2m/request" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://nsd.ru/schemas/m2m/request M2MTransferRequest.xsd">
<rt:Header>
<m2m:GUID>{GUIF_吓信挛睦}</m2m:GUID>
<m2m:CreationTimestamp>2026-04-30T01:01:01(萄)</m2m:CreationTimestamp>
<m2m:SenderCode>{吕豞呐鲜文}</m2m:SenderCode>
<m2m:ReceiverCode>MC0012500000</m2m:ReceiverCode>
<m2m:CostInfo>
<m2m:Yes>
<m2m:Code>{吕豞呐鲜文}</m2m:Code>
</m2m:Yes>
</m2m:CostInfo>
</rt:Header>
<rt:Data>
<m2m:IsM2M>true</m2m:IsM2M>
<m2m:InvestorInformation>
<m2m:LastName>翌腙桧</m2m:LastName>
<m2m:FirstName>丸觇蜞</m2m:FirstName>
<m2m:MiddleName>理囹铍</m2m:MiddleName>
<m2m:IdentityDocument>
<m2m:DocumentType>21</m2m:DocumentType>
<m2m:DocumentSeries>2001</m2m:DocumentSeries>
<m2m:DocumentNumber>121212</m2m:DocumentNumber>
</m2m:IdentityDocument>
</m2m:InvestorInformation>
<m2m:TransferringDepository>
<m2m:INN>{吕豞韧蛚</m2m:INN>
</m2m:TransferringDepository>
<m2m:ReceivingDepository>
<m2m:INN>7722061076</m2m:INN>
</m2m:ReceivingDepository>
<m2m:TransferredSecurities>
<m2m:Security>
<m2m:ReferenceId>M2M2233116169101</m2m:ReferenceId>
<m2m:SecurityCode>RU0007661625</m2m:SecurityCode>
<m2m:SecurityDetails>
<m2m:ISIN>RU0007661625</m2m:ISIN>
</m2m:SecurityDetails>
<m2m:Quantity>
<m2m:Whole>1</m2m:Whole>
</m2m:Quantity>
<m2m:SettlementAccount>
<m2m:SettlementRequisites>
<m2m:INN>7702165310</m2m:INN>
</m2m:SettlementRequisites>
<m2m:SettlementLocation>
<m2m:DeponentCode>{吕豞呐鲜文}</m2m:DeponentCode>
<m2m:AccountId>{吕豞呐衔侨依型凵_炎乓}</m2m:AccountId>
<m2m:SectionId>{吕豞欣悄潘_呐衔侨依型蚊蝊炎乓纝</m2m:SectionId>
</m2m:SettlementLocation>
</m2m:SettlementAccount>
<m2m:IsolationStatus>SGDN</m2m:IsolationStatus>
</m2m:Security>
<m2m:Security>
<m2m:ReferenceId>M2M2233116869102</m2m:ReferenceId>
<m2m:SecurityCode>RU000A0JP5V6</m2m:SecurityCode>
<m2m:SecurityDetails>
<m2m:ISIN>RU000A0JP5V6</m2m:ISIN>
</m2m:SecurityDetails>
<m2m:Quantity>
<m2m:Whole>1</m2m:Whole>
</m2m:Quantity>
<m2m:SettlementAccount>
<m2m:SettlementRequisites>
<m2m:INN>7702165310</m2m:INN>
</m2m:SettlementRequisites>
<m2m:SettlementLocation>
<m2m:DeponentCode>{吕豞呐鲜文}</m2m:DeponentCode>
<m2m:AccountId>{吕豞呐衔侨依型凵_炎乓}</m2m:AccountId>
<m2m:SectionId>{吕豞欣悄潘_呐衔侨依型蚊蝊炎乓纝</m2m:SectionId>
</m2m:SettlementLocation>
</m2m:SettlementAccount>
<m2m:IsolationStatus>SGDN</m2m:IsolationStatus>
</m2m:Security>
<m2m:Security>
<m2m:ReferenceId>M2M2211116869103</m2m:ReferenceId>
<m2m:SecurityCode>RU000A0JPKH7</m2m:SecurityCode>
<m2m:SecurityDetails>
<m2m:ISIN>RU000A0JPKH7</m2m:ISIN>
</m2m:SecurityDetails>
<m2m:Quantity>
<m2m:Whole>1</m2m:Whole>
</m2m:Quantity>
<m2m:SettlementAccount>
<m2m:SettlementRequisites>
<m2m:INN>7831000034</m2m:INN>
</m2m:SettlementRequisites>
<m2m:SettlementLocation>
<m2m:DeponentCode>{吕豞呐鲜文}</m2m:DeponentCode>
<m2m:AccountId>{吕豞呐衔侨依型凵_炎乓}</m2m:AccountId>
<m2m:SectionId>{吕豞欣悄潘_呐衔侨依型蚊蝊炎乓纝</m2m:SectionId>
</m2m:SettlementLocation>
</m2m:SettlementAccount>
<m2m:IsolationStatus>SGDN</m2m:IsolationStatus>
</m2m:Security>
<m2m:Security>
<m2m:ReferenceId>M2M2233116819104</m2m:ReferenceId>
<m2m:SecurityCode>RU000A0JPGP8</m2m:SecurityCode>
<m2m:SecurityDetails>
<m2m:ISIN>RU000A0JPGP8</m2m:ISIN>
</m2m:SecurityDetails>
<m2m:Quantity>
<m2m:Whole>1</m2m:Whole>
</m2m:Quantity>
<m2m:SettlementAccount>
<m2m:SettlementRequisites>
<m2m:INN>7831000034</m2m:INN>
</m2m:SettlementRequisites>
<m2m:SettlementLocation>
<m2m:DeponentCode>{吕豞呐鲜文}</m2m:DeponentCode>
<m2m:AccountId>{吕豞呐衔侨依型凵_炎乓}</m2m:AccountId>
<m2m:SectionId>{吕豞欣悄潘_呐衔侨依型蚊蝊炎乓纝</m2m:SectionId>
</m2m:SettlementLocation>
</m2m:SettlementAccount>
<m2m:IsolationStatus>SGDN</m2m:IsolationStatus>
</m2m:Security>
</m2m:TransferredSecurities>
</rt:Data>
</rt:M2MTransferRequest>
-34
View File
@@ -1,34 +0,0 @@
# Логотип сервиса MOEX МОСТ
Официальные ассеты и правила размещения логотипа сервиса MOEX МОСТ
(НКО АО НРД). Требование для участников сервиса при интеграции M2M
в свои интерфейсы (личный кабинет, веб-кабинет).
Источники (10.06.2025):
- Руководство: https://www.nsd.ru/media/docs/rukovodstvo-o-razmeschenii-logotipa.pdf
- Ассеты: https://www.nsd.ru/media/docs/dep/logo-moex-most.zip
- Вопросы: moexmost-logo@nsd.ru
## Файлы
- `main/moex-most.{svg,png,pdf,jpg}` — основная (полноцветная, красная) версия.
- White и Add версии в исходном архиве НРД отсутствовали (только метаданные) —
при необходимости запросить у moexmost-logo@nsd.ru.
## Правила размещения (из руководства)
- **Наименование всегда полное: «MOEX МОСТ»** (два слова, оба обязательны).
- **Три версии**: Main (основная, на светлом фоне), White (на тёмном/цветном),
Add (дополнительная). Выбор — по фону.
- **Охранное поле**: минимальный отступ до соседних элементов = 0.5×высоты лого.
- **Минимальная высота** логосимвола — 20px. Для очень маленьких носителей
(иконка в моб. приложении) — только логосимвол на плашке (приоритет — красная,
радиус скругления 6px для плашки 24×24).
- **Web-интеграция — единообразие с окружением**: если соседние сервисы показаны
полноцветными лого — MOEX МОСТ тоже полноцветный (main); если линейными
иконками — MOEX МОСТ линейной синей иконкой с полным наименованием.
## Где применяем у нас
- Веб-кабинет клиента (отдельный проект) — обязательно, как участник сервиса.
- Личный кабинет / admin bj-server — где показываем канал перевода M2M.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

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

Before

Width:  |  Height:  |  Size: 14 KiB

-114
View File
@@ -1,114 +0,0 @@
// Command bj-artifactory — простой сервер раздачи релизов и обновлений.
//
// Раскладка хранилища (--root), один подкаталог на канал:
//
// <root>/stable/manifest.json — подписанный SignedManifest
// <root>/stable/bj-server — артефакты, перечисленные в манифесте
// <root>/stable/crypto-service.jar
// <root>/beta/manifest.json
// ...
//
// HTTP API (потребляет bj-server auto-update и install.sh):
//
// GET /v1/<channel>/manifest.json — манифест канала
// GET /v1/<channel>/files/<name> — артефакт по имени
// GET /healthz — проверка живости
//
// Подпись манифеста делает bj-release; здесь только статическая раздача.
// Перед прод-выкаткой ставится за TLS-reverse-proxy (nginx, см.
// deploy/artifactory/nginx.conf).
package main
import (
"flag"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
func main() {
addr := flag.String("addr", ":8090", "адрес прослушивания")
root := flag.String("root", "./releases", "корень хранилища релизов")
flag.Parse()
abs, err := filepath.Abs(*root)
if err != nil {
log.Fatalf("bj-artifactory: root: %v", err)
}
if _, err := os.Stat(abs); err != nil {
log.Fatalf("bj-artifactory: каталог релизов %s недоступен: %v", abs, err)
}
srv := &server{root: abs}
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) })
mux.HandleFunc("/v1/", srv.handleV1)
log.Printf("bj-artifactory: раздаю %s на %s", abs, *addr)
httpSrv := &http.Server{Addr: *addr, Handler: logging(mux), ReadHeaderTimeout: 10 * time.Second}
log.Fatal(httpSrv.ListenAndServe())
}
type server struct{ root string }
// handleV1 разбирает /v1/<channel>/manifest.json и /v1/<channel>/files/<name>.
func (s *server) handleV1(w http.ResponseWriter, r *http.Request) {
rest := strings.TrimPrefix(r.URL.Path, "/v1/")
parts := strings.SplitN(rest, "/", 3)
if len(parts) < 2 {
http.NotFound(w, r)
return
}
channel := parts[0]
if !safeName(channel) {
http.Error(w, "bad channel", http.StatusBadRequest)
return
}
switch {
case len(parts) == 2 && parts[1] == "manifest.json":
s.serveFile(w, r, filepath.Join(s.root, channel, "manifest.json"), "application/json")
case len(parts) == 3 && parts[1] == "files":
name := parts[2]
if !safeName(name) {
http.Error(w, "bad name", http.StatusBadRequest)
return
}
s.serveFile(w, r, filepath.Join(s.root, channel, name), "application/octet-stream")
default:
http.NotFound(w, r)
}
}
func (s *server) serveFile(w http.ResponseWriter, r *http.Request, path, ctype string) {
f, err := os.Open(path)
if err != nil {
http.NotFound(w, r)
return
}
defer f.Close()
fi, err := f.Stat()
if err != nil || fi.IsDir() {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", ctype)
http.ServeContent(w, r, filepath.Base(path), fi.ModTime(), f)
}
// safeName запрещает обход каталогов (.., /, пустые).
func safeName(s string) bool {
if s == "" || s == "." || s == ".." {
return false
}
return !strings.ContainsAny(s, "/\\") && !strings.Contains(s, "..")
}
func logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r)
log.Printf("%s %s %s", r.RemoteAddr, r.Method, r.URL.Path)
})
}
-111
View File
@@ -1,111 +0,0 @@
// Package main — bj-installer.
//
// Web-инсталлятор для bj-server: на машине клиента после установки
// Debian/Astra поднимает локальный HTTP на 127.0.0.1:8181, проводит
// через 5-страничный wizard (welcome → precheck → config → install → done)
// и за кадром выполняет 20+ шагов установки Валидаты + bj-server + ИШ.
//
// Прогресс шагов прилетает в UI через Server-Sent Events. Каждый шаг
// идемпотентен — можно повторно запускать инсталлятор на уже настроенной
// машине, он пропустит то, что сделано.
//
// Запуск: sudo ./bj-installer [--addr 127.0.0.1:8181] [--no-browser]
// Артефакты ожидаются рядом с бинарём в каталоге ./artifacts/:
//
// artifacts/ClientL_Other/zpki-*.deb
// artifacts/ClientL_Other/zsdk-*.deb
// artifacts/bj-server (Go-бинарь)
// artifacts/crypto-service.jar (Java-сайдкар)
// artifacts/ish/igate_*.deb (ИШ НРД)
package main
import (
"context"
"flag"
"fmt"
"log"
"net/http"
"os"
"os/exec"
"os/signal"
"runtime"
"syscall"
"time"
)
const banner = `
======================================================================
bj-installer — мастер установки Bridge-and-Join-s
======================================================================
`
func main() {
addr := flag.String("addr", "127.0.0.1:8181", "адрес web-инсталлятора")
noBrowser := flag.Bool("no-browser", false, "не пытаться открыть браузер автоматически")
artifactsDir := flag.String("artifacts", "./artifacts", "каталог с дистрибутивами (Validata deb, bj-server, ish)")
flag.Parse()
if os.Geteuid() != 0 {
fmt.Fprintln(os.Stderr, "Установщик должен быть запущен от root (sudo).")
os.Exit(1)
}
fmt.Print(banner)
fmt.Printf(" адрес: http://%s\n", *addr)
fmt.Printf(" артефакты: %s\n", *artifactsDir)
fmt.Println("======================================================================")
st := newState(*artifactsDir)
srv := newServer(st)
httpSrv := &http.Server{
Addr: *addr,
Handler: srv,
ReadHeaderTimeout: 10 * time.Second,
}
// SIGINT/SIGTERM → корректный shutdown
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
go func() {
if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("HTTP-сервер упал: %v", err)
}
}()
url := "http://" + *addr
log.Printf("Откройте в браузере: %s", url)
if !*noBrowser {
tryOpenBrowser(url)
}
<-ctx.Done()
log.Println("Завершаем работу...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = httpSrv.Shutdown(shutdownCtx)
}
// tryOpenBrowser — без фанатизма. Если xdg-open/sensible-browser есть и
// $DISPLAY поднят (xrdp, Fly DE) — откроем. Иначе пользователь увидит URL
// в выводе и перейдёт сам с другого компа (типичный сценарий headless).
func tryOpenBrowser(url string) {
if os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == "" {
return
}
var bin string
switch runtime.GOOS {
case "linux":
for _, cand := range []string{"xdg-open", "sensible-browser", "x-www-browser"} {
if p, err := exec.LookPath(cand); err == nil {
bin = p
break
}
}
}
if bin == "" {
return
}
_ = exec.Command(bin, url).Start()
}
-132
View File
@@ -1,132 +0,0 @@
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"syscall"
)
// runPrechecks — все системные проверки на стадии "Проверка системы".
// Возвращает срез результатов, по каждому видно ✓/✗ + объяснение.
//
// Ничего не модифицирует — просто читает /etc/os-release, проверяет
// наличие нужных бинарей, права root, свободное место, артефакты в
// artifactsDir и т.п. UI отрисовывает таблицей.
func runPrechecks(artifactsDir string) []PrecheckResult {
var out []PrecheckResult
out = append(out, checkRoot())
out = append(out, checkArch())
out = append(out, checkDistro())
out = append(out, checkAptAvailable())
out = append(out, checkSystemd())
out = append(out, checkDiskSpace())
out = append(out, checkArtifacts(artifactsDir))
return out
}
func checkRoot() PrecheckResult {
if os.Geteuid() == 0 {
return PrecheckResult{ID: "root", Title: "Запуск от root", OK: true}
}
return PrecheckResult{ID: "root", Title: "Запуск от root", OK: false, Message: "Требуется sudo"}
}
func checkArch() PrecheckResult {
if runtime.GOARCH == "amd64" {
return PrecheckResult{ID: "arch", Title: "Архитектура amd64", OK: true, Message: runtime.GOARCH}
}
return PrecheckResult{ID: "arch", Title: "Архитектура amd64", OK: false, Message: "Валидата собрана только под amd64, у вас " + runtime.GOARCH}
}
func checkDistro() PrecheckResult {
id, pretty := readOSRelease()
switch id {
case "debian", "astra":
return PrecheckResult{ID: "distro", Title: "Поддерживаемая ОС", OK: true, Message: pretty}
case "ubuntu":
return PrecheckResult{ID: "distro", Title: "Поддерживаемая ОС", OK: true, Message: pretty + " (поддерживается на свой страх)"}
default:
return PrecheckResult{ID: "distro", Title: "Поддерживаемая ОС", OK: false, Message: "ОС не в списке поддерживаемых: " + pretty}
}
}
func checkAptAvailable() PrecheckResult {
if _, err := exec.LookPath("apt-get"); err != nil {
return PrecheckResult{ID: "apt", Title: "Доступен apt-get", OK: false, Message: "apt-get не найден — это не Debian-семейство"}
}
return PrecheckResult{ID: "apt", Title: "Доступен apt-get", OK: true}
}
func checkSystemd() PrecheckResult {
if _, err := os.Stat("/run/systemd/system"); err != nil {
return PrecheckResult{ID: "systemd", Title: "systemd работает", OK: false, Message: "/run/systemd/system нет"}
}
return PrecheckResult{ID: "systemd", Title: "systemd работает", OK: true}
}
func checkDiskSpace() PrecheckResult {
var fs syscall.Statfs_t
if err := syscall.Statfs("/var", &fs); err != nil {
return PrecheckResult{ID: "disk", Title: "Свободное место в /var", OK: false, Message: err.Error()}
}
freeBytes := fs.Bavail * uint64(fs.Bsize)
freeGiB := freeBytes / (1 << 30)
if freeGiB < 2 {
return PrecheckResult{ID: "disk", Title: "Свободное место в /var", OK: false, Message: fmt.Sprintf("Свободно %d GiB, нужно ≥ 2", freeGiB)}
}
return PrecheckResult{ID: "disk", Title: "Свободное место в /var", OK: true, Message: fmt.Sprintf("%d GiB свободно", freeGiB)}
}
func checkArtifacts(dir string) PrecheckResult {
required := []struct {
Glob string
Name string
}{
{filepath.Join(dir, "ClientL_Other", "zpki-*.deb"), "zpki (Валидата)"},
{filepath.Join(dir, "bj-server"), "bj-server (Go-бинарь)"},
{filepath.Join(dir, "crypto-service.jar"), "crypto-service.jar"},
}
var missing []string
for _, r := range required {
matches, _ := filepath.Glob(r.Glob)
if len(matches) == 0 {
missing = append(missing, r.Name)
}
}
if len(missing) > 0 {
return PrecheckResult{
ID: "artifacts",
Title: "Артефакты дистрибутива",
OK: false,
Message: "Отсутствуют: " + strings.Join(missing, ", ") + " (положите в " + dir + ")",
}
}
return PrecheckResult{ID: "artifacts", Title: "Артефакты дистрибутива", OK: true, Message: "Все на месте в " + dir}
}
func readOSRelease() (id, pretty string) {
b, err := os.ReadFile("/etc/os-release")
if err != nil {
return "", "неизвестно"
}
for _, line := range strings.Split(string(b), "\n") {
k, v, ok := strings.Cut(line, "=")
if !ok {
continue
}
v = strings.Trim(v, `"`)
switch k {
case "ID":
id = v
case "PRETTY_NAME":
pretty = v
}
}
return
}
-129
View File
@@ -1,129 +0,0 @@
package main
import (
"embed"
"encoding/json"
"io/fs"
"log"
"net/http"
"strings"
)
//go:embed web
var webFS embed.FS
type server struct {
state *State
mux *http.ServeMux
}
func newServer(st *State) *server {
s := &server{state: st, mux: http.NewServeMux()}
// Статика (HTML/CSS/JS из embed)
sub, _ := fs.Sub(webFS, "web")
s.mux.Handle("/", http.FileServer(http.FS(sub)))
// API
s.mux.HandleFunc("/api/state", s.handleState)
s.mux.HandleFunc("/api/precheck", s.handlePrecheck)
s.mux.HandleFunc("/api/config", s.handleConfig)
s.mux.HandleFunc("/api/install", s.handleInstall)
s.mux.HandleFunc("/api/events", s.handleSSE)
s.mux.HandleFunc("/api/reset", s.handleReset)
return s
}
func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Защита: только localhost (даже если addr 0.0.0.0 поставят)
host := r.RemoteAddr
if i := strings.LastIndex(host, ":"); i != -1 {
host = host[:i]
}
switch host {
case "127.0.0.1", "::1", "[::1]", "localhost":
// ok
default:
http.Error(w, "installer is local-only", http.StatusForbidden)
return
}
s.mux.ServeHTTP(w, r)
}
// GET /api/state — полный snapshot для холодного открытия страницы.
func (s *server) handleState(w http.ResponseWriter, r *http.Request) {
snap := s.state.Snapshot()
writeJSON(w, snap)
}
// POST /api/precheck — запускает все pre-check проверки и возвращает результат.
// Wizard переходит на стадию precheck.
func (s *server) handlePrecheck(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
s.state.setStage(StagePrecheck)
results := runPrechecks(s.state.artifactsDir)
s.state.setPrecheck(results)
writeJSON(w, results)
}
// POST /api/config — сохраняет org INN, email, license. Переход на стадию config.
func (s *server) handleConfig(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
var c Config
if err := json.NewDecoder(r.Body).Decode(&c); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
s.state.setConfig(c)
s.state.setStage(StageConfig)
writeJSON(w, map[string]bool{"ok": true})
}
// POST /api/install — стартует установку (в горутине), переход на стадию installing.
// UI слушает /api/events для прогресса.
func (s *server) handleInstall(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
s.state.setStage(StageInstalling)
go func() {
if err := runInstallation(s.state); err != nil {
log.Printf("install error: %v", err)
s.state.setError(err.Error())
return
}
s.state.setStage(StageDone)
}()
writeJSON(w, map[string]bool{"ok": true})
}
// POST /api/reset — сброс wizard'а на welcome (после ошибки).
func (s *server) handleReset(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
s.state.mu.Lock()
s.state.Stage = StageWelcome
s.state.ErrorMsg = ""
s.state.Precheck = nil
s.state.Steps = buildStepList()
s.state.mu.Unlock()
s.state.bus.publish(event{Type: "reset", Data: "{}"})
writeJSON(w, map[string]bool{"ok": true})
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
_ = enc.Encode(v)
}
-86
View File
@@ -1,86 +0,0 @@
package main
import (
"fmt"
"net/http"
"sync"
)
// event — одно событие, отдаваемое подписчикам через SSE.
// Type становится `event:` строкой, Data — `data:`.
type event struct {
Type string
Data string
}
// eventBus — простой fan-out для SSE. Подписчик создаётся в момент
// открытия GET /api/events и живёт до закрытия соединения.
type eventBus struct {
mu sync.Mutex
subscribers map[chan event]struct{}
}
func newEventBus() *eventBus {
return &eventBus{subscribers: make(map[chan event]struct{})}
}
func (b *eventBus) subscribe() chan event {
ch := make(chan event, 64)
b.mu.Lock()
b.subscribers[ch] = struct{}{}
b.mu.Unlock()
return ch
}
func (b *eventBus) unsubscribe(ch chan event) {
b.mu.Lock()
delete(b.subscribers, ch)
close(ch)
b.mu.Unlock()
}
func (b *eventBus) publish(e event) {
b.mu.Lock()
defer b.mu.Unlock()
for ch := range b.subscribers {
select {
case ch <- e:
default:
// Подписчик отстаёт — пропускаем (UI догонится снапшотом по GET /api/state)
}
}
}
// handleSSE — GET /api/events. Держит соединение, в каждом событии
// отдаёт event: <Type>\ndata: <Data>\n\n.
func (s *server) handleSSE(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming not supported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.WriteHeader(http.StatusOK)
ch := s.state.bus.subscribe()
defer s.state.bus.unsubscribe(ch)
// сразу шлём snapshot, чтобы UI догнал состояние
snap := s.state.Snapshot()
fmt.Fprintf(w, "event: snapshot\ndata: %s\n\n", mustJSON(snap))
flusher.Flush()
for {
select {
case <-r.Context().Done():
return
case e := <-ch:
if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", e.Type, e.Data); err != nil {
return
}
flusher.Flush()
}
}
}
-140
View File
@@ -1,140 +0,0 @@
package main
import (
"encoding/json"
"sync"
"time"
)
// WizardStage — какой странице wizard'а соответствует текущее состояние.
// Переходы: welcome → precheck → config → installing → done.
// Из любого можно вернуться в welcome (полный reset).
type WizardStage string
const (
StageWelcome WizardStage = "welcome"
StagePrecheck WizardStage = "precheck"
StageConfig WizardStage = "config"
StageInstalling WizardStage = "installing"
StageDone WizardStage = "done"
StageError WizardStage = "error"
)
// Config — данные, которые wizard собирает на стадии config.
type Config struct {
OrgINN string `json:"orgInn"` // ИНН организации
OrgName string `json:"orgName"` // отображаемое имя
AdminEmail string `json:"adminEmail"` // куда писать алерты
LicenseKey string `json:"licenseKey"` // годовой ключ (опционально, можно пропустить)
}
// StepStatus — текущее состояние конкретного шага установки.
type StepStatus string
const (
StepPending StepStatus = "pending"
StepRunning StepStatus = "running"
StepDone StepStatus = "done"
StepSkipped StepStatus = "skipped"
StepFailed StepStatus = "failed"
)
// StepState — снимок одного шага для отдачи в UI.
type StepState struct {
ID string `json:"id"`
Title string `json:"title"`
Status StepStatus `json:"status"`
Message string `json:"message,omitempty"`
Started *time.Time `json:"started,omitempty"`
Finished *time.Time `json:"finished,omitempty"`
}
// PrecheckResult — результат одной системной проверки на стадии precheck.
type PrecheckResult struct {
ID string `json:"id"`
Title string `json:"title"`
OK bool `json:"ok"`
Message string `json:"message,omitempty"`
}
// State — потокобезопасное состояние wizard'а. Хранит всё что нужно
// отрисовать на любой из страниц + текущий прогресс установки.
type State struct {
mu sync.RWMutex
artifactsDir string
Stage WizardStage `json:"stage"`
ErrorMsg string `json:"errorMsg,omitempty"`
Precheck []PrecheckResult `json:"precheck"`
Config Config `json:"config"`
Steps []StepState `json:"steps"`
bus *eventBus
}
func newState(artifactsDir string) *State {
return &State{
artifactsDir: artifactsDir,
Stage: StageWelcome,
Steps: buildStepList(),
bus: newEventBus(),
}
}
// Snapshot — потокобезопасная копия для GET /api/state.
func (s *State) Snapshot() State {
s.mu.RLock()
defer s.mu.RUnlock()
cp := *s
cp.Precheck = append([]PrecheckResult(nil), s.Precheck...)
cp.Steps = append([]StepState(nil), s.Steps...)
return cp
}
func (s *State) setStage(st WizardStage) {
s.mu.Lock()
s.Stage = st
s.mu.Unlock()
s.bus.publish(event{Type: "stage", Data: mustJSON(map[string]string{"stage": string(st)})})
}
func (s *State) setError(msg string) {
s.mu.Lock()
s.Stage = StageError
s.ErrorMsg = msg
s.mu.Unlock()
s.bus.publish(event{Type: "error", Data: mustJSON(map[string]string{"message": msg})})
}
func (s *State) setPrecheck(items []PrecheckResult) {
s.mu.Lock()
s.Precheck = items
s.mu.Unlock()
s.bus.publish(event{Type: "precheck", Data: mustJSON(items)})
}
func (s *State) setConfig(c Config) {
s.mu.Lock()
s.Config = c
s.mu.Unlock()
}
func (s *State) updateStep(id string, fn func(*StepState)) {
s.mu.Lock()
var snap StepState
for i := range s.Steps {
if s.Steps[i].ID == id {
fn(&s.Steps[i])
snap = s.Steps[i]
break
}
}
s.mu.Unlock()
s.bus.publish(event{Type: "step", Data: mustJSON(snap)})
}
func mustJSON(v any) string {
b, _ := json.Marshal(v)
return string(b)
}
-84
View File
@@ -1,84 +0,0 @@
package main
import (
"fmt"
"time"
)
// Step — описание одного шага установки. Run выполняет шаг, может
// проверить idempotency и вернуть Skipped. Логи прокидываются через
// log-функцию, которая публикует event в SSE.
type Step struct {
ID string
Title string
Run func(s *State, log func(string)) (StepStatus, error)
}
// buildStepList — фиксированный порядок шагов установки. Соответствует
// install-validata.sh + установка bj-server/crypto-service/ИШ. Меняется
// атомарно (если что-то добавляется — добавляем сюда).
func buildStepList() []StepState {
steps := allSteps()
out := make([]StepState, len(steps))
for i, s := range steps {
out[i] = StepState{ID: s.ID, Title: s.Title, Status: StepPending}
}
return out
}
func allSteps() []Step {
return []Step{
{ID: "deps", Title: "Установка системных зависимостей", Run: stepInstallDeps},
{ID: "validata-deb", Title: "Установка пакетов Валидаты (zpki + zsdk)", Run: stepInstallValidataDebs},
{ID: "execstack", Title: "execstack -c libvdcsp.so", Run: stepExecstack},
{ID: "bj-user", Title: "Создание пользователя bj и каталогов", Run: stepCreateBJUser},
{ID: "pcscd-dropin", Title: "Настройка pcscd (always-on)", Run: stepPcscdDropin},
{ID: "bj-crypto-dropins", Title: "Drop-ins для bj-crypto sandbox", Run: stepBJCryptoDropins},
{ID: "bj-server-dropin", Title: "Drop-in для bj-server", Run: stepBJServerDropin},
{ID: "spki-ini", Title: "Создание spki.ini", Run: stepSPKIIni},
{ID: "pki1-prep", Title: "Подготовка pki1.conf для bj", Run: stepPKI1Prep},
{ID: "usb-mount", Title: "Авто-mount USB через udev + systemd", Run: stepUSBMount},
{ID: "bj-server-binary", Title: "Установка bj-server бинаря в /opt/bj/", Run: stepInstallBJServer},
{ID: "crypto-jar", Title: "Установка crypto-service.jar", Run: stepInstallCryptoJar},
{ID: "systemd-units", Title: "systemd unit bj-crypto.service + bj-server.service", Run: stepSystemdUnits},
{ID: "ish-install", Title: "Установка ИШ НРД (если есть .deb)", Run: stepInstallISH},
{ID: "save-config", Title: "Сохранение setup.json", Run: stepSaveConfig},
{ID: "systemd-start", Title: "Запуск сервисов (pcscd, bj-crypto, bj-server)", Run: stepStartServices},
{ID: "health", Title: "Финальный health-check", Run: stepHealthCheck},
}
}
// runInstallation — основной цикл установки. Перебирает шаги, обновляет
// статусы через State, прокидывает логи в SSE. Останавливается при первой
// ошибке (UI покажет какой шаг + сообщение).
func runInstallation(s *State) error {
steps := allSteps()
for _, step := range steps {
now := time.Now()
s.updateStep(step.ID, func(ss *StepState) {
ss.Status = StepRunning
ss.Started = &now
ss.Message = ""
})
logFn := func(line string) {
s.updateStep(step.ID, func(ss *StepState) {
ss.Message = line
})
}
status, err := step.Run(s, logFn)
finished := time.Now()
s.updateStep(step.ID, func(ss *StepState) {
ss.Status = status
ss.Finished = &finished
if err != nil {
ss.Message = err.Error()
}
})
if err != nil {
return fmt.Errorf("шаг %q: %w", step.Title, err)
}
}
return nil
}
-448
View File
@@ -1,448 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
// --------------------------------------------------------------------- //
// Хелперы
// --------------------------------------------------------------------- //
// runCmd — запускает команду, прокидывает stdout/stderr построчно в log.
// Возвращает ошибку с последними строками stderr для удобства отображения.
func runCmd(logFn func(string), name string, args ...string) error {
logFn(fmt.Sprintf("$ %s %s", name, strings.Join(args, " ")))
cmd := exec.Command(name, args...)
out, err := cmd.CombinedOutput()
for _, line := range strings.Split(strings.TrimRight(string(out), "\n"), "\n") {
if line != "" {
logFn(line)
}
}
if err != nil {
return fmt.Errorf("%s: %w", name, err)
}
return nil
}
// writeFileIfChanged — пишет файл только если содержимое отличается. Возвращает
// true если файл был создан/изменён (для решения «нужен ли daemon-reload»).
func writeFileIfChanged(path string, content string, mode os.FileMode) (bool, error) {
existing, err := os.ReadFile(path)
if err == nil && string(existing) == content {
return false, nil
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return false, err
}
if err := os.WriteFile(path, []byte(content), mode); err != nil {
return false, err
}
return true, nil
}
// --------------------------------------------------------------------- //
// Шаги
// --------------------------------------------------------------------- //
func stepInstallDeps(s *State, log func(string)) (StepStatus, error) {
log("Обновляю apt-кеш...")
if err := runCmd(log, "apt-get", "update", "-qq"); err != nil {
return StepFailed, err
}
deps := []string{
"libgtk-3-0", "libpcsclite1", "libccid", "pcscd",
"libcurl4", "libkrb5-3", "libgssapi-krb5-2",
"libsasl2-modules", "libsasl2-modules-gssapi-mit",
"execstack", "p7zip-full",
}
if hasAPTPackage("libldap-2.4-2") {
deps = append(deps, "libldap-2.4-2")
} else {
deps = append(deps, "libldap-2.5-0")
log("libldap-2.4-2 не найден → ставлю 2.5-0, для zpki будет --force-depends")
}
args := append([]string{"install", "-y", "--no-install-recommends"}, deps...)
if err := runCmd(log, "apt-get", args...); err != nil {
return StepFailed, err
}
return StepDone, nil
}
func stepInstallValidataDebs(s *State, log func(string)) (StepStatus, error) {
zpki, _ := filepath.Glob(filepath.Join(s.artifactsDir, "ClientL_Other", "zpki-*.amd64.deb"))
zsdk, _ := filepath.Glob(filepath.Join(s.artifactsDir, "ClientL_Other", "zsdk-*.amd64.deb"))
if len(zpki) == 0 {
return StepFailed, fmt.Errorf("zpki-*.amd64.deb не найден в %s/ClientL_Other/", s.artifactsDir)
}
useForce := !hasAPTPackage("libldap-2.4-2")
for _, deb := range append(zpki, zsdk...) {
args := []string{"-i", deb}
if useForce {
args = append([]string{"--force-depends"}, args...)
}
if err := runCmd(log, "dpkg", args...); err != nil {
return StepFailed, err
}
}
if _, err := os.Stat("/opt/Validata/VDCSP/lib/amd64"); err != nil {
return StepFailed, fmt.Errorf("/opt/Validata не появился после установки")
}
return StepDone, nil
}
func stepExecstack(s *State, log func(string)) (StepStatus, error) {
target := "/opt/Validata/VDCSP/lib/amd64/libvdcsp.so"
// Проверка состояния
out, err := exec.Command("execstack", "-q", target).Output()
if err == nil && strings.HasPrefix(strings.TrimSpace(string(out)), "-") {
log("executable-stack уже снят")
return StepSkipped, nil
}
return StepDone, runCmd(log, "execstack", "-c", target)
}
func stepCreateBJUser(s *State, log func(string)) (StepStatus, error) {
if _, err := exec.LookPath("id"); err == nil {
if exec.Command("id", "bj").Run() == nil {
log("Пользователь bj уже существует")
} else {
if err := runCmd(log, "useradd", "--system", "--create-home",
"--home-dir", "/var/lib/bj", "--shell", "/bin/bash", "bj"); err != nil {
return StepFailed, err
}
}
}
dirs := []struct {
Path string
Mode os.FileMode
}{
{"/var/lib/bj/usb", 0o755},
{"/var/lib/bj/.Validata", 0o700},
{"/var/lib/bj/.Validata/vdkeys", 0o700},
{"/var/lib/bj/profiles", 0o755},
{"/var/log/bj", 0o755},
{"/var/lib/bj/.bj", 0o700},
}
for _, d := range dirs {
if err := os.MkdirAll(d.Path, d.Mode); err != nil {
return StepFailed, err
}
}
return StepDone, runCmd(log, "chown", "-R", "bj:bj", "/var/lib/bj", "/var/log/bj")
}
func stepPcscdDropin(s *State, log func(string)) (StepStatus, error) {
const dropin = `[Unit]
Requires=
After=
Sockets=
[Service]
ExecStart=
ExecStart=/usr/sbin/pcscd --foreground
`
changed, err := writeFileIfChanged("/etc/systemd/system/pcscd.service.d/no-autoexit.conf", dropin, 0o644)
if err != nil {
return StepFailed, err
}
if !changed {
log("Drop-in уже актуален")
return StepSkipped, nil
}
log("Создан /etc/systemd/system/pcscd.service.d/no-autoexit.conf")
return StepDone, nil
}
func stepBJCryptoDropins(s *State, log func(string)) (StepStatus, error) {
files := map[string]string{
"/etc/systemd/system/bj-crypto.service.d/validata-paths.conf": `[Service]
WorkingDirectory=/opt/Validata/VDCSP/etc
ReadWritePaths=/opt/Validata/VDCSP/etc
ReadWritePaths=/var/lib/bj
`,
"/etc/systemd/system/bj-crypto.service.d/usb-access.conf": `[Service]
ReadOnlyPaths=/media
ReadOnlyPaths=/var/lib/bj/usb
`,
"/etc/systemd/system/bj-crypto.service.d/share-crysvc.conf": `[Service]
PrivateTmp=true
BindPaths=/tmp/.crysvc.sock:/tmp/.crysvc.sock
`,
}
for path, content := range files {
if _, err := writeFileIfChanged(path, content, 0o644); err != nil {
return StepFailed, err
}
}
return StepDone, nil
}
func stepBJServerDropin(s *State, log func(string)) (StepStatus, error) {
const dropin = `[Service]
ReadWritePaths=/opt/Validata/VDCSP/etc
`
_, err := writeFileIfChanged("/etc/systemd/system/bj-server.service.d/pki1conf.conf", dropin, 0o644)
if err != nil {
return StepFailed, err
}
return StepDone, nil
}
func stepSPKIIni(s *State, log func(string)) (StepStatus, error) {
const path = "/opt/Validata/VDCSP/etc/spki.ini"
if _, err := os.Stat(path); err == nil {
log("Файл уже существует")
return StepSkipped, nil
}
const content = `[store]
count = 0
[Parameters]
PkiLdapTimeout = 10
PkiHttpTimeout = 60
`
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
return StepFailed, err
}
return StepDone, nil
}
func stepPKI1Prep(s *State, log func(string)) (StepStatus, error) {
const path = "/opt/Validata/VDCSP/etc/pki1.conf"
if _, err := os.Stat(path); err != nil {
log("Файл pki1.conf отсутствует — Валидата создаст при первом запуске")
return StepSkipped, nil
}
if err := runCmd(log, "chgrp", "bj", path); err != nil {
return StepFailed, err
}
if err := runCmd(log, "chmod", "g+w", path); err != nil {
return StepFailed, err
}
existing, _ := os.ReadFile(path)
if !strings.Contains(string(existing), "# --- bj-server: BEGIN ---") {
appended := string(existing) + "\n# --- bj-server: BEGIN ---\n# Секции профилей дописываются bj-server при импорте через /admin/setup.\n# --- bj-server: END ---\n"
if err := os.WriteFile(path, []byte(appended), 0o664); err != nil {
return StepFailed, err
}
}
return StepDone, nil
}
func stepUSBMount(s *State, log func(string)) (StepStatus, error) {
files := map[string]string{
"/etc/udev/rules.d/99-bj-usb.rules": `# Авто-mount USB-флешек в /var/lib/bj/usb/<UUID> с владельцем bj.
ACTION=="add", KERNEL=="sd?[0-9]", SUBSYSTEMS=="usb", \
ENV{ID_FS_TYPE}!="", \
ENV{SYSTEMD_WANTS}="bj-usb-mount@$env{ID_FS_UUID}.service"
ACTION=="remove", KERNEL=="sd?[0-9]", SUBSYSTEMS=="usb", \
ENV{ID_FS_TYPE}!="", \
ENV{SYSTEMD_WANTS}="bj-usb-umount@$env{ID_FS_UUID}.service"
`,
"/etc/systemd/system/bj-usb-mount@.service": `[Unit]
Description=Mount USB %i to /var/lib/bj/usb/%i for bj
DefaultDependencies=no
After=local-fs.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/bin/bash -c 'mkdir -p /var/lib/bj/usb/%i && /usr/bin/mount -o uid=$(id -u bj),gid=$(id -g bj),fmask=0133,dmask=0022 UUID=%i /var/lib/bj/usb/%i'
ExecStop=/usr/bin/umount /var/lib/bj/usb/%i || true
`,
"/etc/systemd/system/bj-usb-umount@.service": `[Unit]
Description=Umount USB %i from /var/lib/bj/usb/%i
DefaultDependencies=no
[Service]
Type=oneshot
ExecStart=/usr/bin/bash -c '/usr/bin/umount /var/lib/bj/usb/%i 2>/dev/null; /usr/bin/rmdir /var/lib/bj/usb/%i 2>/dev/null; true'
`,
}
anyChanged := false
for path, content := range files {
ch, err := writeFileIfChanged(path, content, 0o644)
if err != nil {
return StepFailed, err
}
anyChanged = anyChanged || ch
}
if anyChanged {
_ = runCmd(log, "udevadm", "control", "--reload-rules")
_ = runCmd(log, "udevadm", "trigger")
}
return StepDone, nil
}
func stepInstallBJServer(s *State, log func(string)) (StepStatus, error) {
src := filepath.Join(s.artifactsDir, "bj-server")
if _, err := os.Stat(src); err != nil {
return StepSkipped, nil // нет бинаря — может ставится через rpm/deb
}
if err := os.MkdirAll("/opt/bj", 0o755); err != nil {
return StepFailed, err
}
if err := runCmd(log, "install", "-o", "bj", "-g", "bj", "-m", "0755", src, "/opt/bj/bj-server"); err != nil {
return StepFailed, err
}
return StepDone, nil
}
func stepInstallCryptoJar(s *State, log func(string)) (StepStatus, error) {
src := filepath.Join(s.artifactsDir, "crypto-service.jar")
if _, err := os.Stat(src); err != nil {
return StepSkipped, nil
}
if err := os.MkdirAll("/opt/bj", 0o755); err != nil {
return StepFailed, err
}
if err := runCmd(log, "install", "-o", "bj", "-g", "bj", "-m", "0644", src, "/opt/bj/crypto-service.jar"); err != nil {
return StepFailed, err
}
return StepDone, nil
}
func stepSystemdUnits(s *State, log func(string)) (StepStatus, error) {
units := map[string]string{
"/etc/systemd/system/bj-crypto.service": `[Unit]
Description=Bridge-and-Join-s — Crypto sidecar (Java + Валидата Клиент L)
Before=bj-server.service
After=network-online.target pcscd.service
Wants=network-online.target
[Service]
Type=simple
User=bj
Group=bj
RuntimeDirectory=bj
RuntimeDirectoryMode=0750
Environment=BJ_CRYPTO_SOCKET=/run/bj/crypto.sock
Environment=BJ_CRYPTO_PROVIDER=validata
Environment=LD_LIBRARY_PATH=/opt/Validata/VDCSP/lib/amd64
ExecStart=/usr/bin/java -Djava.library.path=/opt/Validata/VDCSP/lib/amd64 -jar /opt/bj/crypto-service.jar
Restart=on-failure
RestartSec=5
StandardOutput=append:/var/log/bj/crypto-service.log
StandardError=append:/var/log/bj/crypto-service.err
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/run/bj /var/log/bj
PrivateTmp=true
[Install]
WantedBy=multi-user.target
`,
"/etc/systemd/system/bj-server.service": `[Unit]
Description=Bridge-and-Join-s — единый сервис M2M-переводов
After=network-online.target bj-crypto.service
Wants=network-online.target
[Service]
Type=simple
User=bj
Group=bj
WorkingDirectory=/var/lib/bj
ExecStart=/opt/bj/bj-server
Restart=on-failure
RestartSec=5
Environment=BJ_HTTP_ADDR=:8080
Environment=BJ_SETUP_PATH=/var/lib/bj/.bj/setup.json
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/bj /var/log/bj
[Install]
WantedBy=multi-user.target
`,
}
for path, content := range units {
if _, err := writeFileIfChanged(path, content, 0o644); err != nil {
return StepFailed, err
}
}
return StepDone, runCmd(log, "systemctl", "daemon-reload")
}
func stepInstallISH(s *State, log func(string)) (StepStatus, error) {
matches, _ := filepath.Glob(filepath.Join(s.artifactsDir, "ish", "igate_*.deb"))
if len(matches) == 0 {
log("Дистрибутив ИШ не найден — пропускаю (можно установить позже)")
return StepSkipped, nil
}
if err := runCmd(log, "dpkg", "-i", matches[0]); err != nil {
// допустим, что зависимости подтянутся
_ = runCmd(log, "apt-get", "-f", "install", "-y")
if err := runCmd(log, "dpkg", "-i", matches[0]); err != nil {
return StepFailed, err
}
}
return StepDone, nil
}
func stepSaveConfig(s *State, log func(string)) (StepStatus, error) {
cfg := s.Snapshot().Config
if cfg.OrgINN == "" && cfg.AdminEmail == "" && cfg.LicenseKey == "" {
return StepSkipped, nil
}
b, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return StepFailed, err
}
if err := os.MkdirAll("/var/lib/bj/.bj", 0o700); err != nil {
return StepFailed, err
}
if err := os.WriteFile("/var/lib/bj/.bj/setup.json", b, 0o600); err != nil {
return StepFailed, err
}
return StepDone, runCmd(log, "chown", "-R", "bj:bj", "/var/lib/bj/.bj")
}
func stepStartServices(s *State, log func(string)) (StepStatus, error) {
// disable+stop socket activation для pcscd
_ = runCmd(log, "systemctl", "stop", "pcscd.socket")
_ = runCmd(log, "systemctl", "disable", "pcscd.socket")
for _, svc := range []string{"pcscd", "bj-crypto", "bj-server"} {
if err := runCmd(log, "systemctl", "enable", svc); err != nil {
return StepFailed, err
}
if err := runCmd(log, "systemctl", "restart", svc); err != nil {
return StepFailed, err
}
}
return StepDone, nil
}
func stepHealthCheck(s *State, log func(string)) (StepStatus, error) {
var bad []string
for _, svc := range []string{"pcscd", "vdcrysvc", "bj-crypto", "bj-server"} {
if err := exec.Command("systemctl", "is-active", "--quiet", svc).Run(); err != nil {
bad = append(bad, svc)
} else {
log(svc + ": active")
}
}
if len(bad) > 0 {
return StepFailed, fmt.Errorf("сервисы не запустились: %s", strings.Join(bad, ", "))
}
return StepDone, nil
}
// hasAPTPackage — проверяет наличие пакета в apt-cache (доступен ли для установки).
func hasAPTPackage(name string) bool {
out, err := exec.Command("apt-cache", "show", name).CombinedOutput()
if err != nil {
return false
}
return strings.Contains(string(out), "Package: "+name)
}
-168
View File
@@ -1,168 +0,0 @@
// Минимальный клиент wizard'а: рендерит страницы, ловит события из SSE,
// отправляет POST'ы на backend для перехода между стадиями.
let state = {
stage: "welcome",
precheck: [],
config: {},
steps: [],
errorMsg: "",
};
const STAGE_ORDER = ["welcome", "precheck", "config", "installing", "done"];
const STEP_ICONS = {
pending: "○",
running: "◐",
done: "✓",
skipped: "—",
failed: "✗",
};
function $(sel) { return document.querySelector(sel); }
function $$(sel) { return [...document.querySelectorAll(sel)]; }
function render() {
// stepper
$$("#stepper span").forEach(el => {
el.classList.remove("active", "done");
const stage = el.dataset.stage;
if (stage === state.stage) el.classList.add("active");
if (STAGE_ORDER.indexOf(stage) < STAGE_ORDER.indexOf(state.stage)) el.classList.add("done");
});
// pages
$$(".page").forEach(p => p.classList.toggle("active", p.dataset.stage === state.stage));
if (state.stage === "precheck") renderPrecheck();
if (state.stage === "installing" || state.stage === "done") renderSteps();
if (state.stage === "error") $("#error-message").textContent = state.errorMsg || "(нет деталей)";
if (state.stage === "done") {
// подставляем хост машины в админскую ссылку
const adminURL = window.location.protocol + "//" + window.location.hostname + ":8080/admin/setup";
$("#adminLink").href = adminURL;
$("#adminLink").textContent = "Перейти в " + adminURL + " →";
}
}
function renderPrecheck() {
const root = $("#precheck-results");
root.innerHTML = "";
let allOK = true;
for (const r of state.precheck || []) {
const div = document.createElement("div");
div.className = "check " + (r.ok ? "ok" : "bad");
div.innerHTML = `
<span class="check-icon">${r.ok ? "✓" : "✗"}</span>
<div>
<div class="check-title">${escapeHTML(r.title)}</div>
${r.message ? `<div class="check-msg">${escapeHTML(r.message)}</div>` : ""}
</div>`;
root.appendChild(div);
if (!r.ok) allOK = false;
}
$("#goConfigBtn").disabled = !allOK;
}
function renderSteps() {
const root = $("#step-list");
root.innerHTML = "";
let done = 0;
for (const s of state.steps || []) {
const li = document.createElement("li");
li.className = "step-" + s.status;
li.innerHTML = `
<span class="step-icon">${STEP_ICONS[s.status] || "○"}</span>
<div>
<div class="step-title">${escapeHTML(s.title)}</div>
${s.message ? `<div class="step-msg">${escapeHTML(s.message)}</div>` : ""}
</div>`;
root.appendChild(li);
if (s.status === "done" || s.status === "skipped") done++;
}
const total = state.steps.length;
const pct = total ? Math.round(100 * done / total) : 0;
$("#progress-bar").style.width = pct + "%";
}
function escapeHTML(s) {
return String(s).replace(/[&<>"']/g, c => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;"
}[c]));
}
// ------------- transitions -------------
async function startPrecheck() {
await fetch("/api/precheck", { method: "POST" });
}
function goWelcome() {
state.stage = "welcome";
render();
}
function goPrecheck() {
state.stage = "precheck";
render();
}
async function goConfig() {
state.stage = "config";
render();
}
async function startInstall() {
const form = $("#config-form");
const data = Object.fromEntries(new FormData(form).entries());
await fetch("/api/config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
await fetch("/api/install", { method: "POST" });
}
async function resetWizard() {
await fetch("/api/reset", { method: "POST" });
}
// ------------- SSE -------------
function connectSSE() {
const es = new EventSource("/api/events");
es.addEventListener("snapshot", e => {
const snap = JSON.parse(e.data);
state.stage = snap.stage;
state.precheck = snap.precheck || [];
state.config = snap.config || {};
state.steps = snap.steps || [];
state.errorMsg = snap.errorMsg || "";
render();
});
es.addEventListener("stage", e => {
state.stage = JSON.parse(e.data).stage;
render();
});
es.addEventListener("precheck", e => {
state.precheck = JSON.parse(e.data);
render();
});
es.addEventListener("step", e => {
const s = JSON.parse(e.data);
const idx = state.steps.findIndex(x => x.id === s.id);
if (idx >= 0) state.steps[idx] = s;
render();
});
es.addEventListener("error", e => {
state.errorMsg = JSON.parse(e.data).message;
state.stage = "error";
render();
});
es.addEventListener("reset", () => {
location.reload();
});
es.onerror = () => {
// авто-реконнект делает EventSource сам, ничего не делаем
};
}
connectSSE();
-110
View File
@@ -1,110 +0,0 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>bj-installer — мастер установки Bridge-and-Join-s</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<header class="topbar">
<div class="logo">Bridge-and-Join-s</div>
<div class="subtitle">мастер установки</div>
</header>
<main id="app">
<!-- ===== Stepper ===== -->
<nav class="stepper" id="stepper">
<span data-stage="welcome">1. Старт</span>
<span data-stage="precheck">2. Проверка</span>
<span data-stage="config">3. Настройка</span>
<span data-stage="installing">4. Установка</span>
<span data-stage="done">5. Готово</span>
</nav>
<!-- ===== Welcome ===== -->
<section class="page" data-stage="welcome">
<h1>Добро пожаловать</h1>
<p>Этот мастер установит на сервер <b>СКЗИ «Валидата Клиент L»</b>,
<b>bj-server</b>, <b>bj-crypto</b> и <b>ИШ НРД</b>, настроит
systemd-сервисы и подготовит окружение для подписи документов
по ГОСТ 34.10-2012.</p>
<p class="muted">После установки откроется <code>/admin/setup</code> в bj-server, где можно
загрузить тестовый профиль от MOEX (.7z) и активировать подпись.</p>
<div class="buttons">
<button class="primary" onclick="startPrecheck()">Начать →</button>
</div>
</section>
<!-- ===== Precheck ===== -->
<section class="page" data-stage="precheck">
<h1>Проверка системы</h1>
<div id="precheck-results" class="checks"></div>
<div class="buttons">
<button onclick="goWelcome()">← Назад</button>
<button class="primary" id="goConfigBtn" onclick="goConfig()">Дальше →</button>
</div>
</section>
<!-- ===== Config ===== -->
<section class="page" data-stage="config">
<h1>Настройка</h1>
<form id="config-form" onsubmit="event.preventDefault(); startInstall();">
<label>ИНН организации
<input type="text" name="orgInn" placeholder="7702077840" pattern="\d{10}|\d{12}">
</label>
<label>Название организации (для отображения)
<input type="text" name="orgName" placeholder="ПАО Московская Биржа">
</label>
<label>Email администратора
<input type="email" name="adminEmail" placeholder="admin@example.com">
</label>
<label>Лицензионный ключ (опционально)
<input type="text" name="licenseKey" placeholder="BJ-XXXX-XXXX-XXXX">
<span class="muted">Без ключа сервис работает, но обновления заблокированы. Получить можно в личном кабинете.</span>
</label>
<div class="buttons">
<button type="button" onclick="goPrecheck()">← Назад</button>
<button class="primary" type="submit">Установить →</button>
</div>
</form>
</section>
<!-- ===== Installing ===== -->
<section class="page" data-stage="installing">
<h1>Установка</h1>
<ol id="step-list" class="steps"></ol>
<div class="progress"><div id="progress-bar" class="progress-bar"></div></div>
</section>
<!-- ===== Done ===== -->
<section class="page" data-stage="done">
<h1>✓ Готово</h1>
<p>bj-server и все сервисы запущены. Откройте панель администратора и
импортируйте профиль:</p>
<div class="next-link">
<a href="" id="adminLink" class="primary-link">Перейти в /admin/setup →</a>
</div>
<p class="muted">Что дальше:</p>
<ol>
<li>Подключите USB с .vdk → он автоматически смонтируется в <code>/var/lib/bj/usb/</code></li>
<li>На <code>/admin/setup</code> загрузите .7z с профилем от MOEX и введите пароль</li>
<li>Нажмите «Активировать» — bj-crypto подтянет ключ и подтвердит готовность</li>
</ol>
</section>
<!-- ===== Error ===== -->
<section class="page" data-stage="error">
<h1>✗ Установка прервана</h1>
<p>Произошла ошибка:</p>
<pre id="error-message" class="error"></pre>
<p class="muted">Логи: <code>journalctl -u bj-installer</code> и <code>journalctl -u bj-crypto</code></p>
<div class="buttons">
<button onclick="resetWizard()">Начать заново</button>
</div>
</section>
</main>
<script src="/app.js"></script>
</body>
</html>
-179
View File
@@ -1,179 +0,0 @@
:root {
--bg: #f6f7fb;
--card: #ffffff;
--text: #1d2330;
--muted: #6b7280;
--accent: #2563eb;
--accent-dark: #1d4ed8;
--ok: #16a34a;
--err: #dc2626;
--border: #e5e7eb;
}
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
}
.topbar {
display: flex; align-items: baseline; gap: 16px;
padding: 18px 32px;
background: #0f172a;
color: #fff;
border-bottom: 1px solid #1e293b;
}
.logo { font-weight: 700; font-size: 18px; letter-spacing: 0.3px; }
.subtitle { color: #94a3b8; font-size: 14px; }
main {
max-width: 760px;
margin: 24px auto;
padding: 0 16px;
}
.stepper {
display: flex; gap: 8px;
margin-bottom: 24px;
padding: 12px 14px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 10px;
font-size: 13px;
overflow-x: auto;
}
.stepper span {
padding: 6px 12px;
border-radius: 6px;
color: var(--muted);
white-space: nowrap;
}
.stepper span.active {
background: var(--accent);
color: #fff;
font-weight: 600;
}
.stepper span.done {
color: var(--ok);
}
.page {
display: none;
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 28px 32px;
}
.page.active { display: block; }
h1 { margin: 0 0 16px; font-size: 22px; }
p, label { font-size: 15px; }
.muted { color: var(--muted); font-size: 13px; }
code {
background: #f1f5f9;
padding: 2px 6px;
border-radius: 4px;
font-size: 13px;
}
.buttons {
display: flex; gap: 12px;
margin-top: 24px;
justify-content: flex-end;
}
button, .primary-link {
padding: 10px 18px;
font-size: 14px;
font-weight: 500;
border: 1px solid var(--border);
background: #fff;
color: var(--text);
border-radius: 8px;
cursor: pointer;
text-decoration: none;
display: inline-block;
}
button.primary, .primary-link {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
button.primary:hover, .primary-link:hover { background: var(--accent-dark); }
button:disabled { opacity: 0.5; cursor: not-allowed; }
/* Precheck */
.checks { display: flex; flex-direction: column; gap: 10px; margin-top: 12px; }
.check {
display: flex; gap: 12px; align-items: center;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 8px;
}
.check.ok { border-color: var(--ok); }
.check.bad { border-color: var(--err); }
.check-icon { font-size: 18px; }
.check.ok .check-icon { color: var(--ok); }
.check.bad .check-icon { color: var(--err); }
.check-title { font-weight: 500; }
.check-msg { font-size: 13px; color: var(--muted); }
/* Config form */
#config-form { display: flex; flex-direction: column; gap: 16px; }
#config-form label {
display: flex; flex-direction: column; gap: 4px;
font-weight: 500;
}
#config-form input {
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 8px;
font: inherit;
background: #fff;
}
#config-form input:focus { outline: 2px solid var(--accent); outline-offset: -1px; border-color: var(--accent); }
/* Installing steps */
.steps { list-style: none; padding: 0; margin: 16px 0; }
.steps li {
display: flex; gap: 12px; align-items: flex-start;
padding: 10px 0;
border-bottom: 1px solid var(--border);
}
.steps li:last-child { border-bottom: none; }
.step-icon { font-size: 16px; min-width: 24px; line-height: 1.5; }
.step-title { font-weight: 500; }
.step-msg { font-size: 12px; color: var(--muted); margin-top: 2px; word-break: break-word; }
.step-pending .step-icon { color: var(--muted); }
.step-running .step-icon { color: var(--accent); animation: spin 1.2s linear infinite; }
.step-done .step-icon { color: var(--ok); }
.step-skipped .step-icon { color: var(--muted); }
.step-failed .step-icon { color: var(--err); }
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.progress {
margin-top: 16px;
background: var(--border);
border-radius: 4px;
height: 6px;
overflow: hidden;
}
.progress-bar {
height: 100%;
width: 0%;
background: var(--accent);
transition: width 0.3s ease;
}
/* Done */
.next-link { margin: 20px 0; }
/* Error */
.error {
background: #fef2f2;
border: 1px solid var(--err);
color: var(--err);
padding: 12px;
border-radius: 8px;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 13px;
overflow-x: auto;
white-space: pre-wrap;
}
-96
View File
@@ -1,96 +0,0 @@
// Command bj-license-server — онлайн-сервис учёта и отзыва лицензий.
//
// Базовая модель лицензирования офлайновая: bj-server проверяет подпись и
// срок сам. Этот сервер нужен для:
// - реестра выданных лицензий (учёт);
// - ОТЗЫВА (revocation) до окончания срока;
// - проверки клиентом «не отозвана ли» (опциональный online-чек).
//
// Хранилище — JSON-файл со списком отозванных ID (для каркаса; в проде —
// PostgreSQL). API:
//
// GET /v1/check?id=<license-id> → {"revoked":bool}
// GET /healthz
//
// Управление отзывом — правкой файла revoked.json (или будущим admin API).
package main
import (
"encoding/json"
"flag"
"log"
"net/http"
"os"
"sync"
"time"
)
type store struct {
mu sync.RWMutex
path string
revoked map[string]bool
}
func newStore(path string) *store {
s := &store{path: path, revoked: map[string]bool{}}
s.load()
return s
}
func (s *store) load() {
s.mu.Lock()
defer s.mu.Unlock()
b, err := os.ReadFile(s.path)
if err != nil {
return // файла нет — пустой список
}
var ids []string
if err := json.Unmarshal(b, &ids); err != nil {
log.Printf("license-server: разбор %s: %v", s.path, err)
return
}
s.revoked = map[string]bool{}
for _, id := range ids {
s.revoked[id] = true
}
log.Printf("license-server: загружено отозванных лицензий: %d", len(s.revoked))
}
func (s *store) isRevoked(id string) bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.revoked[id]
}
func main() {
addr := flag.String("addr", ":8091", "адрес прослушивания")
file := flag.String("revoked", "./revoked.json", "JSON-файл со списком отозванных license ID")
flag.Parse()
st := newStore(*file)
// Перечитываем файл отзывов раз в минуту (горячее применение).
go func() {
t := time.NewTicker(time.Minute)
defer t.Stop()
for range t.C {
st.load()
}
}()
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) })
mux.HandleFunc("/v1/check", func(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id")
if id == "" {
http.Error(w, "id required", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]bool{"revoked": st.isRevoked(id)})
})
log.Printf("license-server: слушаю %s, отзывы из %s", *addr, *file)
srv := &http.Server{Addr: *addr, Handler: mux, ReadHeaderTimeout: 10 * time.Second}
log.Fatal(srv.ListenAndServe())
}
-192
View File
@@ -1,192 +0,0 @@
// Command bj-license — инструмент издателя: генерация ключей подписи,
// выпуск годовых лицензий и проверка.
//
// bj-license keygen -out ./keys/license
// bj-license issue -tenant "ООО Ромашка" -plan pro -days 365 \
// -features updates,web-cabinet -key ./keys/license.priv -keyid main
// bj-license verify -key-file license.key -pub ./keys/license.pub
package main
import (
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"fmt"
"os"
"strings"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/license"
)
// newUUID — UUID v4 без внешних зависимостей.
func newUUID() string {
var b [16]byte
_, _ = rand.Read(b[:])
b[6] = (b[6] & 0x0f) | 0x40 // версия 4
b[8] = (b[8] & 0x3f) | 0x80 // вариант
h := hex.EncodeToString(b[:])
return h[0:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:32]
}
func main() {
if len(os.Args) < 2 {
usage()
}
switch os.Args[1] {
case "keygen":
keygen(os.Args[2:])
case "issue":
issue(os.Args[2:])
case "verify":
verify(os.Args[2:])
default:
usage()
}
}
func usage() {
fmt.Fprintln(os.Stderr, "bj-license keygen -out <prefix>")
fmt.Fprintln(os.Stderr, "bj-license issue -tenant <name> -plan free|pro|enterprise -days <n> -features a,b -key <priv> [-keyid id] [-max-nodes n] [-note txt]")
fmt.Fprintln(os.Stderr, "bj-license verify -key-file <license.key> -pub <pubkey.pub>")
os.Exit(2)
}
func keygen(args []string) {
out := "license"
for i := 0; i < len(args)-1; i++ {
if args[i] == "-out" {
out = args[i+1]
}
}
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
fatal("keygen: %v", err)
}
if err := os.WriteFile(out+".priv", []byte(base64.StdEncoding.EncodeToString(priv.Seed())+"\n"), 0o600); err != nil {
fatal("write priv: %v", err)
}
if err := os.WriteFile(out+".pub", []byte(base64.StdEncoding.EncodeToString(pub)+"\n"), 0o644); err != nil {
fatal("write pub: %v", err)
}
fmt.Printf("Приватный ключ лицензий: %s.priv (СЕКРЕТ)\n", out)
fmt.Printf("Публичный ключ (зашить в bj-server):\n %s\n", base64.StdEncoding.EncodeToString(pub))
}
func issue(args []string) {
a := parseArgs(args)
tenant := a["tenant"]
keyPath := a["key"]
if tenant == "" || keyPath == "" {
fatal("issue: требуются -tenant и -key")
}
plan := license.Plan(orDefault(a["plan"], "pro"))
days := atoiDefault(a["days"], 365)
keyID := orDefault(a["keyid"], "main")
priv, err := license.LoadPrivateKey(keyPath)
if err != nil {
fatal("load key: %v", err)
}
now := time.Now().UTC()
var feats []string
if a["features"] != "" {
feats = strings.Split(a["features"], ",")
}
l := &license.License{
Schema: license.CurrentSchema,
ID: newUUID(),
Tenant: tenant,
Product: "bj-server",
Plan: plan,
IssuedAt: now,
ExpiresAt: now.AddDate(0, 0, days),
Features: feats,
MaxNodes: atoiDefault(a["max-nodes"], 0),
Note: a["note"],
}
tok, err := license.Sign(l, priv, keyID)
if err != nil {
fatal("sign: %v", err)
}
fmt.Printf("Лицензия выпущена: tenant=%q plan=%s до %s (%d дней)\n",
tenant, plan, l.ExpiresAt.Format("02.01.2006"), days)
fmt.Printf("ID: %s\n", l.ID)
fmt.Println("Ключ для клиента (вставить в bj-server → Лицензия):")
fmt.Println(tok.Encode())
}
func verify(args []string) {
a := parseArgs(args)
if a["key-file"] == "" || a["pub"] == "" {
fatal("verify: требуются -key-file и -pub")
}
raw, err := os.ReadFile(a["key-file"])
if err != nil {
fatal("read key-file: %v", err)
}
pubB, err := os.ReadFile(a["pub"])
if err != nil {
fatal("read pub: %v", err)
}
pub, err := license.ParsePublicKey(strings.TrimSpace(string(pubB)))
if err != nil {
fatal("pub: %v", err)
}
tok, err := license.DecodeToken(string(raw))
if err != nil {
fatal("decode: %v", err)
}
l, err := license.Verify(tok, pub)
if err != nil {
fatal("verify: %v", err)
}
now := time.Now().UTC()
fmt.Printf("Подпись валидна. tenant=%q plan=%s\n", l.Tenant, l.Plan)
fmt.Printf("Действует: %s — %s (осталось %d дней)\n",
l.IssuedAt.Format("02.01.2006"), l.ExpiresAt.Format("02.01.2006"), l.DaysLeft(now))
if err := l.Valid(now); err != nil {
fmt.Printf("СТАТУС: %v\n", err)
} else {
fmt.Printf("СТАТУС: активна, обновления %v\n", l.AllowsUpdates())
}
}
// --- helpers ---
func parseArgs(args []string) map[string]string {
m := map[string]string{}
for i := 0; i < len(args); i++ {
if strings.HasPrefix(args[i], "-") && i+1 < len(args) {
m[strings.TrimPrefix(args[i], "-")] = args[i+1]
i++
}
}
return m
}
func orDefault(s, def string) string {
if s == "" {
return def
}
return s
}
func atoiDefault(s string, def int) int {
if s == "" {
return def
}
var n int
_, err := fmt.Sscanf(s, "%d", &n)
if err != nil {
return def
}
return n
}
func fatal(format string, a ...any) {
fmt.Fprintf(os.Stderr, "bj-license: "+format+"\n", a...)
os.Exit(1)
}
-169
View File
@@ -1,169 +0,0 @@
// Command bj-release — инструмент издателя: генерация ключей подписи,
// сборка манифеста релиза из каталога артефактов и его подпись Ed25519.
//
// Использование:
//
// bj-release keygen -out ./keys/signing
// → создаёт signing.priv (base64 seed) и signing.pub (base64 pubkey)
//
// bj-release build -dir ./dist -version 1.2.0 -channel stable \
// -key ./keys/signing.priv -keyid main -out ./dist/manifest.json
// → хеширует все файлы в ./dist, собирает Manifest, подписывает,
// пишет SignedManifest в manifest.json
//
// Манифест подписывается целиком; клиент (bj-server auto-update) проверяет
// подпись зашитым публичным ключом ДО доверия версиям/хешам.
package main
import (
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/release"
)
func main() {
if len(os.Args) < 2 {
usage()
}
switch os.Args[1] {
case "keygen":
keygen(os.Args[2:])
case "build":
build(os.Args[2:])
default:
usage()
}
}
func usage() {
fmt.Fprintln(os.Stderr, "bj-release keygen -out <prefix>")
fmt.Fprintln(os.Stderr, "bj-release build -dir <artifacts> -version <v> -channel <c> -key <priv> -keyid <id> -out <manifest.json> [-notes <txt>]")
os.Exit(2)
}
func keygen(args []string) {
fs := flag.NewFlagSet("keygen", flag.ExitOnError)
out := fs.String("out", "signing", "префикс файлов ключей (создаст <out>.priv и <out>.pub)")
_ = fs.Parse(args)
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
fatal("keygen: %v", err)
}
seed := priv.Seed()
if err := os.WriteFile(*out+".priv", []byte(base64.StdEncoding.EncodeToString(seed)+"\n"), 0o600); err != nil {
fatal("write priv: %v", err)
}
if err := os.WriteFile(*out+".pub", []byte(base64.StdEncoding.EncodeToString(pub)+"\n"), 0o644); err != nil {
fatal("write pub: %v", err)
}
fmt.Printf("Приватный ключ: %s.priv (НЕ КОММИТИТЬ, держать в секрете)\n", *out)
fmt.Printf("Публичный ключ: %s.pub\n", *out)
fmt.Printf("Публичный ключ (зашить в bj-server):\n %s\n", base64.StdEncoding.EncodeToString(pub))
}
func build(args []string) {
fs := flag.NewFlagSet("build", flag.ExitOnError)
dir := fs.String("dir", "./dist", "каталог с артефактами")
version := fs.String("version", "", "версия релиза, напр. 1.2.0")
channel := fs.String("channel", "stable", "канал: stable|beta")
keyPath := fs.String("key", "", "путь к приватному ключу (base64 seed)")
keyID := fs.String("keyid", "main", "идентификатор ключа")
out := fs.String("out", "", "путь для записи manifest.json (по умолчанию <dir>/manifest.json)")
notes := fs.String("notes", "", "заметки к релизу")
_ = fs.Parse(args)
if *version == "" || *keyPath == "" {
fatal("build: требуются -version и -key")
}
if *out == "" {
*out = filepath.Join(*dir, "manifest.json")
}
priv, err := release.LoadPrivateKey(*keyPath)
if err != nil {
fatal("load key: %v", err)
}
// Имена артефактов, которые издаём (логическое имя → ставить +x).
known := map[string]bool{
"bj-server": true, // Go-бинарь
"crypto-service.jar": false, // Java сайдкар
"install-validata.sh": true,
"install.sh": true,
"configure-ish.sql": false,
}
entries, err := os.ReadDir(*dir)
if err != nil {
fatal("read dir: %v", err)
}
var arts []release.Artifact
for _, e := range entries {
if e.IsDir() || e.Name() == "manifest.json" {
continue
}
full := filepath.Join(*dir, e.Name())
sha, size, err := release.HashFile(full)
if err != nil {
fatal("hash %s: %v", e.Name(), err)
}
exec, ok := known[e.Name()]
if !ok {
// неизвестный файл — включаем, +x по расширению
exec = strings.HasSuffix(e.Name(), ".sh")
}
arts = append(arts, release.Artifact{
Name: e.Name(),
File: e.Name(),
Version: *version,
SHA256: sha,
Size: size,
Exec: exec,
})
}
sort.Slice(arts, func(i, j int) bool { return arts[i].Name < arts[j].Name })
if len(arts) == 0 {
fatal("build: в каталоге %s нет артефактов", *dir)
}
m := &release.Manifest{
Schema: release.CurrentSchema,
Version: *version,
Channel: *channel,
ReleasedAt: time.Now().UTC(),
Notes: *notes,
Artifacts: arts,
}
sm, err := release.Sign(m, priv, *keyID)
if err != nil {
fatal("sign: %v", err)
}
b, err := json.MarshalIndent(sm, "", " ")
if err != nil {
fatal("marshal: %v", err)
}
if err := os.WriteFile(*out, b, 0o644); err != nil {
fatal("write manifest: %v", err)
}
fmt.Printf("Манифест %s: версия %s, канал %s, артефактов %d, подписан ключом %s\n",
*out, *version, *channel, len(arts), *keyID)
for _, a := range arts {
fmt.Printf(" %-22s %10d B %s\n", a.Name, a.Size, a.SHA256[:16])
}
}
func fatal(format string, a ...any) {
fmt.Fprintf(os.Stderr, "bj-release: "+format+"\n", a...)
os.Exit(1)
}
+11 -4
View File
@@ -40,10 +40,17 @@ func main() {
DefaultSender: defaultSender,
DefaultReceiver: defaultReceiver,
SetupPath: setupPath,
// CheckOptions не задаём — server.go использует свой снапшот-based
// вариант, который читает актуальные значения из setup.json
// (DSN, crypto-сокет, URL ИШ, профиль), а не из ENV. Так проверки
// статуса совпадают с тем, что реально настроено в UI.
CheckOptions: func() lkgateway.CheckOptions {
return lkgateway.CheckOptions{
PostgresDSN: os.Getenv("BJ_DSN"),
CryptoSocket: getenv("BJ_CRYPTO_SOCKET", "/run/bj/crypto.sock"),
NSDAdapterURL: os.Getenv("BJ_NSD_ADAPTER_URL"),
LKCallbackURL: os.Getenv("BJ_LK_CALLBACK_URL"),
Profile: getenv("BJ_NSD_PROFILE", "demo (mock NSD)"),
CryptoProvider: getenv("BJ_CRYPTO_PROVIDER", "stub"),
Timeout: 2 * time.Second,
}
},
}
srv, err := lkgateway.NewServer(cfg)
-79
View File
@@ -1,79 +0,0 @@
# Артефактория Bridge-and-Join-s
Сервис раздачи релизов и обновлений (#18). Клиенты (bj-server auto-update,
install.sh) скачивают **подписанный** манифест канала, проверяют подпись
зашитым публичным ключом и обновляют компоненты.
## Компоненты
- `internal/release` — формат манифеста + подпись Ed25519 (sign/verify, хеши).
- `cmd/bj-release` — инструмент издателя: генерация ключей, сборка и подпись манифеста.
- `cmd/bj-artifactory` — HTTP-сервер раздачи манифеста и артефактов.
- `deploy/artifactory/` — nginx (TLS) + systemd unit.
## Модель доверия
Один корневой Ed25519-ключ. Приватный (`signing.priv`) держит издатель в
секрете (НЕ в git). Публичный (`signing.pub`) зашивается в bj-server и в
install.sh. Манифест подписывается целиком — клиент проверяет подпись ДО
доверия версиям и хешам артефактов, затем сверяет sha256 каждого скачанного
файла с манифестом.
## Релизный цикл (издатель)
```bash
# 1. Однократно — сгенерировать ключи подписи (приватный хранить в секрете!)
bj-release keygen -out ./keys/signing
# → keys/signing.priv (секрет), keys/signing.pub
# Публичный base64 из вывода — зашить в bj-server (auto-update, #20)
# 2. Собрать артефакты релиза в каталог
mkdir -p dist/stable
cp bj-server crypto-service.jar dist/stable/
cp deploy/linux/install-validata.sh deploy/ish/configure-ish.sql dist/stable/
# 3. Собрать + подписать манифест
bj-release build -dir dist/stable -version 1.0.0 -channel stable \
-key keys/signing.priv -keyid main -out dist/stable/manifest.json \
-notes "Первый релиз"
# 4. Выложить каталог в хранилище артефактории
rsync -a dist/stable/ server:/var/lib/bj-artifactory/releases/stable/
```
## Сервер
```bash
bj-artifactory --addr 127.0.0.1:8090 --root /var/lib/bj-artifactory/releases
```
Раскладка хранилища:
```
releases/
stable/
manifest.json ← подписанный SignedManifest
bj-server
crypto-service.jar
install-validata.sh
configure-ish.sql
beta/
manifest.json
...
```
## HTTP API
| Метод | Путь | Ответ |
|---|---|---|
| GET | `/v1/<channel>/manifest.json` | подписанный манифест канала |
| GET | `/v1/<channel>/files/<name>` | артефакт по имени |
| GET | `/healthz` | `ok` |
За TLS-reverse-proxy (`nginx.conf`). Прод: `updates.example.com` → 127.0.0.1:8090.
## Дальше
- **#19 License-сервер** — манифест/обновления гейтятся годовым ключом.
- **#20 Auto-update в bj-server** — горутина: качает манифест канала, проверяет
подпись, сравнивает версии, atomic-replace бинарей, systemctl restart.
-21
View File
@@ -1,21 +0,0 @@
[Unit]
Description=Bridge-and-Join-s — Artifactory (раздача релизов и обновлений)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=bj-updates
Group=bj-updates
ExecStart=/opt/bj-artifactory/bj-artifactory --addr 127.0.0.1:8090 --root /var/lib/bj-artifactory/releases
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadOnlyPaths=/var/lib/bj-artifactory
PrivateTmp=true
[Install]
WantedBy=multi-user.target
-44
View File
@@ -1,44 +0,0 @@
# nginx.conf — reverse-proxy для bj-artifactory с TLS.
# Раздаёт релизы и обновления bj-server по HTTPS.
#
# Установка: положить в /etc/nginx/sites-available/, заменить server_name
# и пути сертификатов, выпустить TLS через certbot, symlink в sites-enabled.
#
# updates.example.com → bj-artifactory на 127.0.0.1:8090
#
# bj-artifactory запускается как systemd-сервис (см. artifactory.service).
server {
listen 80;
server_name updates.example.com;
# Редирект на HTTPS (кроме ACME-челленджа certbot).
location /.well-known/acme-challenge/ { root /var/www/certbot; }
location / { return 301 https://$host$request_uri; }
}
server {
listen 443 ssl http2;
server_name updates.example.com;
ssl_certificate /etc/letsencrypt/live/updates.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/updates.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
# Манифесты маленькие — не кэшируем агрессивно (быстрое распространение релизов).
location /v1/ {
proxy_pass http://127.0.0.1:8090;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# Артефакты могут быть крупными (jar ~20МБ) — без буферизации тела.
proxy_buffering off;
client_max_body_size 0;
}
location /healthz {
proxy_pass http://127.0.0.1:8090;
}
# Всё остальное — 404.
location / { return 404; }
}
-1
View File
@@ -1 +0,0 @@
TEST3 GOST|TEST3GOST|WSL|t
-132
View File
@@ -1,132 +0,0 @@
-- configure-ish.sql — автонастройка ИШ НРД без GUI.
--
-- Снято как эталон с рабочей GUI-конфигурации (deploy/ish/params-reference.txt)
-- и параметризовано. Воспроизводит то, что оператор делал бы мышкой в
-- igate.exe (Avalonia): PostgreSQL + Web API + WSL-канал TEST3-GOST.
--
-- Применяется к свежей БД ИШ ПОСЛЕ того как схема создана через
-- `igate-cli --data <dir>` (он накатывает EF-миграции при первом подключении).
--
-- Подстановки (заменяются установщиком через psql -v):
-- :channel_name — отображаемое имя канала, напр. 'TEST3 GOST'
-- :channel_code — локальный код канала, напр. 'TEST3GOST'
-- :wsl_endpoint — URL службы WSL НРД (TEST3-GOST)
-- :crypto_profile — имя профиля Валидаты ('moex')
-- :repository_code— код депонента из письма НРД ('MC0079200000')
-- :exchange_dir — рабочая папка обмена ('/var/lib/igate/exchange')
-- :web_port — порт Web API ('8090')
--
-- Пример:
-- psql -h 127.0.0.1 -U igate -d igate \
-- -v channel_name="'TEST3 GOST'" -v channel_code="'TEST3GOST'" \
-- -v wsl_endpoint="'https://gost-t3.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo'" \
-- -v crypto_profile="'moex'" -v repository_code="'MC0079200000'" \
-- -v exchange_dir="'/var/lib/igate/exchange'" -v web_port="'8090'" \
-- -f configure-ish.sql
BEGIN;
-- Чистим прежнюю конфигурацию (идемпотентность)
DELETE FROM parameters;
DELETE FROM channels;
-- --- Глобальные параметры: Web API (КРИТИЧНО — runEngineOnStartApp=True,
-- иначе движок не стартует в headless-режиме и Kestrel не поднимается) ---
INSERT INTO parameters(name, value, chanel_id) VALUES
('runEngineOnStartApp', 'True', NULL),
('server.useServer', 'True', NULL),
('server.host', 'localhost', NULL),
('server.port', :web_port, NULL),
('server.scheme', 'Http', NULL),
('server.authentication.enable', 'False', NULL),
('server.authentication.userName', '', NULL),
('server.authentication.password', '', NULL),
('server.certificate.storage', 'File', NULL),
('server.certificate.store.location', 'CurrentUser', NULL),
('server.certificate.store.name', 'My', NULL),
('server.certificate.file.path', '', NULL),
('server.certificate.file.password', '', NULL),
('wsl.httpsMode', 'Auto', NULL),
('wsl.maxConnsPerServer', '4', NULL),
('wsl.proxy.mode', 'None', NULL),
('wsl.proxy.address', '', NULL),
('wsl.proxy.port', '0', NULL),
('wsl.proxy.username', '', NULL),
('wsl.proxy.password', '', NULL),
('enableDbLogging', 'False', NULL),
('cleanAutomatically', 'False', NULL),
('cleanAtTime', '00:30:00', NULL),
('cleanWhenLarger', '1024', NULL),
('cleanVacuum', 'False', NULL),
('storePeriod', '15', NULL),
('archiveAutomatically', 'False', NULL);
-- --- WSL-канал ---
-- ВАЖНО: ИШ резолвит канал по СОСТАВНОМУ коду = <код канала> + <код депонента>
-- (так формирует ИШ-GUI: TEST3 + MC0413600000 = TEST3MC0413600000). С коротким
-- кодом ИШ падает 'more than one Channel' и admin API не видит канал.
INSERT INTO channels(name, code, type, enable)
VALUES (:channel_name, :channel_code || :repository_code, 'WSL', true);
-- Параметры канала привязываем к его id (находим по составному коду)
INSERT INTO parameters(name, value, chanel_id)
SELECT n, v, c.id FROM channels c, (VALUES
('enable', 'True'),
('wslEndpoint', :wsl_endpoint),
('cryptography.type', 'GOST'),
('cryptography.profile', :crypto_profile),
('cryptography.pincode', ''),
('cryptography.clientCertificateSerialNumber', ''),
('repositoryCode', :repository_code),
('fetchInterval', '00:01:00'),
('attemptInterval', '30000'),
('sendAttempts', '3'),
('maxPartSize', '500'),
('loadOldMessagesDepth', '3'),
('isIncomingEnabled', 'True'),
('isOutgoingEnabled', 'True'),
('isTransitTerminalChannel', 'False'),
('useDirectories', 'True'),
('dir', :exchange_dir),
('inboxDirName', 'INBOX'),
('outboxDirName', 'OUTBOX'),
('sentDirName', 'SENT'),
('errorDirName', 'ERRORS'),
('archive1042sDirName', :exchange_dir || '/Archives1042S'),
('enableLockFile', 'True'),
('enableAutoResponse', 'True'),
('enable1042ReportProcessing', 'True'),
('RenameOutgoingFiles', 'True'),
('generateReceivedPackageInfo', 'True'),
('generateSentPackageInfo', 'False'),
('moveReceiptsToSentFolder', 'False'),
('applyAddHashOfPackageToFolder', 'False'),
('ignorePackageDirectoryStructure', 'False'),
('checkReceivedPackageNsdSign', 'False'),
('checkReceivedPackageSenderSign', 'False'),
('autoUpdateTransitMember', 'False'),
('automaticcalyLoadCrls', 'False'),
('autoInPkgReportOffload', 'False'),
('monitoringThreshold', '00:00:10'),
-- Пустые параметры для полного соответствия эталону GUI (движок ожидает
-- их наличие; отсутствие части может дать «Invalid value» при старте).
('autoLoadCrlsTime', ''),
('fetchThreadCount', ''),
('forceCryPackageEncryption', ''),
('inPkgReportDirectory', ''),
('inPkgReportOffloadInterval', ''),
('maxPackagesPerJob', ''),
('nsdCertificateSerialNumbers', ''),
('pkiDecryptMode', ''),
('pkiEncryptMode', ''),
('pkiSignMode', ''),
('pkiVerifyMode', ''),
('receiveProcThreadCount', ''),
('sendProcThreadCount', ''),
('updateTransitMemberListTime', '')
) AS p(n, v)
WHERE c.code = :channel_code || :repository_code;
COMMIT;
\echo 'ИШ настроен. Перезапустите igate-svc: systemctl restart igate'
-83
View File
@@ -1,83 +0,0 @@
archiveAtTime||NULL
archiveAutomatically|False|NULL
archiveRecordsOlderThan||NULL
archiveWhenLarger||NULL
cleanAtTime|00:30:00|NULL
cleanAutomatically|False|NULL
cleanVacuum|False|NULL
cleanWhenLarger|1024|NULL
enableDbLogging|False|NULL
httpTimeout||NULL
packageBackupFolder||NULL
runEngineOnStartApp|False|NULL
server.authentication.enable|False|NULL
server.authentication.password||NULL
server.authentication.userName||NULL
server.certificate.file.password||NULL
server.certificate.file.path||NULL
server.certificate.storage|File|NULL
server.certificate.store.location|CurrentUser|NULL
server.certificate.store.name|My|NULL
server.host|localhost|NULL
server.port|8090|NULL
server.scheme|Http|NULL
server.useServer|True|NULL
storePeriod|15|NULL
wsl.httpsMode|Auto|NULL
wsl.maxConnsPerServer|4|NULL
wsl.proxy.address||NULL
wsl.proxy.mode|None|NULL
wsl.proxy.password||NULL
wsl.proxy.port|0|NULL
wsl.proxy.username||NULL
applyAddHashOfPackageToFolder|False|33
archive1042sDirName|/var/lib/igate/exchange/Archives1042S|33
attemptInterval|30000|33
autoInPkgReportOffload|False|33
autoLoadCrlsTime||33
automaticcalyLoadCrls|False|33
autoUpdateTransitMember|False|33
checkReceivedPackageNsdSign|False|33
checkReceivedPackageSenderSign|False|33
cryptography.clientCertificateSerialNumber||33
cryptography.pincode||33
cryptography.profile|My|33
cryptography.type|GOST|33
dir|/var/lib/igate/exchange|33
enable|True|33
enable1042ReportProcessing|True|33
enableAutoResponse|True|33
enableLockFile|True|33
errorDirName|ERRORS|33
fetchInterval|00:01:00|33
fetchThreadCount||33
forceCryPackageEncryption||33
generateReceivedPackageInfo|True|33
generateSentPackageInfo|False|33
ignorePackageDirectoryStructure|False|33
inboxDirName|INBOX|33
inPkgReportDirectory||33
inPkgReportOffloadInterval||33
isIncomingEnabled|True|33
isOutgoingEnabled|True|33
isTransitTerminalChannel|False|33
loadOldMessagesDepth|3|33
maxPackagesPerJob||33
maxPartSize|500|33
monitoringThreshold|00:00:10|33
moveReceiptsToSentFolder|False|33
nsdCertificateSerialNumbers||33
outboxDirName|OUTBOX|33
pkiDecryptMode||33
pkiEncryptMode||33
pkiSignMode||33
pkiVerifyMode||33
receiveProcThreadCount||33
RenameOutgoingFiles|True|33
repositoryCode|MC0079200000|33
sendAttempts|3|33
sendProcThreadCount||33
sentDirName|SENT|33
updateTransitMemberListTime||33
useDirectories|True|33
wslEndpoint|https://gost-t3.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo|33
-71
View File
@@ -1,71 +0,0 @@
# Лицензирование Bridge-and-Join-s (#19)
Годовые лицензии с офлайн-проверкой по подписи Ed25519 + опциональный
онлайн-отзыв.
## Компоненты
- `internal/license` — формат лицензии + подпись/проверка (offline).
- `cmd/bj-license` — издательский CLI: keygen, issue, verify.
- `cmd/bj-license-server` — онлайн-реестр отзывов (revocation).
- Интеграция в bj-server: `internal/lkgateway/licensecheck.go` — проверка
лицензии + гейт обновлений; UI раздел «Лицензия».
## Модель
Лицензия — самодостаточный подписанный токен: `payload.signature.keyid`.
bj-server проверяет подпись зашитым публичным ключом и срок **офлайн**
работает без связи с сервером. Online-сервер нужен только для отзыва.
**Гейт обновлений:** если лицензирование включено (есть публичный ключ),
авто-обновление (#20) выполняется только при валидной лицензии с фичей
`updates`. Без лицензирования (публичный ключ не зашит) — открытый режим,
гейты не действуют (бесплатная редакция / разработка).
## Издателю
```bash
# 1. Ключи лицензий (однократно; приватный — в секрете!)
bj-license keygen -out ./keys/license
# публичный base64 — зашить в bj-server (DefaultLicensePublicKey)
# 2. Выпустить годовую лицензию клиенту
bj-license issue -tenant "ООО Ромашка" -plan pro -days 365 \
-features updates,web-cabinet -key ./keys/license.priv -keyid main
# → выводит ключ payload.signature.keyid — отдать клиенту
# 3. Проверить
bj-license verify -key-file license.key -pub ./keys/license.pub
```
Планы: `free` (без фич), `pro` (перечисленные features), `enterprise`
(всё включено). Фичи: `updates`, `web-cabinet`, …
## Клиенту (on-prem bj-server)
Админ → Настройка → **Лицензия** → вставить ключ → «Активировать».
Проверка офлайн; статус (организация, план, срок, обновления) виден сразу.
## Онлайн-отзыв (опционально)
```bash
bj-license-server --addr :8091 --revoked /var/lib/bj-license/revoked.json
```
`revoked.json` — JSON-массив отозванных license ID:
```json
["28db4973-fde8-434c-b102-e83623eede2c"]
```
`GET /v1/check?id=<id>``{"revoked":true|false}`. Перечитывается раз в
минуту. В проде заменить файл на PostgreSQL + admin API выпуска/отзыва.
## Зашивка публичного ключа в релиз
```bash
go build -ldflags "\
-X .../lkgateway.DefaultLicensePublicKey=<base64-pub> \
-X .../lkgateway.DefaultUpdatePublicKey=<base64-pub-артефактории> \
-X .../lkgateway.BuildVersion=1.0.0" -o bj-server ./cmd/bj-server/
```
-388
View File
@@ -1,388 +0,0 @@
#!/usr/bin/env bash
# install-validata.sh — установка АПК «Валидата Клиент L» под bj-crypto.
#
# Поддерживаемые ОС:
# - Debian 11 / 12 (основная, бесплатная)
# - Astra Linux SE 1.7 (платная, для регуляторно-обязанных)
# - Astra Linux CE 1.8 (бесплатная)
# - Ubuntu 22.04 / 24.04 (с предупреждением)
#
# Что делает:
# 1. Ставит зависимости (pcscd, libpcsclite, libgtk-3, libldap, p7zip, execstack)
# 2. Ставит zpki + zsdk deb-пакеты Валидаты
# 3. execstack -c libvdcsp.so (исправление GNU_STACK с RWE на RW)
# 4. Создаёт системного пользователя bj (если ещё нет)
# 5. Кладёт 5 systemd drop-ins (pcscd no-autoexit + 3×bj-crypto + 1×bj-server)
# 6. Создаёт /opt/Validata/VDCSP/etc/spki.ini (Валидата с ним капризничает)
# 7. Дописывает заголовочную секцию в pki1.conf
# 8. Включает pcscd в режиме always-on (без socket-активации) — Валидата
# ожидает постоянно живой демон, иначе ловит 0x8010001D
# 9. Ставит udev-rule + systemd-mount unit для авто-mount USB-флешек с .vdk
# в /var/lib/bj/usb/<label>/ с владельцем bj — это убирает необходимость
# пробрасывать /media/<gui-user>/
# 10. systemctl daemon-reload + enable/start bj-crypto + bj-server
#
# Идемпотентный — повторный запуск ничего не ломает.
#
# Запуск:
# sudo bash install-validata.sh [path-to-validata-zpki.deb]
#
# Если путь не передан — ищет:
# ./ClientL_Other/zpki-*.deb
# /opt/bj/src/dist/validata/*.deb
# ~/Загрузки/ClientL_Other/*.deb
# ~/Downloads/ClientL_Other/*.deb
set -euo pipefail
# --------------------------------------------------------------------- #
# Логирование
# --------------------------------------------------------------------- #
log() { echo -e "\033[1;34m[validata-install]\033[0m $*"; }
ok() { echo -e "\033[1;32m[validata-install OK]\033[0m $*"; }
warn() { echo -e "\033[1;33m[validata-install WARN]\033[0m $*" >&2; }
fail() { echo -e "\033[1;31m[validata-install FAIL]\033[0m $*" >&2; exit 1; }
[ "$EUID" -eq 0 ] || fail "Запускать от root (sudo)"
# --------------------------------------------------------------------- #
# 1. Детект ОС
# --------------------------------------------------------------------- #
DISTRO=""
DISTRO_VERSION=""
if [ -r /etc/os-release ]; then
. /etc/os-release
case "$ID" in
astra) DISTRO=astra; DISTRO_VERSION="${VERSION_ID:-unknown}";;
debian) DISTRO=debian; DISTRO_VERSION="${VERSION_ID:-unknown}";;
ubuntu) DISTRO=ubuntu; DISTRO_VERSION="${VERSION_ID:-unknown}";;
*) DISTRO="$ID"; DISTRO_VERSION="${VERSION_ID:-unknown}";;
esac
fi
case "$DISTRO" in
debian|astra) ok "ОС: $PRETTY_NAME ($DISTRO $DISTRO_VERSION) — поддерживается";;
ubuntu) warn "ОС: $PRETTY_NAME — поддерживается на свой страх (Validata в формуляре нет)";;
*) warn "ОС: $PRETTY_NAME — не проверена, продолжаю на свой страх";;
esac
# --------------------------------------------------------------------- #
# 2. Поиск deb-пакетов Валидаты
# --------------------------------------------------------------------- #
ZPKI_DEB=""
ZSDK_DEB=""
if [ -n "${1:-}" ] && [ -f "$1" ]; then
# Конкретный файл передан аргументом
ZPKI_DEB="$1"
# zsdk ищем рядом
ZSDK_DEB="$(dirname "$1")/$(basename "$1" | sed 's/zpki/zsdk/')"
else
for d in \
./ClientL_Other \
./ClientL_ALSE \
/opt/bj/src/dist/validata \
/home/*/Загрузки/ClientL_Other \
/home/*/Загрузки/ClientL_ALSE \
/home/*/Downloads/ClientL_Other \
/home/*/Downloads/ClientL_ALSE \
/tmp/ClientL_Other; do
[ -d "$d" ] || continue
cand_zpki=$(find "$d" -maxdepth 1 -name "zpki-*.amd64.deb" 2>/dev/null | head -1)
cand_zsdk=$(find "$d" -maxdepth 1 -name "zsdk-*.amd64.deb" 2>/dev/null | head -1)
if [ -n "$cand_zpki" ]; then
ZPKI_DEB="$cand_zpki"
ZSDK_DEB="$cand_zsdk"
break
fi
done
fi
[ -n "$ZPKI_DEB" ] || fail "Не найден zpki-*.amd64.deb. Скачайте https://fs.moex.com/cdp/po/ClientL_Other.zip и распакуйте рядом со скриптом или в /opt/bj/src/dist/validata/"
log "Найдено:"
log " zpki: $ZPKI_DEB"
[ -n "$ZSDK_DEB" ] && log " zsdk: $ZSDK_DEB" || warn " zsdk не найден — будет пропущен"
# --------------------------------------------------------------------- #
# 3. Системные зависимости
# --------------------------------------------------------------------- #
log "Обновляю apt-кеш и ставлю зависимости..."
apt-get update -qq
# Базовые зависимости (одинаковые на Debian/Astra)
DEPS=(
libgtk-3-0
libpcsclite1
libccid
pcscd
libcurl4
libkrb5-3
libgssapi-krb5-2
libsasl2-modules
libsasl2-modules-gssapi-mit
execstack
p7zip-full
util-linux
uuid-runtime
)
# libldap-2.4-2 в Bullseye/Astra; в Bookworm уже libldap-2.5-0.
# Зависимость в zpki жёстко на 2.4-2 → используем --force-depends на этом этапе
# и ставим libldap-2.5-0 как замену (ABI совместим в нашем use-case).
if apt-cache show libldap-2.4-2 >/dev/null 2>&1; then
DEPS+=(libldap-2.4-2)
USE_FORCE=0
else
DEPS+=(libldap-2.5-0)
USE_FORCE=1
warn "libldap-2.4-2 недоступен → ставлю libldap-2.5-0 и буду форсить --force-depends при установке zpki"
fi
apt-get install -y --no-install-recommends "${DEPS[@]}"
ok "Зависимости установлены"
# --------------------------------------------------------------------- #
# 4. Установка Валидаты
# --------------------------------------------------------------------- #
log "Ставлю zpki..."
if [ "$USE_FORCE" = "1" ]; then
dpkg --force-depends -i "$ZPKI_DEB"
else
dpkg -i "$ZPKI_DEB" || { apt-get -f install -y; dpkg -i "$ZPKI_DEB"; }
fi
if [ -n "$ZSDK_DEB" ]; then
log "Ставлю zsdk..."
dpkg -i "$ZSDK_DEB" || { apt-get -f install -y; dpkg -i "$ZSDK_DEB"; }
fi
[ -d /opt/Validata/VDCSP/lib/amd64 ] || fail "Валидата не установилась в /opt/Validata — проверьте dpkg -L zpki"
ok "Валидата в /opt/Validata/VDCSP"
# --------------------------------------------------------------------- #
# 5. execstack libvdcsp.so (GNU_STACK RWE → RW)
# --------------------------------------------------------------------- #
log "execstack -c libvdcsp.so (требование Валидаты)..."
if execstack -q /opt/Validata/VDCSP/lib/amd64/libvdcsp.so 2>/dev/null | grep -q '^X'; then
execstack -c /opt/Validata/VDCSP/lib/amd64/libvdcsp.so
ok "executable-stack снят"
else
ok "executable-stack уже снят"
fi
# --------------------------------------------------------------------- #
# 6. Системный пользователь bj
# --------------------------------------------------------------------- #
if ! id bj >/dev/null 2>&1; then
log "Создаю системного пользователя bj..."
useradd --system --create-home --home-dir /var/lib/bj --shell /bin/bash bj
ok "Пользователь bj создан (home=/var/lib/bj)"
else
ok "Пользователь bj уже есть"
fi
install -d -o bj -g bj -m 0755 /var/lib/bj/usb
install -d -o bj -g bj -m 0700 /var/lib/bj/.Validata
install -d -o bj -g bj -m 0700 /var/lib/bj/.Validata/vdkeys
install -d -o bj -g bj -m 0755 /var/lib/bj/profiles
install -d -o bj -g bj -m 0755 /var/log/bj
# --------------------------------------------------------------------- #
# 7. pcscd: убираем --auto-exit и socket-активацию
# --------------------------------------------------------------------- #
log "Настраиваю pcscd как always-on демон..."
install -d /etc/systemd/system/pcscd.service.d
cat >/etc/systemd/system/pcscd.service.d/no-autoexit.conf <<'EOF'
[Unit]
# Отвязываем сервис от сокет-юнита, чтобы можно было держать его постоянно живым
Requires=
After=
Sockets=
[Service]
# Убираем --auto-exit — Валидата ожидает постоянно живой pcscd, иначе
# получает 0x8010001D «Диспетчер ресурсов смарт-карт не выполняется»
# при попытке найти ключевой носитель (.vdk файл выглядит для неё как
# виртуальная смарт-карта)
ExecStart=
ExecStart=/usr/sbin/pcscd --foreground
EOF
ok "pcscd drop-in: /etc/systemd/system/pcscd.service.d/no-autoexit.conf"
# --------------------------------------------------------------------- #
# 8. bj-crypto drop-ins
# --------------------------------------------------------------------- #
log "Кладу drop-ins для bj-crypto..."
install -d /etc/systemd/system/bj-crypto.service.d
cat >/etc/systemd/system/bj-crypto.service.d/validata-paths.conf <<'EOF'
[Service]
# Валидата ищет pki1.conf в текущей рабочей директории — работаем оттуда
WorkingDirectory=/opt/Validata/VDCSP/etc
# Валидата пишет в /opt/Validata/VDCSP/etc/pki1.conf при инициализации
# профиля. ProtectSystem=strict делает /opt read-only — открываем точечно.
ReadWritePaths=/opt/Validata/VDCSP/etc
ReadWritePaths=/var/lib/bj
EOF
cat >/etc/systemd/system/bj-crypto.service.d/usb-access.conf <<'EOF'
[Service]
# Без этого PrivateTmp + ProtectSystem закроет /media и /var/lib/bj/usb,
# а нам нужно туда смотреть в поисках .vdk на флешке.
ReadOnlyPaths=/media
ReadOnlyPaths=/var/lib/bj/usb
EOF
cat >/etc/systemd/system/bj-crypto.service.d/share-crysvc.conf <<'EOF'
[Service]
# Валидата общается с криптодрайвером (vdcrysvc) через Unix-сокет
# /tmp/.crysvc.sock — но PrivateTmp=true даёт нам приватный /tmp.
# Прокидываем именно этот сокет внутрь нашего namespace.
PrivateTmp=true
BindPaths=/tmp/.crysvc.sock:/tmp/.crysvc.sock
EOF
ok "bj-crypto drop-ins: validata-paths.conf, usb-access.conf, share-crysvc.conf"
# --------------------------------------------------------------------- #
# 9. bj-server drop-in
# --------------------------------------------------------------------- #
log "Кладу drop-in для bj-server..."
install -d /etc/systemd/system/bj-server.service.d
cat >/etc/systemd/system/bj-server.service.d/pki1conf.conf <<'EOF'
[Service]
# bj-server при импорте профиля дописывает секцию в pki1.conf.
# ProtectSystem=strict закрывает /opt — открываем точечно.
ReadWritePaths=/opt/Validata/VDCSP/etc
EOF
ok "bj-server drop-in: pki1conf.conf"
# --------------------------------------------------------------------- #
# 10. spki.ini — Валидата требует, при отсутствии mkstores падает
# --------------------------------------------------------------------- #
SPKI=/opt/Validata/VDCSP/etc/spki.ini
if [ ! -f "$SPKI" ]; then
log "Создаю $SPKI (Валидата без него падает в mkstores/zpki1utl)..."
cat >"$SPKI" <<'EOF'
[store]
count = 0
[Parameters]
PkiLdapTimeout = 10
PkiHttpTimeout = 60
EOF
chmod 644 "$SPKI"
ok "spki.ini создан"
else
ok "spki.ini уже есть"
fi
# --------------------------------------------------------------------- #
# 11. pki1.conf — делаем доступным для записи группе bj
# --------------------------------------------------------------------- #
PKI1=/opt/Validata/VDCSP/etc/pki1.conf
if [ -f "$PKI1" ]; then
chgrp bj "$PKI1"
chmod g+w "$PKI1"
ok "pki1.conf: group=bj, g+w"
if ! grep -q "^# --- bj-server: BEGIN ---" "$PKI1"; then
printf '\n# --- bj-server: BEGIN ---\n# Секции профилей дописываются автоматически при импорте через /admin/setup.\n# --- bj-server: END ---\n' >> "$PKI1"
ok "В pki1.conf добавлены маркеры bj-server"
fi
fi
# --------------------------------------------------------------------- #
# 12. udev-rule для авто-mount USB с .vdk
# --------------------------------------------------------------------- #
log "Ставлю udev-rule для авто-mount USB → /var/lib/bj/usb/..."
cat >/etc/udev/rules.d/99-bj-usb.rules <<'EOF'
# Авто-mount USB-флешек в /var/lib/bj/usb/<label> с владельцем bj.
# Применяется только к USB-устройствам (SUBSYSTEMS=="usb") с файловой
# системой. Mountpoint выбирается по метке тома или UUID.
ACTION=="add", KERNEL=="sd?[0-9]", SUBSYSTEMS=="usb", \
ENV{ID_FS_TYPE}!="", \
ENV{SYSTEMD_WANTS}="bj-usb-mount@$env{ID_FS_UUID}.service", \
ENV{SYSTEMD_USER_WANTS}=""
ACTION=="remove", KERNEL=="sd?[0-9]", SUBSYSTEMS=="usb", \
ENV{ID_FS_TYPE}!="", \
ENV{SYSTEMD_WANTS}="bj-usb-umount@$env{ID_FS_UUID}.service"
EOF
# Систэмный template-сервис, который монтирует и umonтирует
cat >/etc/systemd/system/bj-usb-mount@.service <<'EOF'
[Unit]
Description=Mount USB %i to /var/lib/bj/usb/%i for bj
DefaultDependencies=no
After=local-fs.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/bin/bash -c 'mkdir -p /var/lib/bj/usb/%i && /usr/bin/mount -o uid=$(id -u bj),gid=$(id -g bj),fmask=0133,dmask=0022 UUID=%i /var/lib/bj/usb/%i'
ExecStop=/usr/bin/umount /var/lib/bj/usb/%i || true
EOF
cat >/etc/systemd/system/bj-usb-umount@.service <<'EOF'
[Unit]
Description=Umount USB %i from /var/lib/bj/usb/%i
DefaultDependencies=no
[Service]
Type=oneshot
ExecStart=/usr/bin/bash -c '/usr/bin/umount /var/lib/bj/usb/%i 2>/dev/null; /usr/bin/rmdir /var/lib/bj/usb/%i 2>/dev/null; true'
EOF
udevadm control --reload-rules
udevadm trigger
ok "udev-rule + systemd-mount шаблон установлены"
# --------------------------------------------------------------------- #
# 13. Установка bj-crypto.service unit (если его ещё нет — берём из репы)
# --------------------------------------------------------------------- #
SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_UNIT="$SELF_DIR/../systemd/bj-crypto.service"
if [ ! -f /etc/systemd/system/bj-crypto.service ] && [ -f "$REPO_UNIT" ]; then
log "Устанавливаю /etc/systemd/system/bj-crypto.service из репы..."
install -m 0644 "$REPO_UNIT" /etc/systemd/system/bj-crypto.service
ok "bj-crypto.service установлен"
fi
# --------------------------------------------------------------------- #
# 14. daemon-reload + старт сервисов
# --------------------------------------------------------------------- #
log "systemctl daemon-reload..."
systemctl daemon-reload
log "Отключаю pcscd.socket (его подменяет наш drop-in always-on)..."
systemctl disable pcscd.socket 2>/dev/null || true
systemctl stop pcscd.socket 2>/dev/null || true
log "Запускаю pcscd..."
systemctl enable pcscd
systemctl restart pcscd
if [ -f /etc/systemd/system/bj-crypto.service ]; then
log "Запускаю bj-crypto..."
systemctl enable bj-crypto
systemctl restart bj-crypto
fi
# --------------------------------------------------------------------- #
# 15. Финальная проверка
# --------------------------------------------------------------------- #
echo
echo "================================================================"
echo " Валидата установлена, окружение настроено"
echo "================================================================"
for svc in pcscd vdcrysvc bj-crypto; do
if systemctl is-active --quiet "$svc" 2>/dev/null; then
echo "$svc — active"
else
echo "$svc — НЕ active (проверьте journalctl -u $svc)"
fi
done
echo
echo " Дальнейшие шаги:"
echo " 1. Подключите USB с .vdk → авто-маунт в /var/lib/bj/usb/<UUID>/"
echo " 2. Откройте /admin/setup в bj-server"
echo " 3. Загрузите .7z с профилем → bj-server сам всё извлечёт и импортирует"
echo " 4. Нажмите «Активировать профиль»"
echo "================================================================"
@@ -1,59 +0,0 @@
<?xml version="1.0" encoding="windows-1251"?>
<!--
АНКЕТА УЧАСТНИКА СЕРВИСА M2M (M2MTransferParticipantForm)
Назначение: регистрация нашего депозитарного кода в справочнике участников
M2M на стороне НРД. Без этой регистрации сервис МОСТ отклоняет запросы с
кодом M2M14 «Код ЭДО НРД отправителя отсутствует в справочнике участников M2M».
ВНИМАНИЕ:
1. Перед отправкой замените все значения ЗАПОЛНИТЬ_* на реальные реквизиты
организации. Это юридические данные — заполняет уполномоченное лицо.
2. Файл должен быть в кодировке windows-1251 (как объявлено в прологе).
Наш редактор хранит его в UTF-8 для удобства — перекодируйте перед
отправкой: iconv -f utf8 -t cp1251 form.xml > form.win1251.xml
3. Известное значение уже подставлено: депозитарный код MC0413600000
(выдан НРД для тестового контура TEST3, период 21.05.202601.09.2026).
4. Схема: DOC/M2MSchemas_260408/M2MTransferParticipantForm.xsd
-->
<pf:M2MTransferParticipantForm
xmlns:m2m="http://nsd.ru/schemas/m2m/types"
xmlns:pf="http://nsd.ru/schemas/m2m/participant/form"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://nsd.ru/schemas/m2m/participant/form M2MTransferParticipantForm.xsd">
<!-- Дата/время формирования анкеты по МСК. T — латиница, МСК — кириллица. -->
<pf:CreationTimestamp>2026-06-17T12:00:00(МСК)</pf:CreationTimestamp>
<pf:Participant>
<!-- ИНН организации (10 цифр для ЮЛ). -->
<m2m:INN>ЗАПОЛНИТЬ_ИНН</m2m:INN>
<m2m:Names>
<m2m:Rus>
<!-- Полное наименование по уставу. -->
<m2m:FullName>ЗАПОЛНИТЬ_ПОЛНОЕ_НАИМЕНОВАНИЕ</m2m:FullName>
<!-- Сокращённое наименование (необязательно). -->
<m2m:ShortName>ЗАПОЛНИТЬ_СОКРАЩЁННОЕ</m2m:ShortName>
<!-- Отображаемое короткое имя (показывается контрагенту). -->
<m2m:DisplayName>ЗАПОЛНИТЬ_ОТОБРАЖАЕМОЕ</m2m:DisplayName>
</m2m:Rus>
<!-- Английский блок необязателен; заполните, если есть. -->
<m2m:Eng>
<m2m:FullName>ЗАПОЛНИТЬ_FULL_NAME_EN</m2m:FullName>
<m2m:DisplayName>ЗАПОЛНИТЬ_DISPLAY_EN</m2m:DisplayName>
</m2m:Eng>
</m2m:Names>
<!-- Депозитарное место: наш код участника в НРД. УЖЕ ЗАПОЛНЕНО. -->
<m2m:DepositoryPlace>
<m2m:ParticipantCode>MC0413600000</m2m:ParticipantCode>
</m2m:DepositoryPlace>
<!-- Брокерское место — только если выступаем брокером. Иначе удалить блок. -->
<!--
<m2m:BrokerPlace>
<m2m:ParticipantCode>ЗАПОЛНИТЬ_БРОКЕРСКИЙ_КОД</m2m:ParticipantCode>
</m2m:BrokerPlace>
-->
</pf:Participant>
</pf:M2MTransferParticipantForm>
-58
View File
@@ -1,58 +0,0 @@
# Регистрация в справочнике участников M2M (НРД)
Закрывает блокер заявок с кодом **M2M14** — «Код ЭДО НРД отправителя
отсутствует в справочнике участников M2M». Технически наш контур (ИШ, СКЗИ,
канал, REST) работает; не хватает только регистрации нашего депозитарного
кода в справочнике сервиса МОСТ на стороне НРД.
## Что известно
| Параметр | Значение |
|-------------------------|---------------------------------------|
| Депозитарный код | `MC0413600000` |
| Тестовый контур | TEST3 (ГОСТ-криптография) |
| Период тестирования | 21.05.2026 — 01.09.2026 |
| Счёт (account_id) | `HL171004001C` |
| Раздел (section_id) | `36MC0413600000F00` |
## Что нужно заполнить (юридические реквизиты организации)
В файле `M2MTransferParticipantForm.example.xml` замените:
- `ЗАПОЛНИТЬ_ИНН` — ИНН организации (10 цифр для ЮЛ);
- `ЗАПОЛНИТЬ_ПОЛНОЕ_НАИМЕНОВАНИЕ` — полное наименование по уставу;
- `ЗАПОЛНИТЬ_СОКРАЩЁННОЕ` / `ЗАПОЛНИТЬ_ОТОБРАЖАЕМОЕ` — сокращённое и
отображаемое имя;
- английский блок `<m2m:Eng>` — при наличии, иначе удалить;
- `<m2m:BrokerPlace>` — только если выступаем брокером, иначе оставить
закомментированным.
Депозитарный код `MC0413600000` уже подставлен.
## Подготовка файла к отправке
Файл хранится в UTF-8 для удобства, а НРД ждёт **windows-1251** (как объявлено
в прологе XML). Перекодируйте перед отправкой:
```bash
iconv -f utf8 -t cp1251 M2MTransferParticipantForm.example.xml \
> M2MTransferParticipantForm.win1251.xml
```
(Опционально) проверьте по схеме, если установлен xmllint:
```bash
xmllint --noout \
--schema ../../DOC/M2MSchemas_260408/M2MTransferParticipantForm.xsd \
M2MTransferParticipantForm.win1251.xml
```
## Куда отправлять
Письмо на **M2MOST@nsd.ru** (служба сервиса МОСТ M2M), приложив заполненную
анкету. Текст письма — в `email-draft.txt`.
После того как НРД внесёт код `MC0413600000` в справочник участников M2M,
тестовый робот начнёт отвечать `M2MTransferDecision` вместо `M2MTransferResponse`
с ошибкой M2M14 — и заявки в bj-server будут доходить до статуса
«Подтверждена/Отклонена» по решению контрагента, а не «Отклонена (M2M14)».
-29
View File
@@ -1,29 +0,0 @@
Кому: M2MOST@nsd.ru
Тема: Регистрация участника сервиса M2M (тестовый контур TEST3, код MC0413600000)
Добрый день!
Просим зарегистрировать нашу организацию в справочнике участников сервиса
M2M (МОСТ) для тестового контура TEST3.
При отправке тестовых запросов M2MTransferRequest роботом возвращается
M2MTransferResponse со статусом ERROR и кодом M2M14 «Код ЭДО НРД отправителя
отсутствует в справочнике участников M2M». Технически интеграция настроена
(Интеграционный шлюз, ГОСТ-криптография, REST-обмен работают), требуется
только внесение нашего депозитарного кода в справочник участников M2M.
Реквизиты для регистрации:
- Депозитарный код участника: MC0413600000
- Тестовый контур: TEST3 (ГОСТ-криптография)
- Период тестирования: 21.05.2026 — 01.09.2026
- Полное наименование организации: ЗАПОЛНИТЬ
- ИНН: ЗАПОЛНИТЬ
Заполненная анкета участника (M2MTransferParticipantForm) во вложении.
После регистрации просим подтвердить — повторим тестовый сценарий с роботом.
С уважением,
ЗАПОЛНИТЬ_ФИО, ЗАПОЛНИТЬ_ДОЛЖНОСТЬ
ЗАПОЛНИТЬ_ОРГАНИЗАЦИЯ
ЗАПОЛНИТЬ_КОНТАКТ (телефон / e-mail)
@@ -1,119 +0,0 @@
<?xml version="1.0" encoding="windows-1251"?>
<M2MTransferRequest xmlns="http://nsd.ru/schemas/m2m/request">
<Header xmlns="http://nsd.ru/schemas/m2m/request">
<GUID xmlns="http://nsd.ru/schemas/m2m/types">b26440be-8a1e-4403-a35e-bc9df0da4a33</GUID>
<CreationTimestamp xmlns="http://nsd.ru/schemas/m2m/types">2026-06-18T13:13:50(МСК)</CreationTimestamp>
<SenderCode xmlns="http://nsd.ru/schemas/m2m/types">MC0413600000</SenderCode>
<ReceiverCode xmlns="http://nsd.ru/schemas/m2m/types">MC0012500000</ReceiverCode>
<CostInfo xmlns="http://nsd.ru/schemas/m2m/types">
<Yes xmlns="http://nsd.ru/schemas/m2m/types">
<Code xmlns="http://nsd.ru/schemas/m2m/types">MC0413600000</Code>
</Yes>
</CostInfo>
</Header>
<Data xmlns="http://nsd.ru/schemas/m2m/request">
<IsM2M xmlns="http://nsd.ru/schemas/m2m/types">true</IsM2M>
<InvestorInformation xmlns="http://nsd.ru/schemas/m2m/types">
<LastName xmlns="http://nsd.ru/schemas/m2m/types">Петров</LastName>
<FirstName xmlns="http://nsd.ru/schemas/m2m/types">Пётр</FirstName>
<MiddleName xmlns="http://nsd.ru/schemas/m2m/types">Петрович</MiddleName>
<IdentityDocument xmlns="http://nsd.ru/schemas/m2m/types">
<DocumentType xmlns="http://nsd.ru/schemas/m2m/types">21</DocumentType>
<DocumentSeries xmlns="http://nsd.ru/schemas/m2m/types">2001</DocumentSeries>
<DocumentNumber xmlns="http://nsd.ru/schemas/m2m/types">111111</DocumentNumber>
</IdentityDocument>
</InvestorInformation>
<TransferringDepository xmlns="http://nsd.ru/schemas/m2m/types">
<INN xmlns="http://nsd.ru/schemas/m2m/types">7702165310</INN>
</TransferringDepository>
<ReceivingDepository xmlns="http://nsd.ru/schemas/m2m/types">
<INN xmlns="http://nsd.ru/schemas/m2m/types">7722061076</INN>
</ReceivingDepository>
<TransferredSecurities xmlns="http://nsd.ru/schemas/m2m/types">
<Security xmlns="http://nsd.ru/schemas/m2m/types">
<ReferenceId xmlns="http://nsd.ru/schemas/m2m/types">M2M20260618RGVNK</ReferenceId>
<SecurityCode xmlns="http://nsd.ru/schemas/m2m/types">RU0007661625</SecurityCode>
<SecurityDetails xmlns="http://nsd.ru/schemas/m2m/types">
<ISIN xmlns="http://nsd.ru/schemas/m2m/types">RU0007661625</ISIN>
</SecurityDetails>
<Quantity xmlns="http://nsd.ru/schemas/m2m/types">
<Whole xmlns="http://nsd.ru/schemas/m2m/types">1</Whole>
</Quantity>
<SettlementAccount xmlns="http://nsd.ru/schemas/m2m/types">
<SettlementRequisites xmlns="http://nsd.ru/schemas/m2m/types">
<INN xmlns="http://nsd.ru/schemas/m2m/types">7702070139</INN>
</SettlementRequisites>
<SettlementLocation xmlns="http://nsd.ru/schemas/m2m/types">
<DeponentCode xmlns="http://nsd.ru/schemas/m2m/types">DP100200</DeponentCode>
<AccountId xmlns="http://nsd.ru/schemas/m2m/types">31MC0010000000A01</AccountId>
<SectionId xmlns="http://nsd.ru/schemas/m2m/types">A001</SectionId>
</SettlementLocation>
</SettlementAccount>
<IsolationStatus xmlns="http://nsd.ru/schemas/m2m/types">SGDN</IsolationStatus>
</Security>
<Security xmlns="http://nsd.ru/schemas/m2m/types">
<ReferenceId xmlns="http://nsd.ru/schemas/m2m/types">M2M20260618G5DW6</ReferenceId>
<SecurityCode xmlns="http://nsd.ru/schemas/m2m/types">RU000A0JP5V6</SecurityCode>
<SecurityDetails xmlns="http://nsd.ru/schemas/m2m/types">
<ISIN xmlns="http://nsd.ru/schemas/m2m/types">RU000A0JP5V6</ISIN>
</SecurityDetails>
<Quantity xmlns="http://nsd.ru/schemas/m2m/types">
<Whole xmlns="http://nsd.ru/schemas/m2m/types">1</Whole>
</Quantity>
<SettlementAccount xmlns="http://nsd.ru/schemas/m2m/types">
<SettlementRequisites xmlns="http://nsd.ru/schemas/m2m/types">
<INN xmlns="http://nsd.ru/schemas/m2m/types">7702070139</INN>
</SettlementRequisites>
<SettlementLocation xmlns="http://nsd.ru/schemas/m2m/types">
<DeponentCode xmlns="http://nsd.ru/schemas/m2m/types">DP100200</DeponentCode>
<AccountId xmlns="http://nsd.ru/schemas/m2m/types">31MC0010000000A01</AccountId>
<SectionId xmlns="http://nsd.ru/schemas/m2m/types">A001</SectionId>
</SettlementLocation>
</SettlementAccount>
<IsolationStatus xmlns="http://nsd.ru/schemas/m2m/types">SGDN</IsolationStatus>
</Security>
<Security xmlns="http://nsd.ru/schemas/m2m/types">
<ReferenceId xmlns="http://nsd.ru/schemas/m2m/types">M2M20260618CTDHY</ReferenceId>
<SecurityCode xmlns="http://nsd.ru/schemas/m2m/types">RU000A0JPKH7</SecurityCode>
<SecurityDetails xmlns="http://nsd.ru/schemas/m2m/types">
<ISIN xmlns="http://nsd.ru/schemas/m2m/types">RU000A0JPKH7</ISIN>
</SecurityDetails>
<Quantity xmlns="http://nsd.ru/schemas/m2m/types">
<Whole xmlns="http://nsd.ru/schemas/m2m/types">1</Whole>
</Quantity>
<SettlementAccount xmlns="http://nsd.ru/schemas/m2m/types">
<SettlementRequisites xmlns="http://nsd.ru/schemas/m2m/types">
<INN xmlns="http://nsd.ru/schemas/m2m/types">7702070139</INN>
</SettlementRequisites>
<SettlementLocation xmlns="http://nsd.ru/schemas/m2m/types">
<DeponentCode xmlns="http://nsd.ru/schemas/m2m/types">DP100200</DeponentCode>
<AccountId xmlns="http://nsd.ru/schemas/m2m/types">31MC0010000000A01</AccountId>
<SectionId xmlns="http://nsd.ru/schemas/m2m/types">A001</SectionId>
</SettlementLocation>
</SettlementAccount>
<IsolationStatus xmlns="http://nsd.ru/schemas/m2m/types">SGDN</IsolationStatus>
</Security>
<Security xmlns="http://nsd.ru/schemas/m2m/types">
<ReferenceId xmlns="http://nsd.ru/schemas/m2m/types">M2M20260618HQZ1Q</ReferenceId>
<SecurityCode xmlns="http://nsd.ru/schemas/m2m/types">RU000A0JPGP8</SecurityCode>
<SecurityDetails xmlns="http://nsd.ru/schemas/m2m/types">
<ISIN xmlns="http://nsd.ru/schemas/m2m/types">RU000A0JPGP8</ISIN>
</SecurityDetails>
<Quantity xmlns="http://nsd.ru/schemas/m2m/types">
<Whole xmlns="http://nsd.ru/schemas/m2m/types">1</Whole>
</Quantity>
<SettlementAccount xmlns="http://nsd.ru/schemas/m2m/types">
<SettlementRequisites xmlns="http://nsd.ru/schemas/m2m/types">
<INN xmlns="http://nsd.ru/schemas/m2m/types">7702070139</INN>
</SettlementRequisites>
<SettlementLocation xmlns="http://nsd.ru/schemas/m2m/types">
<DeponentCode xmlns="http://nsd.ru/schemas/m2m/types">DP100200</DeponentCode>
<AccountId xmlns="http://nsd.ru/schemas/m2m/types">31MC0010000000A01</AccountId>
<SectionId xmlns="http://nsd.ru/schemas/m2m/types">A001</SectionId>
</SettlementLocation>
</SettlementAccount>
<IsolationStatus xmlns="http://nsd.ru/schemas/m2m/types">SGDN</IsolationStatus>
</Security>
</TransferredSecurities>
</Data>
</M2MTransferRequest>
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="windows-1251" standalone="yes"?>
<M2MTransferResponse xmlns:ns2="http://nsd.ru/schemas/m2m/types" xmlns:ns3="http://nsd.ru/schemas/m2m/response">
<ns3:GUID>00000000-0000-0000-0000-000000000000</ns3:GUID>
<ns3:StatusCode>ERROR</ns3:StatusCode>
<ns3:Response>
<ns2:Code>M2M14</ns2:Code>
<ns2:Text>Код ЭДО НРД отправителя сообщения отсутствует в справочнике участников M2M</ns2:Text>
</ns3:Response>
</M2MTransferResponse>
@@ -1,55 +0,0 @@
ПАКЕТ ДЛЯ ТЕХПОДДЕРЖКИ НРД (сервис MOEX МОСТ / M2M)
====================================================
Цель: показать НРД, ЧТО мы отправляем роботу и КАКОЙ ответ получаем, чтобы
поддержка однозначно поняла суть обращения и подтвердила/выполнила регистрацию
нашего кода в справочнике участников M2M тестового контура.
НАШИ РЕКВИЗИТЫ
-------------
Депозитарный код (= Код ЭДО НРД отправителя): MC0413600000
Тестовый контур: TEST3 (ГОСТ-криптография)
Канал ИШ: TEST3MC0413600000
Период тестирования: 21.05.2026 — 01.09.2026
ЧТО МЫ ОТПРАВЛЯЕМ → файл 01_outgoing_M2MTransferRequest.xml
--------------------------------------------------------------
Тип документа: M2MTransferRequest (#M2MTR)
GUID запроса: b26440be-8a1e-4403-a35e-bc9df0da4a33
CreationTimestamp: 2026-06-18T13:13:50(МСК)
SenderCode: MC0413600000 (наш код ЭДО НРД)
ReceiverCode: MC0012500000 (тестовый робот НРД)
Сценарий робота: 2001 (DocumentSeries), «принять все бумаги»
Бумаг в пакете: 4
Пакет подписан Интеграционным шлюзом (ИШ) сертификатом УЦ МБ.
ЧТО ОТВЕЧАЕТ НРД → файл 02_incoming_M2MTransferResponse.xml
--------------------------------------------------------------
Тип документа: M2MTransferResponse (#M2MER), подпись НРД — VALID
GUID: 00000000-0000-0000-0000-000000000000 (нулевой)
StatusCode: ERROR
Код: M2M14
Текст: «Код ЭДО НРД отправителя сообщения отсутствует в
справочнике участников M2M»
СУТЬ ОБРАЩЕНИЯ И ВОПРОС К НРД
----------------------------
Технически обмен работает: запрос доходит до сервиса МОСТ, НРД его принимает,
проверяет нашу подпись и отвечает. Отказ — на уровне справочника участников:
наш код ЭДО MC0413600000 в справочнике участников M2M (тестовый контур) пока
отсутствует.
Просим:
1) подтвердить, что отправителем M2M для нашего контура должен выступать
именно депозитарный код MC0413600000 (либо сообщить корректный код);
2) зарегистрировать наш код в справочнике участников M2M тестового контура,
чтобы робот (MC0012500000) распознавал отправителя и возвращал
M2MTransferDecision вместо ошибки M2M14.
Для поиска нашего запроса на стороне НРД: GUID b26440be-8a1e-4403-a35e-bc9df0da4a33
(ответ M2M14 приходит с нулевым GUID, поэтому указываем GUID исходного запроса).
ВЛОЖЕНИЯ
--------
01_outgoing_M2MTransferRequest.xml — наш запрос (то, что мы отправляем)
02_incoming_M2MTransferResponse.xml — ответ НРД (M2M14)
@@ -1,44 +0,0 @@
Кому: M2MOST@nsd.ru
Копия: soed@nsd.ru
Тема: M2M Автотестирование (МОСТ): ошибка M2M14 — регистрация кода MC0413600000 в справочнике участников
Добрый день!
Проводим автотестирование сервиса MOEX МОСТ (перевод M2M) с роботом в тестовом
контуре TEST3. Технически обмен настроен и работает: запрос доходит до сервиса,
НРД проверяет подпись и отвечает. Однако робот возвращает отказ:
StatusCode: ERROR
Код: M2M14
Текст: «Код ЭДО НРД отправителя сообщения отсутствует в справочнике
участников M2M»
Наши реквизиты:
- Депозитарный код (Код ЭДО НРД отправителя): MC0413600000
- Тестовый контур: TEST3, ГОСТ-криптография
- Период тестирования: 21.05.2026 — 01.09.2026
- GUID нашего запроса для поиска на вашей стороне:
b26440be-8a1e-4403-a35e-bc9df0da4a33
(ответ M2M14 приходит с нулевым GUID, поэтому указываем GUID запроса)
Во вложении — фактический обмен, чтобы предметно видеть, что мы отправляем и
что получаем в ответ:
1) 01_outgoing_M2MTransferRequest.xml — наш запрос роботу (MC0012500000),
SenderCode=MC0413600000, сценарий 2001;
2) 02_incoming_M2MTransferResponse.xml — ваш ответ с кодом M2M14.
Просим:
1) подтвердить, что отправителем M2M для нашего контура должен выступать
именно депозитарный код MC0413600000 (либо сообщить корректный код);
2) зарегистрировать наш код в справочнике участников M2M тестового контура,
чтобы робот распознавал отправителя и возвращал M2MTransferDecision
вместо ошибки M2M14.
Если для этого требуется уточнить/дополнить онлайн-заявку на участие в
тестировании систем НРД (система МОСТ, тип «M2M Автотестирование») — подскажите,
пожалуйста, что именно поправить.
С уважением,
ЗАПОЛНИТЬ_ФИО, ЗАПОЛНИТЬ_ДОЛЖНОСТЬ
ЗАПОЛНИТЬ_ОРГАНИЗАЦИЯ
ЗАПОЛНИТЬ_КОНТАКТ (телефон / e-mail)
-35
View File
@@ -1,35 +0,0 @@
[Unit]
Description=Bridge-and-Join-s — Crypto sidecar (Java + Валидата Клиент L)
Documentation=https://git.zetit.ru/zuevav/Bridge-and-Join-s
Before=bj-server.service
After=network-online.target pcscd.service
Wants=network-online.target
[Service]
Type=simple
User=bj
Group=bj
RuntimeDirectory=bj
RuntimeDirectoryMode=0750
Environment=BJ_CRYPTO_SOCKET=/run/bj/crypto.sock
Environment=BJ_CRYPTO_PROVIDER=validata
Environment=LD_LIBRARY_PATH=/opt/Validata/VDCSP/lib/amd64
ExecStart=/usr/bin/java \
-Djava.library.path=/opt/Validata/VDCSP/lib/amd64 \
-jar /opt/bj/crypto-service.jar
Restart=on-failure
RestartSec=5
StandardOutput=append:/var/log/bj/crypto-service.log
StandardError=append:/var/log/bj/crypto-service.err
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/run/bj /var/log/bj
PrivateTmp=true
[Install]
WantedBy=multi-user.target
+5 -9
View File
@@ -1,21 +1,17 @@
module git.zetit.ru/zuevav/Bridge-and-Join-s
go 1.25.0
go 1.24.0
require (
github.com/jackc/pgx/v5 v5.7.4
golang.org/x/text v0.34.0
google.golang.org/grpc v1.81.1
google.golang.org/protobuf v1.36.11
github.com/miekg/pkcs11 v1.1.2
golang.org/x/text v0.22.0
)
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
golang.org/x/crypto v0.35.0 // indirect
golang.org/x/sync v0.17.0 // indirect
)
+8 -42
View File
@@ -1,18 +1,6 @@
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -21,6 +9,8 @@ github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg=
github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/miekg/pkcs11 v1.1.2 h1:/VxmeAX5qU6Q3EwafypogwWbYryHFmF2RpkJmw3m4MQ=
github.com/miekg/pkcs11 v1.1.2/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -28,36 +18,12 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+277 -223
View File
@@ -1,297 +1,351 @@
// Package cryptocli — gRPC-клиент к crypto-service по Unix Domain
// Socket. Сам Go-процесс не выполняет криптографию — всё делает
// Java-сайдкар (services/crypto-service) поверх АПК «Валидата
// Клиент L».
// Package cryptocli — Go-клиент к СКЗИ через PKCS#11 (КриптоПро CSP,
// Рутокен ЭЦП 2.0, ViPNet, Валидата). Загружает указанный .so модуль,
// открывает сессию, перечисляет токены, читает сертификаты и
// предоставляет операции Sign/Verify.
//
// На дев-стендах без поднятого сайдкара (стандартный путь
// /run/bj/crypto.sock не существует) клиент возвращает понятную
// ошибку «провайдер недоступен» и lk-gateway работает в stub-режиме:
// XMLDSig-подписи проходят без проверки (только для демо).
// На ВМ без установленного СКЗИ модуль не загрузится — клиент
// возвращает понятную ошибку и помечает себя как «провайдер
// недоступен». В этом случае lk-gateway переходит в режим stub:
// XMLDSig-подписи проходят без реальной проверки (только для
// дев-стендов и демо).
package cryptocli
import (
"context"
"crypto/sha256"
"crypto/x509"
"encoding/asn1"
"encoding/hex"
"errors"
"fmt"
"os"
"sync"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"github.com/miekg/pkcs11"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/cryptocli/cryptopb"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
)
// Provider — тип СКЗИ-провайдера (информативный — реальный выбор
// делает crypto-service через переменную BJ_CRYPTO_PROVIDER).
// Provider — тип СКЗИ-провайдера.
type Provider string
// Известные провайдеры.
const (
ProviderStub Provider = "stub"
ProviderValidata Provider = "validata"
ProviderStub Provider = "stub"
ProviderCryptoPro Provider = "cryptopro"
ProviderRutoken Provider = "rutoken"
ProviderValidata Provider = "validata"
ProviderVipNet Provider = "vipnet"
)
// DefaultModulePath сохранена для обратной совместимости с UI;
// текущий путь интеграции — не PKCS#11-модуль, а UDS-сокет
// crypto-service. Возвращаемое значение информативное.
// DefaultModulePath возвращает дефолтный путь до PKCS#11 .so модуля
// для указанного провайдера. Используется в /admin/setup как placeholder.
func DefaultModulePath(p Provider) string {
if p == ProviderValidata {
return "/opt/Validata/VDCSP/lib/amd64 (через сайдкар, не PKCS#11)"
switch p {
case ProviderCryptoPro:
return "/opt/cprocsp/lib/amd64/libcppkcs11.so"
case ProviderRutoken:
return "/usr/lib64/librtpkcs11ecp.so"
case ProviderValidata:
return "/opt/validata/lib/libvalidata-pkcs11.so"
case ProviderVipNet:
return "/opt/itcs/lib/libvipnet-pkcs11.so"
}
return ""
}
// Config — конфигурация клиента.
type Config struct {
// SocketPath — путь к UDS-сокету crypto-service.
// Пустое значение = /run/bj/crypto.sock.
SocketPath string
// Provider — желаемый провайдер; информативно (см. выше).
Provider Provider
// ModulePath — сохраняется для UI; в gRPC-режиме не используется.
ModulePath string
// Timeout — таймаут одной gRPC-операции.
Timeout time.Duration
Provider Provider
ModulePath string // путь до PKCS#11 .so модуля (libcppkcs11.so и т.п.)
PIN string // PIN для сессии (логин на токен)
SlotID uint // 0 = первый доступный
Timeout time.Duration
}
// Client — gRPC-клиент к crypto-service.
// Client — PKCS#11-клиент к СКЗИ.
type Client struct {
cfg Config
mu sync.Mutex
conn *grpc.ClientConn
api cryptopb.CryptoServiceClient
cfg Config
mu sync.Mutex
ctx *pkcs11.Ctx
opened bool
}
// New создаёт клиент. Само соединение поднимается лениво при первом
// вызове.
// New создаёт клиент. Сам Initialize() здесь не вызывается — это
// делает Connect или явный Ping (Health-check на admin-странице).
func New(cfg Config) *Client {
if cfg.Timeout == 0 {
cfg.Timeout = 5 * time.Second
}
if cfg.SocketPath == "" {
cfg.SocketPath = "/run/bj/crypto.sock"
}
return &Client{cfg: cfg}
}
// Close закрывает gRPC-соединение.
func (c *Client) Close() error {
// Health — лёгкая проверка готовности. Шаги:
// 1. Сам файл .so существует?
// 2. Initialize модуля?
// 3. Есть ли хотя бы один доступный слот с токеном?
// 4. Информация о токене (label, manufacturer, serial).
func (c *Client) Health(_ context.Context) (HealthInfo, error) {
if c.cfg.Provider == "" || c.cfg.Provider == ProviderStub {
return HealthInfo{Provider: string(ProviderStub),
Message: "Провайдер stub — реальная криптография не подключена."}, nil
}
if c.cfg.ModulePath == "" {
return HealthInfo{}, errors.New("cryptocli: ModulePath не задан")
}
if _, err := os.Stat(c.cfg.ModulePath); err != nil {
return HealthInfo{}, fmt.Errorf("cryptocli: модуль %s не найден: %w", c.cfg.ModulePath, err)
}
c.mu.Lock()
defer c.mu.Unlock()
if c.conn != nil {
err := c.conn.Close()
c.conn = nil
c.api = nil
return err
}
return nil
}
// ensureConn устанавливает gRPC-канал к UDS-сокету при первом
// использовании. Используем встроенный в grpc-go резолвер unix:.
func (c *Client) ensureConn() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.api != nil {
return nil
}
target := "unix:" + c.cfg.SocketPath
conn, err := grpc.NewClient(target,
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
return fmt.Errorf("cryptocli: dial %s: %w", c.cfg.SocketPath, err)
}
c.conn = conn
c.api = cryptopb.NewCryptoServiceClient(conn)
return nil
}
// Health — gRPC Health-вызов. Если сокет недоступен (сайдкар не
// поднят) — вернёт «провайдер недоступен» с явной ошибкой.
func (c *Client) Health(ctx context.Context) (HealthInfo, error) {
if c.cfg.Provider == ProviderStub {
return HealthInfo{
Provider: string(ProviderStub),
Message: "Провайдер stub — реальная криптография не подключена.",
}, nil
}
if err := c.ensureConn(); err != nil {
if err := c.ensureInitLocked(); err != nil {
return HealthInfo{}, err
}
cctx, cancel := context.WithTimeout(ctx, c.cfg.Timeout)
defer cancel()
resp, err := c.api.Health(cctx, &cryptopb.HealthRequest{})
info, err := c.ctx.GetInfo()
if err != nil {
return HealthInfo{}, fmt.Errorf("cryptocli: Health: %w", err)
return HealthInfo{}, fmt.Errorf("cryptocli: GetInfo: %w", err)
}
return HealthInfo{
Provider: resp.GetProvider(),
Message: resp.GetVersion(),
ModulePath: c.cfg.SocketPath,
}, nil
slots, err := c.ctx.GetSlotList(true) // только токены
if err != nil {
return HealthInfo{}, fmt.Errorf("cryptocli: GetSlotList: %w", err)
}
h := HealthInfo{
Provider: string(c.cfg.Provider),
ModulePath: c.cfg.ModulePath,
CryptokiVersion: fmt.Sprintf("%d.%d", info.CryptokiVersion.Major, info.CryptokiVersion.Minor),
ManufacturerID: info.ManufacturerID,
LibraryVersion: fmt.Sprintf("%d.%d", info.LibraryVersion.Major, info.LibraryVersion.Minor),
}
for _, slot := range slots {
tok, err := c.ctx.GetTokenInfo(slot)
if err != nil {
h.Tokens = append(h.Tokens, TokenInfo{SlotID: slot, Error: err.Error()})
continue
}
h.Tokens = append(h.Tokens, TokenInfo{
SlotID: slot,
Label: tok.Label,
Manufacturer: tok.ManufacturerID,
Model: tok.Model,
SerialNumber: tok.SerialNumber,
})
}
if len(h.Tokens) == 0 {
h.Message = "Модуль PKCS#11 загружен, но активных токенов не найдено. Подключите Рутокен или установите ключевой контейнер."
} else {
h.Message = fmt.Sprintf("Доступно токенов: %d. Криптография готова к работе.", len(h.Tokens))
}
return h, nil
}
// Certificate — упрощённое описание сертификата (для совместимости с
// прежним UI). В gRPC-режиме crypto-service возвращает информацию о
// подписанте через VerifyResponse; полный список сертификатов
// (FindCertificates) пока не реализован — для UI возвращаем пустой
// список.
// Certificate — DER-сертификат с распарсенными атрибутами для UI.
type Certificate struct {
SlotID uint
TokenLabel string
Label string
Label string // CKA_LABEL (объект на токене)
SubjectCN string
IssuerCN string
Serial string
NotBefore time.Time
NotAfter time.Time
INN string
INN string // если есть в OID 1.2.643.3.131.1.1
DER []byte
HasPrivateKey bool
HasPrivateKey bool // найден ли парный приватный ключ на токене
}
// FindCertificates пока возвращает пустой список — список ключей
// управляется самой Валидатой через её собственный справочник (zcs),
// а bj-server о конкретных сертификатах узнаёт по результатам
// Verify/Sign-операций. Эту функцию переопределим позже отдельным
// gRPC-методом ListCertificates если потребуется.
// FindCertificates перечисляет сертификаты на всех подключенных
// токенах. Не требует Login для публичных сертификатов; для контейнеров
// CryptoPro/Rutoken достаточно открыть сессию (CKU_USER не выполняется).
func (c *Client) FindCertificates(_ context.Context) ([]Certificate, error) {
return nil, nil
}
// Shutdown — отправляет команду «выйти с exit-code 2» сайдкару.
// systemd с Restart=on-failure поднимет его обратно. Возвращает
// ошибку если соединение разорвалось (что нормально и означает что
// сайдкар уже завершается).
func (c *Client) Shutdown(ctx context.Context) error {
if c.cfg.Provider == ProviderStub {
return errors.New("provider=stub: некуда отправлять Shutdown")
if c.cfg.Provider == "" || c.cfg.Provider == ProviderStub {
return nil, errors.New("cryptocli: провайдер stub — нет реальных сертификатов")
}
if err := c.ensureConn(); err != nil {
return err
}
cctx, cancel := context.WithTimeout(ctx, c.cfg.Timeout)
defer cancel()
_, err := c.api.Shutdown(cctx, &cryptopb.ShutdownRequest{})
// Закрываем соединение, чтобы не держать ссылку на падающий процесс.
_ = c.Close()
return err
}
// ActivateResult — результат переключения профиля Валидаты.
type ActivateResult struct {
OK bool
Provider string
Profile string
Message string
}
// Activate переключает crypto-service на указанный профиль pki1.conf.
// Пустая строка = minimal mode (без профиля).
func (c *Client) Activate(ctx context.Context, profile string) (ActivateResult, error) {
if c.cfg.Provider == ProviderStub {
return ActivateResult{
OK: false,
Provider: string(ProviderStub),
Message: "Провайдер stub — переключение профиля недоступно.",
}, nil
}
if err := c.ensureConn(); err != nil {
return ActivateResult{}, err
}
cctx, cancel := context.WithTimeout(ctx, c.cfg.Timeout)
defer cancel()
resp, err := c.api.Activate(cctx, &cryptopb.ActivateRequest{Profile: profile})
if err != nil {
return ActivateResult{}, fmt.Errorf("cryptocli: Activate: %w", err)
}
return ActivateResult{
OK: resp.GetOk(),
Provider: resp.GetProvider(),
Profile: resp.GetProfile(),
Message: resp.GetMessage(),
}, nil
}
// VerifyXMLDSig — проксирует в crypto-service.VerifyXMLDSig.
// Реализует m2mcore.CryptoVerifier — поэтому возвращает CertInfo,
// заполненный из gRPC-ответа.
func (c *Client) VerifyXMLDSig(ctx context.Context, payload []byte) (m2mcore.CertInfo, error) {
if c.cfg.Provider == ProviderStub {
return m2mcore.CertInfo{
SignerCN: "stub-verifier",
}, nil
}
if err := c.ensureConn(); err != nil {
return m2mcore.CertInfo{}, err
}
cctx, cancel := context.WithTimeout(ctx, c.cfg.Timeout)
defer cancel()
resp, err := c.api.VerifyXMLDSig(cctx, &cryptopb.VerifyRequest{
Payload: payload,
})
if err != nil {
return m2mcore.CertInfo{}, fmt.Errorf("cryptocli: VerifyXMLDSig: %w", err)
}
if !resp.GetValid() {
var msg string
if errs := resp.GetErrors(); len(errs) > 0 {
msg = errs[0]
} else {
msg = "подпись недействительна"
}
return m2mcore.CertInfo{}, errors.New("cryptocli: " + msg)
}
return m2mcore.CertInfo{
SignerCN: resp.GetSignerCn(),
SignerINN: resp.GetSignerInn(),
Serial: resp.GetSerial(),
NotBefore: time.Unix(resp.GetNotBefore(), 0),
NotAfter: time.Unix(resp.GetNotAfter(), 0),
}, nil
}
// SignXMLDSig — проксирует в crypto-service.SignXMLDSig. Возвращает
// DER-байты CMS detached signature (готовы к включению в XMLDSig-обёртку
// или к самостоятельной отправке как .p7s).
//
// keyAlias — alias ключа из ПСП Валидаты (пустой = ключ по умолчанию
// активного профиля). profile — имя профиля в pki1.conf, пустой = тот
// что инициализирован.
func (c *Client) SignXMLDSig(ctx context.Context, payload []byte, keyAlias, profile string) ([]byte, error) {
if c.cfg.Provider == ProviderStub {
return nil, errors.New("provider=stub: подпись недоступна")
}
if err := c.ensureConn(); err != nil {
c.mu.Lock()
defer c.mu.Unlock()
if err := c.ensureInitLocked(); err != nil {
return nil, err
}
cctx, cancel := context.WithTimeout(ctx, c.cfg.Timeout)
defer cancel()
resp, err := c.api.SignXMLDSig(cctx, &cryptopb.SignRequest{
Payload: payload,
KeyAlias: keyAlias,
Profile: profile,
})
slots, err := c.ctx.GetSlotList(true)
if err != nil {
return nil, fmt.Errorf("cryptocli: SignXMLDSig: %w", err)
return nil, fmt.Errorf("cryptocli: GetSlotList: %w", err)
}
return resp.GetSignedXml(), nil
var out []Certificate
for _, slot := range slots {
tokInfo, _ := c.ctx.GetTokenInfo(slot)
certs, err := c.listSlotCertificates(slot, tokInfo.Label)
if err != nil {
// продолжаем — возможно один слот занят, другие доступны
continue
}
out = append(out, certs...)
}
return out, nil
}
// HealthInfo — что показывает /admin/setup → СКЗИ.
// listSlotCertificates открывает сессию на слоте, ищет CKO_CERTIFICATE,
// читает DER и парсит x509.
func (c *Client) listSlotCertificates(slot uint, tokenLabel string) ([]Certificate, error) {
sess, err := c.ctx.OpenSession(slot, pkcs11.CKF_SERIAL_SESSION)
if err != nil {
return nil, fmt.Errorf("OpenSession: %w", err)
}
defer func() { _ = c.ctx.CloseSession(sess) }()
template := []*pkcs11.Attribute{
pkcs11.NewAttribute(pkcs11.CKA_CLASS, pkcs11.CKO_CERTIFICATE),
}
if err := c.ctx.FindObjectsInit(sess, template); err != nil {
return nil, fmt.Errorf("FindObjectsInit: %w", err)
}
handles, _, err := c.ctx.FindObjects(sess, 32)
_ = c.ctx.FindObjectsFinal(sess)
if err != nil {
return nil, fmt.Errorf("FindObjects: %w", err)
}
out := make([]Certificate, 0, len(handles))
for _, h := range handles {
attrs, err := c.ctx.GetAttributeValue(sess, h, []*pkcs11.Attribute{
pkcs11.NewAttribute(pkcs11.CKA_VALUE, nil),
pkcs11.NewAttribute(pkcs11.CKA_LABEL, nil),
pkcs11.NewAttribute(pkcs11.CKA_ID, nil),
})
if err != nil {
continue
}
cert := Certificate{
SlotID: slot,
TokenLabel: tokenLabel,
}
var idAttr []byte
for _, a := range attrs {
switch a.Type {
case pkcs11.CKA_VALUE:
cert.DER = a.Value
case pkcs11.CKA_LABEL:
cert.Label = string(a.Value)
case pkcs11.CKA_ID:
idAttr = a.Value
}
}
// Парсим X.509 (ГОСТ-сертификаты тоже парсятся через crypto/x509
// — Subject/Issuer/Serial/Validity не зависят от алгоритма подписи).
parsed, err := x509.ParseCertificate(cert.DER)
if err == nil {
cert.SubjectCN = parsed.Subject.CommonName
cert.IssuerCN = parsed.Issuer.CommonName
cert.Serial = parsed.SerialNumber.Text(16)
cert.NotBefore = parsed.NotBefore
cert.NotAfter = parsed.NotAfter
// ИНН в OID 1.2.643.3.131.1.1 — извлекаем из Subject.
cert.INN = extractINN(parsed)
}
// Проверим есть ли парный приватный ключ.
if len(idAttr) > 0 {
cert.HasPrivateKey = c.hasPrivateKey(sess, idAttr)
}
out = append(out, cert)
}
return out, nil
}
// hasPrivateKey ищет CKO_PRIVATE_KEY с тем же CKA_ID что и сертификат.
func (c *Client) hasPrivateKey(sess pkcs11.SessionHandle, id []byte) bool {
tmpl := []*pkcs11.Attribute{
pkcs11.NewAttribute(pkcs11.CKA_CLASS, pkcs11.CKO_PRIVATE_KEY),
pkcs11.NewAttribute(pkcs11.CKA_ID, id),
}
if err := c.ctx.FindObjectsInit(sess, tmpl); err != nil {
return false
}
defer func() { _ = c.ctx.FindObjectsFinal(sess) }()
handles, _, err := c.ctx.FindObjects(sess, 1)
return err == nil && len(handles) > 0
}
// extractINN ищет ИНН в Subject сертификата по OID НРД 1.2.643.3.131.1.1.
func extractINN(c *x509.Certificate) string {
innOID := asn1.ObjectIdentifier{1, 2, 643, 3, 131, 1, 1}
for _, name := range c.Subject.Names {
if name.Type.Equal(innOID) {
if s, ok := name.Value.(string); ok {
return s
}
}
}
return ""
}
// VerifyXMLDSig — заглушка для интерфейса m2mcore.CryptoVerifier.
// Реальная проверка XMLDSig потребует канонизации XML и parsing
// сертификатов; пока возвращает CertInfo с подписанной полезной
// нагрузкой как хеш SHA-256 и заглушку CN. На M3-M4 заменим на
// полноценный verify через PKCS#11 + Apache Santuario-like канонизатор.
func (c *Client) VerifyXMLDSig(ctx context.Context, payload []byte) (m2mcore.CertInfo, error) {
if _, err := c.Health(ctx); err != nil {
return m2mcore.CertInfo{}, err
}
sum := sha256.Sum256(payload)
return m2mcore.CertInfo{
SignerCN: "stub-verifier",
SignerINN: "",
Serial: hex.EncodeToString(sum[:8]),
NotBefore: time.Now().Add(-365 * 24 * time.Hour),
NotAfter: time.Now().Add(365 * 24 * time.Hour),
}, nil
}
// Close завершает работу PKCS#11 модуля.
func (c *Client) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.ctx == nil {
return nil
}
_ = c.ctx.Finalize()
c.ctx.Destroy()
c.ctx = nil
c.opened = false
return nil
}
// ensureInitLocked инициализирует PKCS#11 модуль если ещё не.
// Должен вызываться под c.mu.Lock.
func (c *Client) ensureInitLocked() error {
if c.opened {
return nil
}
c.ctx = pkcs11.New(c.cfg.ModulePath)
if c.ctx == nil {
return fmt.Errorf("cryptocli: не получилось загрузить модуль %s", c.cfg.ModulePath)
}
if err := c.ctx.Initialize(); err != nil {
c.ctx.Destroy()
c.ctx = nil
return fmt.Errorf("cryptocli: Initialize: %w", err)
}
c.opened = true
return nil
}
// HealthInfo — что показывает /admin/setup и /admin/status.
type HealthInfo struct {
Provider string
ModulePath string // в gRPC-режиме — UDS-сокет
CryptokiVersion string // не используется
ManufacturerID string // не используется
LibraryVersion string // не используется
ModulePath string
CryptokiVersion string
ManufacturerID string
LibraryVersion string
Tokens []TokenInfo
Message string
}
// TokenInfo — для совместимости с UI; в gRPC-режиме пустой.
// TokenInfo — описание подключённого токена/контейнера.
type TokenInfo struct {
SlotID uint
Label string
+30 -20
View File
@@ -8,45 +8,55 @@ import (
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/cryptocli"
)
// TestStubProviderHealthOK — провайдер stub не лезет в gRPC,
// возвращает информативный Health без ошибки.
func TestStubProviderHealthOK(t *testing.T) {
cli := cryptocli.New(cryptocli.Config{Provider: cryptocli.ProviderStub})
defer cli.Close()
h, err := cli.Health(context.Background())
if err != nil {
t.Fatalf("Health: %v", err)
}
if h.Provider != string(cryptocli.ProviderStub) {
t.Errorf("Provider = %q, ожидался stub", h.Provider)
t.Errorf("Provider = %q", h.Provider)
}
if !strings.Contains(h.Message, "stub") {
t.Errorf("сообщение не содержит 'stub': %q", h.Message)
}
}
// TestValidataProviderNoSocket — провайдер validata пытается дойти до
// сайдкара, но в тестах сокета нет. gRPC-клиент создаётся лениво
// (NewClient не возвращает ошибку), а ошибка приходит при первом RPC.
func TestValidataProviderNoSocket(t *testing.T) {
func TestModulePathMissing(t *testing.T) {
cli := cryptocli.New(cryptocli.Config{
Provider: cryptocli.ProviderValidata,
SocketPath: "/nonexistent/crypto.sock",
Provider: cryptocli.ProviderCryptoPro,
ModulePath: "/nonexistent/libcppkcs11.so",
})
defer cli.Close()
_, err := cli.Health(context.Background())
if err == nil {
t.Fatal("ожидалась ошибка о недоступном сокете")
t.Fatal("ожидалась ошибка о ненайденном модуле")
}
if !strings.Contains(err.Error(), "не найден") {
t.Errorf("неинформативная ошибка: %v", err)
}
}
// TestDefaultModulePath — информативный текст для UI.
func TestDefaultModulePath(t *testing.T) {
if cryptocli.DefaultModulePath(cryptocli.ProviderStub) != "" {
t.Error("DefaultModulePath(stub) должен быть пустым")
}
v := cryptocli.DefaultModulePath(cryptocli.ProviderValidata)
if v == "" {
t.Error("DefaultModulePath(validata) не должен быть пустым")
func TestEmptyModulePath(t *testing.T) {
cli := cryptocli.New(cryptocli.Config{Provider: cryptocli.ProviderCryptoPro})
_, err := cli.Health(context.Background())
if err == nil {
t.Fatal("ожидалась ошибка о пустом ModulePath")
}
}
func TestDefaultModulePath(t *testing.T) {
cases := []struct {
p cryptocli.Provider
want string
}{
{cryptocli.ProviderCryptoPro, "/opt/cprocsp/lib/amd64/libcppkcs11.so"},
{cryptocli.ProviderRutoken, "/usr/lib64/librtpkcs11ecp.so"},
{cryptocli.ProviderStub, ""},
}
for _, c := range cases {
got := cryptocli.DefaultModulePath(c.p)
if got != c.want {
t.Errorf("DefaultModulePath(%s) = %q, ожидалось %q", c.p, got, c.want)
}
}
}
-694
View File
@@ -1,694 +0,0 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v3.12.4
// source: crypto.proto
package cryptopb
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type ActivateRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Имя профиля в pki1.conf. Пустая строка = minimal mode.
Profile string `protobuf:"bytes,1,opt,name=profile,proto3" json:"profile,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ActivateRequest) Reset() {
*x = ActivateRequest{}
mi := &file_crypto_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ActivateRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ActivateRequest) ProtoMessage() {}
func (x *ActivateRequest) ProtoReflect() protoreflect.Message {
mi := &file_crypto_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ActivateRequest.ProtoReflect.Descriptor instead.
func (*ActivateRequest) Descriptor() ([]byte, []int) {
return file_crypto_proto_rawDescGZIP(), []int{0}
}
func (x *ActivateRequest) GetProfile() string {
if x != nil {
return x.Profile
}
return ""
}
type ActivateResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
// true если провайдер успешно (пере)инициализирован.
Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"`
// Имя активного провайдера ("validata" / "stub").
Provider string `protobuf:"bytes,2,opt,name=provider,proto3" json:"provider,omitempty"`
// Имя активного профиля (пусто для minimal).
Profile string `protobuf:"bytes,3,opt,name=profile,proto3" json:"profile,omitempty"`
// Сообщение о результате (для UI).
Message string `protobuf:"bytes,4,opt,name=message,proto3" json:"message,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ActivateResponse) Reset() {
*x = ActivateResponse{}
mi := &file_crypto_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ActivateResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ActivateResponse) ProtoMessage() {}
func (x *ActivateResponse) ProtoReflect() protoreflect.Message {
mi := &file_crypto_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ActivateResponse.ProtoReflect.Descriptor instead.
func (*ActivateResponse) Descriptor() ([]byte, []int) {
return file_crypto_proto_rawDescGZIP(), []int{1}
}
func (x *ActivateResponse) GetOk() bool {
if x != nil {
return x.Ok
}
return false
}
func (x *ActivateResponse) GetProvider() string {
if x != nil {
return x.Provider
}
return ""
}
func (x *ActivateResponse) GetProfile() string {
if x != nil {
return x.Profile
}
return ""
}
func (x *ActivateResponse) GetMessage() string {
if x != nil {
return x.Message
}
return ""
}
type ShutdownRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ShutdownRequest) Reset() {
*x = ShutdownRequest{}
mi := &file_crypto_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ShutdownRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ShutdownRequest) ProtoMessage() {}
func (x *ShutdownRequest) ProtoReflect() protoreflect.Message {
mi := &file_crypto_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ShutdownRequest.ProtoReflect.Descriptor instead.
func (*ShutdownRequest) Descriptor() ([]byte, []int) {
return file_crypto_proto_rawDescGZIP(), []int{2}
}
type ShutdownResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
// true означает «запрос принят, процесс завершится через ~500ms».
Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ShutdownResponse) Reset() {
*x = ShutdownResponse{}
mi := &file_crypto_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ShutdownResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ShutdownResponse) ProtoMessage() {}
func (x *ShutdownResponse) ProtoReflect() protoreflect.Message {
mi := &file_crypto_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ShutdownResponse.ProtoReflect.Descriptor instead.
func (*ShutdownResponse) Descriptor() ([]byte, []int) {
return file_crypto_proto_rawDescGZIP(), []int{3}
}
func (x *ShutdownResponse) GetOk() bool {
if x != nil {
return x.Ok
}
return false
}
type VerifyRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Целиком подписанный XML.
Payload []byte `protobuf:"bytes,1,opt,name=payload,proto3" json:"payload,omitempty"`
// Профиль ключей и сертификатов: "guest-gost" | "test3-gost" |
// "prod-gost" | "guest-rsa" | ... — определяет хранилище и trust store.
Profile string `protobuf:"bytes,2,opt,name=profile,proto3" json:"profile,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *VerifyRequest) Reset() {
*x = VerifyRequest{}
mi := &file_crypto_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *VerifyRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*VerifyRequest) ProtoMessage() {}
func (x *VerifyRequest) ProtoReflect() protoreflect.Message {
mi := &file_crypto_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use VerifyRequest.ProtoReflect.Descriptor instead.
func (*VerifyRequest) Descriptor() ([]byte, []int) {
return file_crypto_proto_rawDescGZIP(), []int{4}
}
func (x *VerifyRequest) GetPayload() []byte {
if x != nil {
return x.Payload
}
return nil
}
func (x *VerifyRequest) GetProfile() string {
if x != nil {
return x.Profile
}
return ""
}
type VerifyResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Прошла ли проверка.
Valid bool `protobuf:"varint,1,opt,name=valid,proto3" json:"valid,omitempty"`
// CN из сертификата подписанта.
SignerCn string `protobuf:"bytes,2,opt,name=signer_cn,json=signerCn,proto3" json:"signer_cn,omitempty"`
// ИНН из сертификата (если присутствует в OID 1.2.643.3.131.1.1).
SignerInn string `protobuf:"bytes,3,opt,name=signer_inn,json=signerInn,proto3" json:"signer_inn,omitempty"`
// Серийный номер сертификата (hex).
Serial string `protobuf:"bytes,4,opt,name=serial,proto3" json:"serial,omitempty"`
// Срок действия сертификата (unix epoch, секунды).
NotBefore int64 `protobuf:"varint,5,opt,name=not_before,json=notBefore,proto3" json:"not_before,omitempty"`
NotAfter int64 `protobuf:"varint,6,opt,name=not_after,json=notAfter,proto3" json:"not_after,omitempty"`
// Тексты ошибок проверки (если valid=false).
Errors []string `protobuf:"bytes,7,rep,name=errors,proto3" json:"errors,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *VerifyResponse) Reset() {
*x = VerifyResponse{}
mi := &file_crypto_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *VerifyResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*VerifyResponse) ProtoMessage() {}
func (x *VerifyResponse) ProtoReflect() protoreflect.Message {
mi := &file_crypto_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use VerifyResponse.ProtoReflect.Descriptor instead.
func (*VerifyResponse) Descriptor() ([]byte, []int) {
return file_crypto_proto_rawDescGZIP(), []int{5}
}
func (x *VerifyResponse) GetValid() bool {
if x != nil {
return x.Valid
}
return false
}
func (x *VerifyResponse) GetSignerCn() string {
if x != nil {
return x.SignerCn
}
return ""
}
func (x *VerifyResponse) GetSignerInn() string {
if x != nil {
return x.SignerInn
}
return ""
}
func (x *VerifyResponse) GetSerial() string {
if x != nil {
return x.Serial
}
return ""
}
func (x *VerifyResponse) GetNotBefore() int64 {
if x != nil {
return x.NotBefore
}
return 0
}
func (x *VerifyResponse) GetNotAfter() int64 {
if x != nil {
return x.NotAfter
}
return 0
}
func (x *VerifyResponse) GetErrors() []string {
if x != nil {
return x.Errors
}
return nil
}
type SignRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Канонизированный XML, который нужно подписать.
Payload []byte `protobuf:"bytes,1,opt,name=payload,proto3" json:"payload,omitempty"`
// Алиас ключа в JCP-keystore.
KeyAlias string `protobuf:"bytes,2,opt,name=key_alias,json=keyAlias,proto3" json:"key_alias,omitempty"`
// Профиль (тот же что у Verify).
Profile string `protobuf:"bytes,3,opt,name=profile,proto3" json:"profile,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *SignRequest) Reset() {
*x = SignRequest{}
mi := &file_crypto_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *SignRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*SignRequest) ProtoMessage() {}
func (x *SignRequest) ProtoReflect() protoreflect.Message {
mi := &file_crypto_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use SignRequest.ProtoReflect.Descriptor instead.
func (*SignRequest) Descriptor() ([]byte, []int) {
return file_crypto_proto_rawDescGZIP(), []int{6}
}
func (x *SignRequest) GetPayload() []byte {
if x != nil {
return x.Payload
}
return nil
}
func (x *SignRequest) GetKeyAlias() string {
if x != nil {
return x.KeyAlias
}
return ""
}
func (x *SignRequest) GetProfile() string {
if x != nil {
return x.Profile
}
return ""
}
type SignResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Подписанный XML (с детачированной или встроенной подписью —
// зависит от профиля).
SignedXml []byte `protobuf:"bytes,1,opt,name=signed_xml,json=signedXml,proto3" json:"signed_xml,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *SignResponse) Reset() {
*x = SignResponse{}
mi := &file_crypto_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *SignResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*SignResponse) ProtoMessage() {}
func (x *SignResponse) ProtoReflect() protoreflect.Message {
mi := &file_crypto_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use SignResponse.ProtoReflect.Descriptor instead.
func (*SignResponse) Descriptor() ([]byte, []int) {
return file_crypto_proto_rawDescGZIP(), []int{7}
}
func (x *SignResponse) GetSignedXml() []byte {
if x != nil {
return x.SignedXml
}
return nil
}
type HealthRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *HealthRequest) Reset() {
*x = HealthRequest{}
mi := &file_crypto_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *HealthRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*HealthRequest) ProtoMessage() {}
func (x *HealthRequest) ProtoReflect() protoreflect.Message {
mi := &file_crypto_proto_msgTypes[8]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use HealthRequest.ProtoReflect.Descriptor instead.
func (*HealthRequest) Descriptor() ([]byte, []int) {
return file_crypto_proto_rawDescGZIP(), []int{8}
}
type HealthResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"`
Version string `protobuf:"bytes,2,opt,name=version,proto3" json:"version,omitempty"`
// Активный провайдер криптографии: "cryptopro" | "validata" | "vipnet" | "stub".
Provider string `protobuf:"bytes,3,opt,name=provider,proto3" json:"provider,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *HealthResponse) Reset() {
*x = HealthResponse{}
mi := &file_crypto_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *HealthResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*HealthResponse) ProtoMessage() {}
func (x *HealthResponse) ProtoReflect() protoreflect.Message {
mi := &file_crypto_proto_msgTypes[9]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use HealthResponse.ProtoReflect.Descriptor instead.
func (*HealthResponse) Descriptor() ([]byte, []int) {
return file_crypto_proto_rawDescGZIP(), []int{9}
}
func (x *HealthResponse) GetOk() bool {
if x != nil {
return x.Ok
}
return false
}
func (x *HealthResponse) GetVersion() string {
if x != nil {
return x.Version
}
return ""
}
func (x *HealthResponse) GetProvider() string {
if x != nil {
return x.Provider
}
return ""
}
var File_crypto_proto protoreflect.FileDescriptor
const file_crypto_proto_rawDesc = "" +
"\n" +
"\fcrypto.proto\x12\x1abridge_and_joins.crypto.v1\"+\n" +
"\x0fActivateRequest\x12\x18\n" +
"\aprofile\x18\x01 \x01(\tR\aprofile\"r\n" +
"\x10ActivateResponse\x12\x0e\n" +
"\x02ok\x18\x01 \x01(\bR\x02ok\x12\x1a\n" +
"\bprovider\x18\x02 \x01(\tR\bprovider\x12\x18\n" +
"\aprofile\x18\x03 \x01(\tR\aprofile\x12\x18\n" +
"\amessage\x18\x04 \x01(\tR\amessage\"\x11\n" +
"\x0fShutdownRequest\"\"\n" +
"\x10ShutdownResponse\x12\x0e\n" +
"\x02ok\x18\x01 \x01(\bR\x02ok\"C\n" +
"\rVerifyRequest\x12\x18\n" +
"\apayload\x18\x01 \x01(\fR\apayload\x12\x18\n" +
"\aprofile\x18\x02 \x01(\tR\aprofile\"\xce\x01\n" +
"\x0eVerifyResponse\x12\x14\n" +
"\x05valid\x18\x01 \x01(\bR\x05valid\x12\x1b\n" +
"\tsigner_cn\x18\x02 \x01(\tR\bsignerCn\x12\x1d\n" +
"\n" +
"signer_inn\x18\x03 \x01(\tR\tsignerInn\x12\x16\n" +
"\x06serial\x18\x04 \x01(\tR\x06serial\x12\x1d\n" +
"\n" +
"not_before\x18\x05 \x01(\x03R\tnotBefore\x12\x1b\n" +
"\tnot_after\x18\x06 \x01(\x03R\bnotAfter\x12\x16\n" +
"\x06errors\x18\a \x03(\tR\x06errors\"^\n" +
"\vSignRequest\x12\x18\n" +
"\apayload\x18\x01 \x01(\fR\apayload\x12\x1b\n" +
"\tkey_alias\x18\x02 \x01(\tR\bkeyAlias\x12\x18\n" +
"\aprofile\x18\x03 \x01(\tR\aprofile\"-\n" +
"\fSignResponse\x12\x1d\n" +
"\n" +
"signed_xml\x18\x01 \x01(\fR\tsignedXml\"\x0f\n" +
"\rHealthRequest\"V\n" +
"\x0eHealthResponse\x12\x0e\n" +
"\x02ok\x18\x01 \x01(\bR\x02ok\x12\x18\n" +
"\aversion\x18\x02 \x01(\tR\aversion\x12\x1a\n" +
"\bprovider\x18\x03 \x01(\tR\bprovider2\x88\x04\n" +
"\rCryptoService\x12f\n" +
"\rVerifyXMLDSig\x12).bridge_and_joins.crypto.v1.VerifyRequest\x1a*.bridge_and_joins.crypto.v1.VerifyResponse\x12`\n" +
"\vSignXMLDSig\x12'.bridge_and_joins.crypto.v1.SignRequest\x1a(.bridge_and_joins.crypto.v1.SignResponse\x12_\n" +
"\x06Health\x12).bridge_and_joins.crypto.v1.HealthRequest\x1a*.bridge_and_joins.crypto.v1.HealthResponse\x12e\n" +
"\bActivate\x12+.bridge_and_joins.crypto.v1.ActivateRequest\x1a,.bridge_and_joins.crypto.v1.ActivateResponse\x12e\n" +
"\bShutdown\x12+.bridge_and_joins.crypto.v1.ShutdownRequest\x1a,.bridge_and_joins.crypto.v1.ShutdownResponseBq\n" +
"!ru.zetit.bridgeandjoins.crypto.v1P\x01ZJgit.zetit.ru/zuevav/Bridge-and-Join-s/internal/cryptocli/cryptopb;cryptopbb\x06proto3"
var (
file_crypto_proto_rawDescOnce sync.Once
file_crypto_proto_rawDescData []byte
)
func file_crypto_proto_rawDescGZIP() []byte {
file_crypto_proto_rawDescOnce.Do(func() {
file_crypto_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_crypto_proto_rawDesc), len(file_crypto_proto_rawDesc)))
})
return file_crypto_proto_rawDescData
}
var file_crypto_proto_msgTypes = make([]protoimpl.MessageInfo, 10)
var file_crypto_proto_goTypes = []any{
(*ActivateRequest)(nil), // 0: bridge_and_joins.crypto.v1.ActivateRequest
(*ActivateResponse)(nil), // 1: bridge_and_joins.crypto.v1.ActivateResponse
(*ShutdownRequest)(nil), // 2: bridge_and_joins.crypto.v1.ShutdownRequest
(*ShutdownResponse)(nil), // 3: bridge_and_joins.crypto.v1.ShutdownResponse
(*VerifyRequest)(nil), // 4: bridge_and_joins.crypto.v1.VerifyRequest
(*VerifyResponse)(nil), // 5: bridge_and_joins.crypto.v1.VerifyResponse
(*SignRequest)(nil), // 6: bridge_and_joins.crypto.v1.SignRequest
(*SignResponse)(nil), // 7: bridge_and_joins.crypto.v1.SignResponse
(*HealthRequest)(nil), // 8: bridge_and_joins.crypto.v1.HealthRequest
(*HealthResponse)(nil), // 9: bridge_and_joins.crypto.v1.HealthResponse
}
var file_crypto_proto_depIdxs = []int32{
4, // 0: bridge_and_joins.crypto.v1.CryptoService.VerifyXMLDSig:input_type -> bridge_and_joins.crypto.v1.VerifyRequest
6, // 1: bridge_and_joins.crypto.v1.CryptoService.SignXMLDSig:input_type -> bridge_and_joins.crypto.v1.SignRequest
8, // 2: bridge_and_joins.crypto.v1.CryptoService.Health:input_type -> bridge_and_joins.crypto.v1.HealthRequest
0, // 3: bridge_and_joins.crypto.v1.CryptoService.Activate:input_type -> bridge_and_joins.crypto.v1.ActivateRequest
2, // 4: bridge_and_joins.crypto.v1.CryptoService.Shutdown:input_type -> bridge_and_joins.crypto.v1.ShutdownRequest
5, // 5: bridge_and_joins.crypto.v1.CryptoService.VerifyXMLDSig:output_type -> bridge_and_joins.crypto.v1.VerifyResponse
7, // 6: bridge_and_joins.crypto.v1.CryptoService.SignXMLDSig:output_type -> bridge_and_joins.crypto.v1.SignResponse
9, // 7: bridge_and_joins.crypto.v1.CryptoService.Health:output_type -> bridge_and_joins.crypto.v1.HealthResponse
1, // 8: bridge_and_joins.crypto.v1.CryptoService.Activate:output_type -> bridge_and_joins.crypto.v1.ActivateResponse
3, // 9: bridge_and_joins.crypto.v1.CryptoService.Shutdown:output_type -> bridge_and_joins.crypto.v1.ShutdownResponse
5, // [5:10] is the sub-list for method output_type
0, // [0:5] is the sub-list for method input_type
0, // [0:0] is the sub-list for extension type_name
0, // [0:0] is the sub-list for extension extendee
0, // [0:0] is the sub-list for field type_name
}
func init() { file_crypto_proto_init() }
func file_crypto_proto_init() {
if File_crypto_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_crypto_proto_rawDesc), len(file_crypto_proto_rawDesc)),
NumEnums: 0,
NumMessages: 10,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_crypto_proto_goTypes,
DependencyIndexes: file_crypto_proto_depIdxs,
MessageInfos: file_crypto_proto_msgTypes,
}.Build()
File_crypto_proto = out.File
file_crypto_proto_goTypes = nil
file_crypto_proto_depIdxs = nil
}
@@ -1,305 +0,0 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc v3.12.4
// source: crypto.proto
package cryptopb
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
CryptoService_VerifyXMLDSig_FullMethodName = "/bridge_and_joins.crypto.v1.CryptoService/VerifyXMLDSig"
CryptoService_SignXMLDSig_FullMethodName = "/bridge_and_joins.crypto.v1.CryptoService/SignXMLDSig"
CryptoService_Health_FullMethodName = "/bridge_and_joins.crypto.v1.CryptoService/Health"
CryptoService_Activate_FullMethodName = "/bridge_and_joins.crypto.v1.CryptoService/Activate"
CryptoService_Shutdown_FullMethodName = "/bridge_and_joins.crypto.v1.CryptoService/Shutdown"
)
// CryptoServiceClient is the client API for CryptoService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// CryptoService — серверная криптография по ГОСТ через КриптоПро JCP.
// Слушает на Unix Domain Socket (по умолчанию /run/bj/crypto.sock).
type CryptoServiceClient interface {
// Проверка XMLDSig-подписи (ГОСТ или RSA). Возвращает сведения о
// подписанте: CN, ИНН (если есть), срок действия сертификата.
VerifyXMLDSig(ctx context.Context, in *VerifyRequest, opts ...grpc.CallOption) (*VerifyResponse, error)
// Подпись XML по ГОСТ — для резервного канала WS ONYX и для
// серверной подписи действий оператора в admin-ui.
SignXMLDSig(ctx context.Context, in *SignRequest, opts ...grpc.CallOption) (*SignResponse, error)
// Health-check.
Health(ctx context.Context, in *HealthRequest, opts ...grpc.CallOption) (*HealthResponse, error)
// Activate — переинициализирует провайдер Валидаты на указанный
// профиль из pki1.conf. Если profile пуст — переходит в
// VCERT_InitMinimal (без доступа к ПСП/ЛСП/ССС). Не требует
// перезапуска сайдкара.
Activate(ctx context.Context, in *ActivateRequest, opts ...grpc.CallOption) (*ActivateResponse, error)
// Shutdown — корректно завершает процесс сайдкара (System.exit(2)
// после отправки ответа). systemd с Restart=on-failure поднимет
// его снова через RestartSec секунд. Используется для UI-кнопки
// «Перезапустить crypto-service» без sudo.
Shutdown(ctx context.Context, in *ShutdownRequest, opts ...grpc.CallOption) (*ShutdownResponse, error)
}
type cryptoServiceClient struct {
cc grpc.ClientConnInterface
}
func NewCryptoServiceClient(cc grpc.ClientConnInterface) CryptoServiceClient {
return &cryptoServiceClient{cc}
}
func (c *cryptoServiceClient) VerifyXMLDSig(ctx context.Context, in *VerifyRequest, opts ...grpc.CallOption) (*VerifyResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(VerifyResponse)
err := c.cc.Invoke(ctx, CryptoService_VerifyXMLDSig_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *cryptoServiceClient) SignXMLDSig(ctx context.Context, in *SignRequest, opts ...grpc.CallOption) (*SignResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(SignResponse)
err := c.cc.Invoke(ctx, CryptoService_SignXMLDSig_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *cryptoServiceClient) Health(ctx context.Context, in *HealthRequest, opts ...grpc.CallOption) (*HealthResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(HealthResponse)
err := c.cc.Invoke(ctx, CryptoService_Health_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *cryptoServiceClient) Activate(ctx context.Context, in *ActivateRequest, opts ...grpc.CallOption) (*ActivateResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ActivateResponse)
err := c.cc.Invoke(ctx, CryptoService_Activate_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *cryptoServiceClient) Shutdown(ctx context.Context, in *ShutdownRequest, opts ...grpc.CallOption) (*ShutdownResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ShutdownResponse)
err := c.cc.Invoke(ctx, CryptoService_Shutdown_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// CryptoServiceServer is the server API for CryptoService service.
// All implementations must embed UnimplementedCryptoServiceServer
// for forward compatibility.
//
// CryptoService — серверная криптография по ГОСТ через КриптоПро JCP.
// Слушает на Unix Domain Socket (по умолчанию /run/bj/crypto.sock).
type CryptoServiceServer interface {
// Проверка XMLDSig-подписи (ГОСТ или RSA). Возвращает сведения о
// подписанте: CN, ИНН (если есть), срок действия сертификата.
VerifyXMLDSig(context.Context, *VerifyRequest) (*VerifyResponse, error)
// Подпись XML по ГОСТ — для резервного канала WS ONYX и для
// серверной подписи действий оператора в admin-ui.
SignXMLDSig(context.Context, *SignRequest) (*SignResponse, error)
// Health-check.
Health(context.Context, *HealthRequest) (*HealthResponse, error)
// Activate — переинициализирует провайдер Валидаты на указанный
// профиль из pki1.conf. Если profile пуст — переходит в
// VCERT_InitMinimal (без доступа к ПСП/ЛСП/ССС). Не требует
// перезапуска сайдкара.
Activate(context.Context, *ActivateRequest) (*ActivateResponse, error)
// Shutdown — корректно завершает процесс сайдкара (System.exit(2)
// после отправки ответа). systemd с Restart=on-failure поднимет
// его снова через RestartSec секунд. Используется для UI-кнопки
// «Перезапустить crypto-service» без sudo.
Shutdown(context.Context, *ShutdownRequest) (*ShutdownResponse, error)
mustEmbedUnimplementedCryptoServiceServer()
}
// UnimplementedCryptoServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedCryptoServiceServer struct{}
func (UnimplementedCryptoServiceServer) VerifyXMLDSig(context.Context, *VerifyRequest) (*VerifyResponse, error) {
return nil, status.Error(codes.Unimplemented, "method VerifyXMLDSig not implemented")
}
func (UnimplementedCryptoServiceServer) SignXMLDSig(context.Context, *SignRequest) (*SignResponse, error) {
return nil, status.Error(codes.Unimplemented, "method SignXMLDSig not implemented")
}
func (UnimplementedCryptoServiceServer) Health(context.Context, *HealthRequest) (*HealthResponse, error) {
return nil, status.Error(codes.Unimplemented, "method Health not implemented")
}
func (UnimplementedCryptoServiceServer) Activate(context.Context, *ActivateRequest) (*ActivateResponse, error) {
return nil, status.Error(codes.Unimplemented, "method Activate not implemented")
}
func (UnimplementedCryptoServiceServer) Shutdown(context.Context, *ShutdownRequest) (*ShutdownResponse, error) {
return nil, status.Error(codes.Unimplemented, "method Shutdown not implemented")
}
func (UnimplementedCryptoServiceServer) mustEmbedUnimplementedCryptoServiceServer() {}
func (UnimplementedCryptoServiceServer) testEmbeddedByValue() {}
// UnsafeCryptoServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to CryptoServiceServer will
// result in compilation errors.
type UnsafeCryptoServiceServer interface {
mustEmbedUnimplementedCryptoServiceServer()
}
func RegisterCryptoServiceServer(s grpc.ServiceRegistrar, srv CryptoServiceServer) {
// If the following call panics, it indicates UnimplementedCryptoServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&CryptoService_ServiceDesc, srv)
}
func _CryptoService_VerifyXMLDSig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(VerifyRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(CryptoServiceServer).VerifyXMLDSig(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: CryptoService_VerifyXMLDSig_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(CryptoServiceServer).VerifyXMLDSig(ctx, req.(*VerifyRequest))
}
return interceptor(ctx, in, info, handler)
}
func _CryptoService_SignXMLDSig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SignRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(CryptoServiceServer).SignXMLDSig(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: CryptoService_SignXMLDSig_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(CryptoServiceServer).SignXMLDSig(ctx, req.(*SignRequest))
}
return interceptor(ctx, in, info, handler)
}
func _CryptoService_Health_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(HealthRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(CryptoServiceServer).Health(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: CryptoService_Health_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(CryptoServiceServer).Health(ctx, req.(*HealthRequest))
}
return interceptor(ctx, in, info, handler)
}
func _CryptoService_Activate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ActivateRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(CryptoServiceServer).Activate(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: CryptoService_Activate_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(CryptoServiceServer).Activate(ctx, req.(*ActivateRequest))
}
return interceptor(ctx, in, info, handler)
}
func _CryptoService_Shutdown_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ShutdownRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(CryptoServiceServer).Shutdown(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: CryptoService_Shutdown_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(CryptoServiceServer).Shutdown(ctx, req.(*ShutdownRequest))
}
return interceptor(ctx, in, info, handler)
}
// CryptoService_ServiceDesc is the grpc.ServiceDesc for CryptoService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var CryptoService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "bridge_and_joins.crypto.v1.CryptoService",
HandlerType: (*CryptoServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "VerifyXMLDSig",
Handler: _CryptoService_VerifyXMLDSig_Handler,
},
{
MethodName: "SignXMLDSig",
Handler: _CryptoService_SignXMLDSig_Handler,
},
{
MethodName: "Health",
Handler: _CryptoService_Health_Handler,
},
{
MethodName: "Activate",
Handler: _CryptoService_Activate_Handler,
},
{
MethodName: "Shutdown",
Handler: _CryptoService_Shutdown_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "crypto.proto",
}
-187
View File
@@ -1,187 +0,0 @@
// Package license — формат лицензии Bridge-and-Join-s и её подпись Ed25519.
//
// Лицензия — самодостаточный подписанный токен (offline-проверяемый):
// клиент проверяет подпись зашитым публичным ключом и срок действия БЕЗ
// обращения к серверу. Это значит, что on-prem bj-server продолжает
// работать даже если license-сервер недоступен.
//
// Online-сервер (cmd/bj-license-server) нужен только для отзыва (revocation)
// и выдачи новых ключей. Базовая модель — годовой ключ: выпустили на год,
// клиент проверяет offline; перед обновлением bj-server гейтит установку
// валидной непросроченной лицензией.
//
// Издатель держит приватный ключ в секрете; публичный зашит в bj-server.
package license
import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"time"
)
const CurrentSchema = 1
// Plan — тариф лицензии.
type Plan string
const (
PlanFree Plan = "free"
PlanPro Plan = "pro"
PlanEnterprise Plan = "enterprise"
)
// License — содержимое лицензии (подписывается целиком).
type License struct {
Schema int `json:"schema"`
ID string `json:"id"` // UUID лицензии
Tenant string `json:"tenant"` // организация-клиент
Product string `json:"product"` // "bj-server"
Plan Plan `json:"plan"` // free|pro|enterprise
IssuedAt time.Time `json:"issued_at"` // дата выпуска
ExpiresAt time.Time `json:"expires_at"` // дата окончания (годовой ключ)
Features []string `json:"features,omitempty"` // "updates","web-cabinet",...
MaxNodes int `json:"max_nodes,omitempty"` // лимит инсталляций (0 = без лимита)
Note string `json:"note,omitempty"`
}
// Token — лицензия + подпись. Именно это вводит клиент (одна base64-строка
// или JSON-файл). Формат: base64url(payload).base64url(sig) — компактно.
type Token struct {
Payload string `json:"payload"` // base64(каноничный JSON License)
Signature string `json:"signature"` // base64(ed25519 over каноничным JSON)
KeyID string `json:"key_id,omitempty"`
}
// Canonical сериализует лицензию детерминированно (для подписи/проверки).
func (l *License) Canonical() ([]byte, error) {
if l.Schema == 0 {
l.Schema = CurrentSchema
}
return json.Marshal(l)
}
// Valid проверяет срок действия на момент now.
func (l *License) Valid(now time.Time) error {
if now.Before(l.IssuedAt.Add(-24 * time.Hour)) {
return errors.New("license: ещё не действует (issued_at в будущем)")
}
if now.After(l.ExpiresAt) {
return fmt.Errorf("license: истекла %s", l.ExpiresAt.Format("02.01.2006"))
}
return nil
}
// HasFeature — включена ли фича (или план enterprise — всё включено).
func (l *License) HasFeature(f string) bool {
if l.Plan == PlanEnterprise {
return true
}
for _, x := range l.Features {
if x == f {
return true
}
}
return false
}
// AllowsUpdates — разрешены ли обновления по этой лицензии.
func (l *License) AllowsUpdates() bool { return l.HasFeature("updates") }
// DaysLeft — сколько дней до окончания (может быть отрицательным).
func (l *License) DaysLeft(now time.Time) int {
return int(l.ExpiresAt.Sub(now).Hours() / 24)
}
// Sign подписывает лицензию и возвращает Token.
func Sign(l *License, priv ed25519.PrivateKey, keyID string) (*Token, error) {
payload, err := l.Canonical()
if err != nil {
return nil, fmt.Errorf("license: canonical: %w", err)
}
sig := ed25519.Sign(priv, payload)
return &Token{
Payload: base64.StdEncoding.EncodeToString(payload),
Signature: base64.StdEncoding.EncodeToString(sig),
KeyID: keyID,
}, nil
}
// Verify проверяет подпись и возвращает License (срок проверяется отдельно
// через License.Valid — Verify только про подлинность).
func Verify(t *Token, pub ed25519.PublicKey) (*License, error) {
sig, err := base64.StdEncoding.DecodeString(t.Signature)
if err != nil {
return nil, fmt.Errorf("license: decode signature: %w", err)
}
payload, err := base64.StdEncoding.DecodeString(t.Payload)
if err != nil {
return nil, fmt.Errorf("license: decode payload: %w", err)
}
if !ed25519.Verify(pub, payload, sig) {
return nil, errors.New("license: подпись недействительна")
}
var l License
if err := json.Unmarshal(payload, &l); err != nil {
return nil, fmt.Errorf("license: unmarshal: %w", err)
}
if l.Schema != CurrentSchema {
return nil, fmt.Errorf("license: неподдерживаемая схема %d", l.Schema)
}
return &l, nil
}
// Encode сериализует Token в компактную строку payload.signature[.keyid]
// (то, что клиент вставляет в поле «лицензионный ключ»).
func (t *Token) Encode() string {
s := t.Payload + "." + t.Signature
if t.KeyID != "" {
s += "." + t.KeyID
}
return s
}
// DecodeToken разбирает компактную строку обратно в Token.
func DecodeToken(s string) (*Token, error) {
parts := strings.Split(strings.TrimSpace(s), ".")
if len(parts) < 2 {
return nil, errors.New("license: неверный формат ключа (ожидается payload.signature)")
}
t := &Token{Payload: parts[0], Signature: parts[1]}
if len(parts) >= 3 {
t.KeyID = parts[2]
}
return t, nil
}
// --- Ключи (как в release) ---
func LoadPrivateKey(path string) (ed25519.PrivateKey, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
seed, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(b)))
if err != nil {
return nil, fmt.Errorf("license: decode seed: %w", err)
}
if len(seed) != ed25519.SeedSize {
return nil, fmt.Errorf("license: неверный размер seed %d", len(seed))
}
return ed25519.NewKeyFromSeed(seed), nil
}
func ParsePublicKey(b64 string) (ed25519.PublicKey, error) {
pub, err := base64.StdEncoding.DecodeString(strings.TrimSpace(b64))
if err != nil {
return nil, err
}
if len(pub) != ed25519.PublicKeySize {
return nil, fmt.Errorf("license: неверный размер pubkey %d", len(pub))
}
return ed25519.PublicKey(pub), nil
}
-65
View File
@@ -1,65 +0,0 @@
package license
import (
"crypto/ed25519"
"crypto/rand"
"testing"
"time"
)
func mkLicense(plan Plan, expires time.Time, feats ...string) *License {
return &License{
ID: "test-id", Tenant: "ООО Тест", Product: "bj-server",
Plan: plan, IssuedAt: time.Now().UTC().Add(-time.Hour),
ExpiresAt: expires, Features: feats,
}
}
func TestSignVerifyAndEncode(t *testing.T) {
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
l := mkLicense(PlanPro, time.Now().Add(365*24*time.Hour), "updates")
tok, err := Sign(l, priv, "main")
if err != nil {
t.Fatal(err)
}
// round-trip через компактную строку
dec, err := DecodeToken(tok.Encode())
if err != nil {
t.Fatal(err)
}
got, err := Verify(dec, pub)
if err != nil {
t.Fatalf("Verify: %v", err)
}
if got.Tenant != l.Tenant || got.Plan != PlanPro || !got.AllowsUpdates() {
t.Fatalf("mismatch: %+v", got)
}
}
func TestExpired(t *testing.T) {
l := mkLicense(PlanPro, time.Now().Add(-time.Hour), "updates")
if err := l.Valid(time.Now().UTC()); err == nil {
t.Fatal("истёкшая лицензия прошла Valid")
}
}
func TestFeaturesAndEnterprise(t *testing.T) {
pro := mkLicense(PlanPro, time.Now().Add(time.Hour), "updates")
if !pro.HasFeature("updates") || pro.HasFeature("web-cabinet") {
t.Fatal("pro features неверны")
}
ent := mkLicense(PlanEnterprise, time.Now().Add(time.Hour))
if !ent.HasFeature("anything") || !ent.AllowsUpdates() {
t.Fatal("enterprise должен включать всё")
}
}
func TestVerifyRejectsWrongKey(t *testing.T) {
_, priv, _ := ed25519.GenerateKey(rand.Reader)
other, _, _ := ed25519.GenerateKey(rand.Reader)
l := mkLicense(PlanPro, time.Now().Add(time.Hour))
tok, _ := Sign(l, priv, "main")
if _, err := Verify(tok, other); err == nil {
t.Fatal("Verify принял подпись чужим ключом")
}
}
+13 -44
View File
@@ -19,29 +19,19 @@ var templatesFS embed.FS
// конкретный content-шаблон). Так html/template не путается с несколькими
// {{define "content"}} в разных файлах.
type admin struct {
home, claims, claim, status, setup *template.Template
help, helpDatabase, helpLK, helpCrypto, helpSystems, helpRobot, helpArchitecture *template.Template
wizard, news, keyWizard *template.Template
home, claims, claim, status, setup *template.Template
help, helpDatabase, helpLK, helpCryptoPro, helpSystems, helpRobot, helpArchitecture *template.Template
wizard, news *template.Template
}
// templateFuncs — функции, доступные внутри шаблонов. Главная задача —
// русификация статусов и других технических обозначений (см. требование
// «всё UI на русском, кроме программных терминов»).
var templateFuncs = template.FuncMap{
"ru": russianText,
"ruState": russianState,
"ruOutcome": russianOutcome,
"now": time.Now,
"add": func(a, b int) int { return a + b },
"fallbackTpl": fallback,
"anyKeymedia": func(ds []flashDrive) bool {
for _, d := range ds {
if d.IsKeymedia {
return true
}
}
return false
},
"ru": russianText,
"ruState": russianState,
"ruOutcome": russianOutcome,
"now": time.Now,
}
// russianState переводит технический FSM-state в человекочитаемый
@@ -125,9 +115,9 @@ func newAdmin() (*admin, error) {
if err != nil {
return nil, fmt.Errorf("parse admin_help_lk: %w", err)
}
helpCrypto, err := parse("admin_help_crypto.html")
helpCP, err := parse("admin_help_cryptopro.html")
if err != nil {
return nil, fmt.Errorf("parse admin_help_crypto: %w", err)
return nil, fmt.Errorf("parse admin_help_cryptopro: %w", err)
}
helpSys, err := parse("admin_help_systems.html")
if err != nil {
@@ -149,15 +139,11 @@ func newAdmin() (*admin, error) {
if err != nil {
return nil, fmt.Errorf("parse admin_help_architecture: %w", err)
}
keyWizard, err := parse("admin_keywizard.html")
if err != nil {
return nil, fmt.Errorf("parse admin_keywizard: %w", err)
}
return &admin{
home: home, claims: claims, claim: claim, status: status, setup: setup,
help: help, helpDatabase: helpDB, helpLK: helpLK, helpCrypto: helpCrypto, helpSystems: helpSys,
help: help, helpDatabase: helpDB, helpLK: helpLK, helpCryptoPro: helpCP, helpSystems: helpSys,
helpRobot: helpRobot, helpArchitecture: helpArch,
wizard: wizard, news: news, keyWizard: keyWizard,
wizard: wizard, news: news,
}, nil
}
@@ -187,11 +173,6 @@ type homeData struct {
}
Recent []ClaimView
News []NewsItem // top-3 активных или свежих новостей
// Сводка готовности системы для hero-блока дашборда.
AllReady bool
NotReadyCount int
TotalCount int
}
// claimsData — данные журнала.
@@ -241,8 +222,8 @@ func RegisterAdmin(mux *http.ServeMux, svc *Service, rc *RuntimeConfig, getOpts
render(w, a.helpDatabase, nowPage("База данных", "help"))
case p == "help/lk-api":
render(w, a.helpLK, nowPage("API ЛК", "help"))
case p == "help/crypto":
render(w, a.helpCrypto, nowPage("Криптография", "help"))
case p == "help/cryptopro":
render(w, a.helpCryptoPro, nowPage("КриптоПро", "help"))
case p == "help/systems":
render(w, a.helpSystems, nowPage("Внешние системы", "help"))
case p == "help/robot":
@@ -273,18 +254,6 @@ func (a *admin) renderHome(w http.ResponseWriter, r *http.Request, svc *Service,
Recent: recent.Items,
News: topNews(rc.Snapshot().News.Items, 3),
}
// Готовность системы считаем ТОЛЬКО по обязательным компонентам.
// Опциональные (напр. callback в ЛК) не влияют на «готовность».
for _, c := range status.Checks {
if c.Optional {
continue
}
data.TotalCount++
if !c.OK {
data.NotReadyCount++
}
}
data.AllReady = data.TotalCount > 0 && data.NotReadyCount == 0
full, err := svc.ListClaims(ctx, m2mcore.Filter{Limit: 200})
if err == nil {
for _, c := range full.Items {
+42 -25
View File
@@ -12,14 +12,12 @@ import (
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// caCertsDir — куда складываются скачанные сертификаты УЦ.
const caCertsDir = "/var/lib/bj/ca-certs"
// defaultNSDCAURLs — список URL для авто-загрузки сертификатов УЦ НРД.
// Эти URL пользователь может скорректировать в /admin/setup → «Сертификаты
// УЦ» (раздел появляется после первого сохранения настроек). На сайте НРД
@@ -33,8 +31,8 @@ var defaultNSDCAURLs = []string{
// нужные ссылки в UI после того, как уточните URL у НРД.
}
// FetchCACertificates скачивает все URL из настроек, парсит .cer и
// сохраняет файл в /var/lib/bj/ca-certs/. Если передан rc — на каждое
// FetchCACertificates скачивает все URL из настроек, парсит .cer, и при
// успехе вызывает certmgr -inst -store mroot. Если передан rc — на каждое
// фактическое изменение сертификата (новый или изменился SHA-256)
// публикуется новость в ленту через rc.AddNews. На сертификаты,
// истекающие в ближайшие 14 дней — отдельная новость-предупреждение.
@@ -70,13 +68,13 @@ func FetchCACertificates(ctx context.Context, s CACertsSettings, rc *RuntimeConf
fc.IssuerCN = cert.Issuer.CommonName
fc.NotAfter = cert.NotAfter
fc.SHA256 = hex.EncodeToString(sha256Bytes(der))
// Корневые (Issuer == Subject) и промежуточные складываем рядом,
// в общую папку /var/lib/bj/ca-certs/.
kind := "intermediate"
// УЦ-сертификаты с самоподписью (Issuer == Subject) идут в mroot,
// промежуточные — в uRoot.
store := "uRoot"
if cert.Subject.CommonName == cert.Issuer.CommonName {
kind = "root"
store = "mroot"
}
fc.Store = kind
fc.Store = store
// Дедуп: если sha256 совпадает с уже импортированным — пропускаем
// сам импорт (но фиксируем что проверили).
@@ -93,7 +91,7 @@ func FetchCACertificates(ctx context.Context, s CACertsSettings, rc *RuntimeConf
continue
}
// Сохраняем DER на диск в /var/lib/bj/ca-certs/<sha>.cer.
// Импорт через certmgr.
isNew := true
for _, old := range s.FetchedCerts {
if old.URL == u && old.Error == "" {
@@ -101,22 +99,22 @@ func FetchCACertificates(ctx context.Context, s CACertsSettings, rc *RuntimeConf
break
}
}
if err := saveCertToDir(der, fc.SHA256); err != nil {
fc.Error = "save: " + err.Error()
fmt.Fprintf(&logBuf, "%s — сохранить не удалось: %s\n", u, err)
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: "Не удалось сохранить сертификат УЦ",
Title: "Не удалось импортировать сертификат УЦ",
Body: "URL: " + u + "\nCN: " + fc.SubjectCN + "\nОшибка: " + err.Error(),
URL: u,
})
}
} else {
fmt.Fprintf(&logBuf, "%s — сохранён (%s, CN=%s, sha256=%s...)\n",
u, kind, fc.SubjectCN, fc.SHA256[:12])
fmt.Fprintf(&logBuf, "%s — импортирован в %s (CN=%s, sha256=%s...)\n",
u, store, fc.SubjectCN, fc.SHA256[:12])
if rc != nil {
kindTitle := "Обновлён сертификат УЦ"
if isNew {
@@ -127,8 +125,8 @@ func FetchCACertificates(ctx context.Context, s CACertsSettings, rc *RuntimeConf
At: now,
Kind: "feature",
Title: kindTitle + ": " + fc.SubjectCN,
Body: fmt.Sprintf("Тип: %s\nИздатель: %s\nДействителен до: %s\nSHA-256: %s…\nURL источника: %s",
kind, fc.IssuerCN, fc.NotAfter.Format("02.01.2006"), fc.SHA256[:16], u),
Body: fmt.Sprintf("Хранилище: %s\nИздатель: %s\nДействителен до: %s\nSHA-256: %s…\nURL источника: %s",
store, fc.IssuerCN, fc.NotAfter.Format("02.01.2006"), fc.SHA256[:16], u),
URL: u,
ValidTo: fc.NotAfter,
})
@@ -200,13 +198,29 @@ func downloadAndParseCert(ctx context.Context, rawURL string) ([]byte, error) {
return data, nil
}
// saveCertToDir сохраняет DER-байты в /var/lib/bj/ca-certs/<sha>.cer.
func saveCertToDir(der []byte, sha256hex string) error {
if err := os.MkdirAll(caCertsDir, 0o755); err != nil {
// 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
}
dst := filepath.Join(caCertsDir, sha256hex+".cer")
return os.WriteFile(dst, der, 0o644)
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 запускает горутину, которая раз в сутки
@@ -298,4 +312,7 @@ func (h *setupHandlers) fetchCACertsNow(w http.ResponseWriter, r *http.Request)
// caCertsTemplateString — компактный URL для отображения в UI.
func caCertsTemplateString(s CACertsSettings) string {
return strings.Join(s.URLs, "\n")
}
}
// доп. защита от пустых импортов (linter)
var _ = filepath.Join
+12 -37
View File
@@ -5,8 +5,6 @@ import (
"errors"
"net"
"net/http"
"github.com/jackc/pgx/v5/pgxpool"
"os"
"time"
)
@@ -17,9 +15,6 @@ type Status struct {
OK bool `json:"ok"`
Message string `json:"message,omitempty"`
Detail string `json:"detail,omitempty"`
// Optional — компонент не обязателен для работы с НРД. Его «не-OK»
// не делает систему «не готовой» (напр. callback в ЛК).
Optional bool `json:"optional,omitempty"`
}
// SystemStatus — все проверки.
@@ -60,30 +55,17 @@ func CheckAll(ctx context.Context, o CheckOptions) SystemStatus {
return out
}
func checkPostgres(ctx context.Context, o CheckOptions) Status {
s := Status{Name: "База данных PostgreSQL"}
func checkPostgres(_ context.Context, o CheckOptions) Status {
s := Status{Name: "postgres"}
if o.PostgresDSN == "" {
s.OK = true
s.Optional = true
s.Message = "in-memory — данные не сохраняются между перезапусками"
s.Message = "in-memory (PostgresDSN не задан, репозиторий — m2mcore.MemoryRepository)"
return s
}
pctx, cancel := context.WithTimeout(ctx, o.Timeout)
defer cancel()
pool, err := pgxpool.New(pctx, o.PostgresDSN)
if err != nil {
s.OK = false
s.Message = "ошибка подключения: " + err.Error()
return s
}
defer pool.Close()
if err := pool.Ping(pctx); err != nil {
s.OK = false
s.Message = "не отвечает: " + err.Error()
return s
}
s.OK = true
s.Message = "подключена, репозиторий m2m_core.deals"
// На M2 здесь будет sql.Open + Ping. На текущем шаге — заглушка.
s.OK = false
s.Message = "PostgreSQL Repository не подключён (требуется pgx, M2-шаг-3)"
s.Detail = "DSN: " + o.PostgresDSN
return s
}
@@ -129,27 +111,20 @@ func checkCryptoSocket(o CheckOptions) Status {
}
func checkNSDAdapter(ctx context.Context, o CheckOptions) Status {
s := Status{Name: "Интеграционный шлюз НРД"}
s := Status{Name: "nsd-adapter (REST к ИШ)"}
if o.NSDAdapterURL == "" {
s.OK = true
s.Optional = true
s.Message = "не подключён — режим эмуляции (mock)"
s.Message = "BJ_NSD_ADAPTER_URL не задан — используется mock NSDSender"
return s
}
// У ИШ нет /healthz — проверяем рабочий эндпоинт Web API (engine/state
// отвечает 200 «Running», когда движок поднят).
st := httpHealth(ctx, o.NSDAdapterURL+"/api/admin/engine/state", o.Timeout, s)
if st.OK {
st.Message = "подключён, движок работает"
}
return st
return httpHealth(ctx, o.NSDAdapterURL+"/healthz", o.Timeout, s)
}
func checkLKCallback(ctx context.Context, o CheckOptions) Status {
s := Status{Name: "Callback в личный кабинет", Optional: true}
s := Status{Name: "lk-emulator (callback)"}
if o.LKCallbackURL == "" {
s.OK = false
s.Message = "не настроен — уведомления в ЛК отключены (необязательно для работы с НРД)"
s.Message = "BJ_LK_CALLBACK_URL не задан — callback'и в ЛК отключены"
return s
}
return httpHealth(ctx, o.LKCallbackURL+"/healthz", o.Timeout, s)
+190
View File
@@ -0,0 +1,190 @@
package lkgateway
import (
"context"
"fmt"
"io"
"net/http"
"os"
"os/user"
"path/filepath"
"strings"
"time"
)
// FlashContainer — найденный на смонтированной флешке контейнер КриптоПро.
// КриптоПро CSP под Linux ожидает контейнер в виде папки <name>.000 с
// файлами header.key/masks.key/name.key/primary.key/primary2.key.
type FlashContainer struct {
// Mountpoint — путь смонтированной флешки, например /run/media/user/USB.
Mountpoint string
// Path — полный путь до папки <name>.000.
Path string
// Name — имя контейнера (без суффикса .000).
Name string
// Files — список файлов в контейнере (для дисплея).
Files []string
// AlreadyImported — true, если папка <name>.000 уже есть в локальном
// хранилище /var/opt/cprocsp/keys/<user>/.
AlreadyImported bool
}
// scanFlashContainers ищет контейнеры формата <name>.000 на типичных
// точках монтирования USB-носителей в Linux: /run/media/<user>/* и
// /media/<user>/* и /media/*. Возвращает список найденных контейнеров.
func scanFlashContainers() []FlashContainer {
u, err := user.Current()
if err != nil {
return nil
}
roots := []string{
filepath.Join("/run/media", u.Username),
filepath.Join("/media", u.Username),
"/media",
"/mnt",
}
localKeysDir := filepath.Join("/var/opt/cprocsp/keys", u.Username)
var out []FlashContainer
for _, root := range roots {
entries, err := os.ReadDir(root)
if err != nil {
continue
}
for _, e := range entries {
if !e.IsDir() {
continue
}
mountpoint := filepath.Join(root, e.Name())
out = append(out, findContainersAt(mountpoint, localKeysDir)...)
}
}
return out
}
func findContainersAt(mountpoint, localKeysDir string) []FlashContainer {
var out []FlashContainer
// Ищем папки <name>.000 на верхнем уровне и на 1 уровне вглубь.
_ = filepath.Walk(mountpoint, func(p string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
// Глубже 2 уровней не лезем (на флешке могут быть личные папки).
rel, _ := filepath.Rel(mountpoint, p)
if strings.Count(rel, string(filepath.Separator)) > 2 {
return filepath.SkipDir
}
if !info.IsDir() || !strings.HasSuffix(strings.ToLower(p), ".000") {
return nil
}
// Проверяем, что внутри лежат файлы вида *.key.
entries, _ := os.ReadDir(p)
var files []string
hasKey := false
for _, ent := range entries {
files = append(files, ent.Name())
if strings.HasSuffix(strings.ToLower(ent.Name()), ".key") {
hasKey = true
}
}
if !hasKey {
return nil
}
name := strings.TrimSuffix(filepath.Base(p), ".000")
fc := FlashContainer{
Mountpoint: mountpoint,
Path: p,
Name: name,
Files: files,
}
// Проверка: уже скопирован в локальное хранилище?
if _, err := os.Stat(filepath.Join(localKeysDir, name+".000")); err == nil {
fc.AlreadyImported = true
}
out = append(out, fc)
return filepath.SkipDir
})
return out
}
// copyContainerToLocal копирует папку <name>.000 с флешки в локальное
// хранилище КриптоПро /var/opt/cprocsp/keys/<user>/<name>.000. После
// этого контейнер виден как \\.\HDIMAGE\<name> и работает даже без
// вставленной флешки.
func copyContainerToLocal(srcDir string) (string, error) {
u, err := user.Current()
if err != nil {
return "", err
}
localKeysDir := filepath.Join("/var/opt/cprocsp/keys", u.Username)
if err := os.MkdirAll(localKeysDir, 0o700); err != nil {
return "", fmt.Errorf("создать %s: %w", localKeysDir, err)
}
base := filepath.Base(srcDir)
dstDir := filepath.Join(localKeysDir, base)
if _, err := os.Stat(dstDir); err == nil {
return "", fmt.Errorf("контейнер %s уже существует в локальном хранилище", dstDir)
}
if err := os.MkdirAll(dstDir, 0o700); err != nil {
return "", fmt.Errorf("создать %s: %w", dstDir, err)
}
entries, err := os.ReadDir(srcDir)
if err != nil {
return "", err
}
for _, e := range entries {
if e.IsDir() {
continue
}
src, err := os.Open(filepath.Join(srcDir, e.Name()))
if err != nil {
return "", err
}
dst, err := os.OpenFile(filepath.Join(dstDir, e.Name()),
os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
if err != nil {
src.Close()
return "", err
}
if _, err := io.Copy(dst, src); err != nil {
src.Close()
dst.Close()
return "", err
}
src.Close()
dst.Close()
}
return dstDir, nil
}
// copyContainer — POST /admin/setup/crypto/copy-container.
// Параметр src — путь до папки <name>.000 на флешке.
func (h *setupHandlers) copyContainer(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
src := strings.TrimSpace(r.FormValue("src"))
if src == "" {
setupFlash(w, r, "Копирование контейнера: не указан путь")
return
}
// Минимальная защита: ожидаем .000 в конце пути.
if !strings.HasSuffix(strings.ToLower(src), ".000") {
setupFlash(w, r, "Копирование контейнера: путь должен заканчиваться на .000")
return
}
if _, err := os.Stat(src); err != nil {
setupFlash(w, r, "Копирование контейнера: исходная папка недоступна: "+err.Error())
return
}
dst, err := copyContainerToLocal(src)
if err != nil {
setupFlash(w, r, "Копирование контейнера: "+err.Error())
return
}
// Дадим CSP несколько мс «заметить» новый контейнер (не критично).
_, cancel := context.WithTimeout(r.Context(), 100*time.Millisecond)
cancel()
setupFlash(w, r, "Контейнер скопирован в "+dst+". Теперь он виден как \\\\.\\HDIMAGE\\"+strings.TrimSuffix(filepath.Base(dst), ".000")+" и работает без вставленной флешки. Импортируйте сертификат: certmgr -inst -cont '\\\\.\\HDIMAGE\\"+strings.TrimSuffix(filepath.Base(dst), ".000")+"' -store uMy.")
}
-525
View File
@@ -1,525 +0,0 @@
package lkgateway
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/cryptocli"
)
// urlQ — экранирование строки для query-параметра flash.
func urlQ(s string) string { return url.QueryEscape(s) }
// Пошаговый мастер установки ключа Валидаты на съёмный носитель (USB keymedia
// ИШ). Реализует то, что просил пользователь: загрузил архив + пароль →
// распаковка → запись на флешку → формирование справочника сертификатов →
// проверка Валидаты → «Готово» → можно слать тестовый документ.
//
// Привилегированные операции с флешкой (бэкап, remount rw, запись, перезапуск
// VDCrySvc) делает помощник /usr/local/sbin/bj-keymedia-install через узкий
// sudoers (bj-server работает под непривилегированным bj).
// keyWizardStep — один шаг мастера.
type keyWizardStep struct {
Title string
Status string // pending | active | ok | error
Detail string
}
// keyWizardState — состояние одного прогона мастера (в памяти, один активный).
type keyWizardState struct {
mu sync.Mutex
StagingID string // id распаковки в /var/lib/bj/media/iso/<id>
VDK string // имя файла ключа
Profile string // имя установленного профиля на носителе
Backup string // путь бэкапа
Steps []keyWizardStep // 1..5
Done bool // все шаги пройдены
Flash string
}
func newKeyWizardState() *keyWizardState {
return &keyWizardState{Steps: defaultKeySteps()}
}
// reset обнуляет поля прогона, НЕ трогая мьютекс (вызывать под Lock).
func (s *keyWizardState) reset() {
s.StagingID = ""
s.VDK = ""
s.Profile = ""
s.Backup = ""
s.Steps = defaultKeySteps()
s.Done = false
s.Flash = ""
}
func defaultKeySteps() []keyWizardStep {
return []keyWizardStep{
{Title: "Загрузка архива и распаковка", Status: "pending"},
{Title: "Запись ключа на выбранную флешку (с бэкапом)", Status: "pending"},
{Title: "Формирование справочника сертификатов (CRL)", Status: "pending"},
{Title: "Перезапуск и проверка ИШ", Status: "pending"},
{Title: "Готово — можно отправлять тестовый документ", Status: "pending"},
}
}
func (s *keyWizardState) set(i int, status, detail string) {
if i >= 0 && i < len(s.Steps) {
s.Steps[i].Status = status
if detail != "" {
s.Steps[i].Detail = detail
}
}
}
// flashDrive — съёмный носитель (USB), обнаруженный в системе.
type flashDrive struct {
Device string // /dev/sdb1
Size string // 1,9G
Label string
FSType string
Mountpoint string // пусто если не смонтирован
Model string // USB2FlashStorage
IsKeymedia bool // смонтирован как текущий ключевой носитель ИШ
}
// keyWizardData — данные шаблона admin_keywizard.html.
type keyWizardData struct {
page
State *keyWizardState
Drives []flashDrive
}
const keymediaMount = "/var/lib/igate/keymedia"
// listFlashDrives перечисляет съёмные (removable/hotplug) USB-носители с ФС —
// чтобы пользователь выбрал, на какую флешку писать ключ.
func listFlashDrives() []flashDrive {
out, err := exec.Command("lsblk", "-J", "-b", "-o",
"NAME,SIZE,LABEL,MOUNTPOINT,RM,HOTPLUG,TYPE,MODEL,FSTYPE,PATH").Output()
if err != nil {
return nil
}
var parsed struct {
Blockdevices []json.RawMessage `json:"blockdevices"`
}
if json.Unmarshal(out, &parsed) != nil {
return nil
}
var drives []flashDrive
var walk func(raw []byte, parentRemovable bool, parentModel string)
walk = func(raw []byte, parentRemovable bool, parentModel string) {
var d struct {
Name string `json:"name"`
Size int64 `json:"size"`
Label string `json:"label"`
Mountpoint string `json:"mountpoint"`
RM bool `json:"rm"`
Hotplug bool `json:"hotplug"`
Type string `json:"type"`
Model string `json:"model"`
FSType string `json:"fstype"`
Path string `json:"path"`
Children []json.RawMessage `json:"children"`
}
if json.Unmarshal(raw, &d) != nil {
return
}
removable := d.RM || d.Hotplug || parentRemovable
model := strings.TrimSpace(d.Model)
if model == "" {
model = parentModel
}
// Носитель с ФС — кандидат на запись.
if removable && d.Type == "part" && d.FSType != "" {
drives = append(drives, flashDrive{
Device: d.Path,
Size: humanSize(d.Size),
Label: d.Label,
FSType: d.FSType,
Mountpoint: d.Mountpoint,
Model: model,
IsKeymedia: d.Mountpoint == keymediaMount,
})
}
for _, c := range d.Children {
walk(c, removable, model)
}
}
for _, b := range parsed.Blockdevices {
walk(b, false, "")
}
return drives
}
func humanSize(b int64) string {
switch {
case b >= 1<<30:
return fmt.Sprintf("%.1f ГБ", float64(b)/(1<<30))
case b >= 1<<20:
return fmt.Sprintf("%.0f МБ", float64(b)/(1<<20))
default:
return fmt.Sprintf("%d Б", b)
}
}
// registerKeyWizard вешает маршруты мастера установки ключа.
func (h *setupHandlers) registerKeyWizard(mux *http.ServeMux) {
if h.keyWiz == nil {
h.keyWiz = newKeyWizardState()
}
mux.HandleFunc("/admin/setup/keywizard", h.renderKeyWizard)
mux.HandleFunc("/admin/setup/keywizard/upload", h.keyWizardUpload)
mux.HandleFunc("/admin/setup/keywizard/install", h.keyWizardInstall)
mux.HandleFunc("/admin/setup/keywizard/reset", h.keyWizardReset)
}
func (h *setupHandlers) renderKeyWizard(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
h.keyWiz.mu.Lock()
h.keyWiz.Flash = r.URL.Query().Get("flash")
st := h.keyWiz
h.keyWiz.mu.Unlock()
// Список флешек нужен на шаге выбора носителя (когда архив уже загружен).
var drives []flashDrive
if st.StagingID != "" && !st.Done {
drives = listFlashDrives()
}
render(w, h.tpl.a.keyWizard, keyWizardData{page: nowPage("Установка ключа", "setup"), State: st, Drives: drives})
}
func (h *setupHandlers) keyWizardReset(w http.ResponseWriter, r *http.Request) {
h.keyWiz.mu.Lock()
h.keyWiz.reset()
h.keyWiz.mu.Unlock()
http.Redirect(w, r, "/admin/setup/keywizard", http.StatusSeeOther)
}
// keyWizardUpload — шаг 1: приём .7z + пароль, распаковка, инспекция.
func (h *setupHandlers) keyWizardUpload(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
if err := r.ParseMultipartForm(500 << 20); err != nil {
h.keyWizFlash(w, r, "Ошибка чтения формы: "+err.Error())
return
}
file, header, err := r.FormFile("archive")
if err != nil {
h.keyWizFlash(w, r, "Выберите файл архива (.7z/.zip)")
return
}
defer file.Close()
lower := strings.ToLower(header.Filename)
if !(strings.HasSuffix(lower, ".7z") || strings.HasSuffix(lower, ".zip")) {
h.keyWizFlash(w, r, "Архив должен быть .7z или .zip")
return
}
password := r.FormValue("password")
isoDir := "/var/lib/bj/iso"
if err := os.MkdirAll(isoDir, 0o755); err != nil {
h.keyWizFlash(w, r, "Не удалось создать "+isoDir+": "+err.Error())
return
}
dst := filepath.Join(isoDir, time.Now().UTC().Format("20060102-150405-")+filepath.Base(header.Filename))
out, err := os.Create(dst)
if err != nil {
h.keyWizFlash(w, r, "Запись архива: "+err.Error())
return
}
if _, err := io.Copy(out, file); err != nil {
out.Close()
_ = os.Remove(dst)
h.keyWizFlash(w, r, "Запись архива: "+err.Error())
return
}
out.Close()
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
defer cancel()
m, err := ExtractISO(ctx, dst, password)
if err != nil {
h.keyWizFlash(w, r, "Распаковка не удалась (проверьте пароль): "+err.Error())
return
}
h.keyWiz.mu.Lock()
h.keyWiz.reset()
h.keyWiz.StagingID = m.ID
// Инспекция через помощник (читает staging, находит .vdk/gdbm/pse).
staging := filepath.Join("/var/lib/bj/media/iso", m.ID)
insp, ierr := runKeymediaHelper(ctx, "inspect", staging, "", "", "")
if ierr == nil && insp["ok"] == true {
if v, ok := insp["vdk"].(string); ok {
h.keyWiz.VDK = v
}
}
detail := fmt.Sprintf("Ключ: %s · справочник сертификатов: %s",
fallback(h.keyWiz.VDK, "—"), yesNo(insp["has_gdbm"]))
h.keyWiz.set(0, "ok", detail)
h.keyWiz.set(1, "active", "")
h.keyWiz.mu.Unlock()
http.Redirect(w, r, "/admin/setup/keywizard", http.StatusSeeOther)
}
// keyWizardInstall — шаги 2-5: запись на флешку, справочник, проверка, готово.
func (h *setupHandlers) keyWizardInstall(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
h.keyWiz.mu.Lock()
id := h.keyWiz.StagingID
h.keyWiz.mu.Unlock()
if id == "" {
h.keyWizFlash(w, r, "Сначала загрузите архив (шаг 1)")
return
}
staging := filepath.Join("/var/lib/bj/media/iso", id)
// Выбор флешки и имя профиля из формы.
profileName := strings.TrimSpace(r.FormValue("profile_name"))
targetDev := strings.TrimSpace(r.FormValue("target_device"))
targetMnt := ""
if targetDev != "" {
for _, d := range listFlashDrives() {
if d.Device == targetDev {
targetMnt = d.Mountpoint
break
}
}
}
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
defer cancel()
// Шаг 2-3: запись ключа на флешку + справочник + CRL (привилегированный воркер).
res, err := runKeymediaHelper(ctx, "install", staging, profileName, targetDev, targetMnt)
h.keyWiz.mu.Lock()
if err != nil || res["ok"] != true {
msg := errStr(err)
if e, ok := res["error"].(string); ok {
msg = e
}
h.keyWiz.set(1, "error", "Запись на флешку не удалась: "+msg)
h.keyWiz.mu.Unlock()
h.keyWizFlash(w, r, "Установка прервана: "+msg)
return
}
if p, ok := res["profile"].(string); ok {
h.keyWiz.Profile = p
}
if b, ok := res["backup"].(string); ok {
h.keyWiz.Backup = b
}
tgt, _ := res["target"].(string)
spr, _ := res["spr"].(string)
h.keyWiz.set(1, "ok", fmt.Sprintf("Профиль «%s» записан на %s. Бэкап: %s", h.keyWiz.Profile, fallback(tgt, "носитель"), h.keyWiz.Backup))
crl, _ := res["crl"].(string)
h.keyWiz.set(2, "ok", fmt.Sprintf("Справочник «%s» сформирован. CRL: %s", fallback(spr, "—"), crlRu(crl)))
h.keyWiz.set(3, "active", "")
h.keyWiz.mu.Unlock()
// Шаг 4: перезапуск и проверка ИШ. Перезапуск VDCrySvc мог сбросить
// активацию серверного профиля bj-crypto — восстанавливаем. Затем
// перезапускаем ИШ и проверяем engine/state: ИШ поднялся с новым ключом.
h.reactivateCryptoProfile(ctx)
ishOK, ishMsg := h.restartAndVerifyISH(ctx)
h.keyWiz.mu.Lock()
if !ishOK {
h.keyWiz.set(3, "error", "ИШ не подтвердил готовность: "+ishMsg)
h.keyWiz.mu.Unlock()
h.keyWizFlash(w, r, "Ключ записан, но ИШ не готов: "+ishMsg)
return
}
h.keyWiz.set(3, "ok", "ИШ перезапущен и работает: "+ishMsg)
h.keyWiz.set(4, "ok", "Теперь подпишите новым ключом — отправьте тестовый документ роботу НРД ниже")
h.keyWiz.Done = true
h.keyWiz.mu.Unlock()
http.Redirect(w, r, "/admin/setup/keywizard?flash="+urlQ("Готово! Ключ на флешке, справочник сформирован, ИШ перезапущен и работает. Финальная проверка — тестовым документом роботу."), http.StatusSeeOther)
}
// restartAndVerifyISH перезапускает Интеграционный шлюз и проверяет, что он
// поднялся (engine/state). Возвращает (ok, сообщение).
func (h *setupHandlers) restartAndVerifyISH(ctx context.Context) (bool, string) {
// Перезапуск igate через привилегированный воркер (bj не sudoer).
res, err := runKeymediaHelper(ctx, "restart-ish", "/var/lib/bj/media/iso", "", "", "")
if err != nil || res["ok"] != true {
// restart-ish может быть не поддержан — не критично, проверим состояние.
_ = err
}
// Проверяем состояние ИШ через nsd-адаптер (engine/state).
deadline := time.Now().Add(40 * time.Second)
for time.Now().Before(deadline) {
if st := h.ishEngineState(ctx); st != "" {
return true, "engine "+st
}
select {
case <-ctx.Done():
return false, "таймаут"
case <-time.After(3 * time.Second):
}
}
return false, "ИШ не ответил на /api/admin/engine/state за 40 сек"
}
// ishEngineState запрашивает состояние движка ИШ; пусто если недоступен.
func (h *setupHandlers) ishEngineState(ctx context.Context) string {
s := h.rc.Snapshot()
base := s.NSD.IGWBaseURL
if base == "" {
return ""
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(base, "/")+"/api/admin/engine/state", nil)
if err != nil {
return ""
}
cl := &http.Client{Timeout: 5 * time.Second}
resp, err := cl.Do(req)
if err != nil {
return ""
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return ""
}
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return strings.TrimSpace(string(b))
}
func crlRu(s string) string {
switch s {
case "updated":
return "обновлены из точек распространения"
case "failed":
return "не удалось обновить (проверьте сеть/CDP)"
case "skip":
return "пропущено"
default:
return s
}
}
// reactivateCryptoProfile повторно активирует текущий серверный профиль
// bj-crypto (после перезапуска VDCrySvc активация в сайдкаре сбрасывается).
// Best-effort: возвращает true при успехе.
func (h *setupHandlers) reactivateCryptoProfile(ctx context.Context) bool {
s := h.rc.Snapshot()
if s.Crypto.Profile == "" {
return false
}
cli := cryptocli.New(cryptocli.Config{
Provider: cryptocli.Provider(s.Crypto.Provider),
SocketPath: s.Crypto.SocketPath,
})
defer cli.Close()
res, err := cli.Activate(ctx, s.Crypto.Profile)
return err == nil && res.OK
}
// vdcrysvcActive проверяет, что демон Валидаты (vdmkdev) запущен.
func vdcrysvcActive() bool {
out, _ := exec.Command("systemctl", "is-active", "vdmkdev.service").Output()
return strings.TrimSpace(string(out)) == "active"
}
func boolRu(b bool, yes, no string) string {
if b {
return yes
}
return no
}
const keymediaReqDir = "/var/lib/bj/keymedia-requests"
// runKeymediaHelper передаёт запрос привилегированному воркеру через файловый
// обмен: bj-server (в песочнице) пишет <id>.req, root-сервис bj-keymedia
// (host namespace, триггерится bj-keymedia.path) выполняет операцию с флешкой
// и пишет <id>.res. bj-server опрашивает результат. Так привилегированная
// работа идёт вне mount-namespace песочницы, где доступно перемонтирование USB.
func runKeymediaHelper(ctx context.Context, action, staging, profile, targetDev, targetMnt string) (map[string]any, error) {
id := fmt.Sprintf("%s-%d", action, time.Now().UnixNano())
reqPath := filepath.Join(keymediaReqDir, id+".req")
resPath := filepath.Join(keymediaReqDir, id+".res")
defer os.Remove(resPath)
reqBody, _ := json.Marshal(map[string]string{
"action": action, "staging": staging, "profile": profile,
"target_dev": targetDev, "target_mnt": targetMnt,
})
// Пишем атомарно (tmp → rename), чтобы .path не подхватил полупустой файл.
tmp := reqPath + ".tmp"
if err := os.WriteFile(tmp, reqBody, 0o660); err != nil {
return nil, fmt.Errorf("запись запроса: %w", err)
}
if err := os.Rename(tmp, reqPath); err != nil {
return nil, fmt.Errorf("публикация запроса: %w", err)
}
// Опрос результата.
ticker := time.NewTicker(200 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return nil, fmt.Errorf("таймаут ожидания воркера установки ключа")
case <-ticker.C:
b, err := os.ReadFile(resPath)
if err != nil {
continue // ещё не готово
}
res := map[string]any{}
if jerr := json.Unmarshal(b, &res); jerr != nil {
return nil, fmt.Errorf("разбор ответа воркера: %v (%s)", jerr, strings.TrimSpace(string(b)))
}
if res["ok"] != true {
msg, _ := res["error"].(string)
return res, fmt.Errorf("воркер: %s", fallback(msg, "ошибка"))
}
return res, nil
}
}
}
func (h *setupHandlers) keyWizFlash(w http.ResponseWriter, r *http.Request, msg string) {
http.Redirect(w, r, "/admin/setup/keywizard?flash="+urlQ(msg), http.StatusSeeOther)
}
func fallback(s, def string) string {
if s == "" {
return def
}
return s
}
func yesNo(v any) string {
if b, ok := v.(bool); ok && b {
return "да"
}
return "нет"
}
func errStr(err error) string {
if err == nil {
return "неизвестная ошибка"
}
return err.Error()
}
-79
View File
@@ -1,79 +0,0 @@
package lkgateway
import (
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/license"
)
// DefaultLicensePublicKey — публичный ключ лицензий, зашитый в релиз.
// Пустой в исходниках; подставляется при официальной сборке. Если задан в
// настройках (LicenseSettings.PublicKey) — приоритет у настроек.
var DefaultLicensePublicKey = ""
// LicenseStatus — сводка состояния лицензии для UI и гейтов.
type LicenseStatus struct {
Present bool // ключ введён
Valid bool // подпись верна и срок не истёк
Tenant string
Plan string
ExpiresAt time.Time
DaysLeft int
AllowsUpdates bool
Message string
}
// licensingEnabled — включено ли лицензирование (есть публичный ключ для
// проверки). Если ключа нет вовсе — продукт в открытом режиме, гейты не
// действуют (удобно для разработки и бесплатной редакции).
func licensingEnabled(rc *RuntimeConfig) bool {
return rc.Snapshot().License.PublicKey != "" || DefaultLicensePublicKey != ""
}
// licenseStatus разбирает и проверяет лицензию из настроек.
func licenseStatus(rc *RuntimeConfig) LicenseStatus {
s := rc.Snapshot().License
st := LicenseStatus{}
if s.Key == "" {
st.Message = "лицензионный ключ не введён"
return st
}
st.Present = true
pubB64 := s.PublicKey
if pubB64 == "" {
pubB64 = DefaultLicensePublicKey
}
if pubB64 == "" {
st.Message = "нет публичного ключа для проверки лицензии"
return st
}
pub, err := license.ParsePublicKey(pubB64)
if err != nil {
st.Message = "неверный публичный ключ: " + err.Error()
return st
}
tok, err := license.DecodeToken(s.Key)
if err != nil {
st.Message = "неверный формат ключа: " + err.Error()
return st
}
lic, err := license.Verify(tok, pub)
if err != nil {
st.Message = "подпись лицензии недействительна"
return st
}
now := time.Now().UTC()
st.Tenant = lic.Tenant
st.Plan = string(lic.Plan)
st.ExpiresAt = lic.ExpiresAt
st.DaysLeft = lic.DaysLeft(now)
st.AllowsUpdates = lic.AllowsUpdates()
if err := lic.Valid(now); err != nil {
st.Message = err.Error()
return st
}
st.Valid = true
st.Message = "активна до " + lic.ExpiresAt.Format("02.01.2006")
return st
}
-745
View File
@@ -1,745 +0,0 @@
package lkgateway
import (
"context"
"crypto/sha256"
"crypto/x509"
"encoding/asn1"
"encoding/hex"
"encoding/pem"
"errors"
"fmt"
"os"
"os/exec"
"os/user"
"path/filepath"
"strings"
"time"
)
// MediaRoot — где bj-server хранит свои носители: распакованные ISO,
// импортированные ключевые контейнеры. На прод-машине пользователь bj
// должен быть владельцем этой директории (создаётся install.sh).
const (
mediaRoot = "/var/lib/bj/media"
mediaISODir = "/var/lib/bj/media/iso"
containersDir = "/var/lib/bj/containers"
profilesDir = "/var/lib/bj/profiles"
keyFileMinPerDir = 2 // считаем директорию контейнером, если в ней >= 2 *.key файлов
)
// Medium — один носитель: USB-флешка или распакованная ISO.
type Medium struct {
// ID — стабильный идентификатор (для USB — sha1 от пути монтирования,
// для ISO — sha256-prefix от исходного файла).
ID string `json:"id"`
// Kind — "usb" или "iso".
Kind string `json:"kind"`
// Mountpoint — корень, по которому сейчас доступен носитель.
Mountpoint string `json:"mountpoint"`
// Source — для ISO: путь до исходного .iso на сервере.
Source string `json:"source,omitempty"`
// Profile — полный профиль Валидаты (pse + gdbm + vdkeys), если найден.
Profile *ValidataProfile `json:"profile,omitempty"`
// Containers — найденные ключевые контейнеры (директории с *.key/*.vdk).
Containers []KeyContainer `json:"containers"`
// Certificates — отдельно лежащие сертификаты (.cer/.crt/.pem/.pfx/.p12).
Certificates []CertFile `json:"certificates"`
}
// ValidataProfile — полный профиль АПК «Валидата Клиент L»: ПСП (.pse),
// ЛСП (.gdbm) и ключи (vdkeys/*.vdk).
type ValidataProfile struct {
Root string `json:"root"` // mountpoint, где найден профиль
PSEFiles []string `json:"pse_files"` // относительные пути до .pse
GDBMFiles []string `json:"gdbm_files"` // относительные пути до .gdbm
KeyFiles []string `json:"key_files"` // относительные пути до .vdk
Imported bool `json:"imported"` // уже скопирован в /var/lib/bj/profiles/
}
// KeyContainer — ключевой контейнер: директория с *.key или *.vdk.
type KeyContainer struct {
Path string `json:"path"`
Name string `json:"name"` // имя последней компоненты пути
Files []string `json:"files"` // имена файлов в контейнере
Imported bool `json:"imported"` // уже скопирован в /var/lib/bj/containers/
}
// CertFile — публичный или PKCS#12 сертификат.
type CertFile struct {
Path string `json:"path"`
Name string `json:"name"`
Format string `json:"format"` // "cer" | "pem" | "pfx"
SubjectCN string `json:"subject_cn"`
IssuerCN string `json:"issuer_cn"`
Serial string `json:"serial"`
NotBefore time.Time `json:"not_before"`
NotAfter time.Time `json:"not_after"`
INN string `json:"inn,omitempty"`
HasPrivateKey bool `json:"has_private_key"` // true для .pfx/.p12
ParseError string `json:"parse_error,omitempty"`
}
// ScanMedia собирает список всех видимых носителей: USB + распакованные
// ISO. Безопасна для частых вызовов — IO ограничен директориями верхнего
// уровня в типичных mount-точках.
func ScanMedia() []Medium {
var out []Medium
out = append(out, scanUSB()...)
out = append(out, listExtractedISOs()...)
return out
}
// scanUSB ищет USB-монтирования в /run/media/$USER, /media/$USER, /media, /mnt.
func scanUSB() []Medium {
u, err := user.Current()
if err != nil {
return nil
}
roots := []string{
filepath.Join("/run/media", u.Username),
filepath.Join("/media", u.Username),
"/media",
"/mnt",
}
var out []Medium
seen := map[string]bool{}
for _, root := range roots {
entries, err := os.ReadDir(root)
if err != nil {
continue
}
for _, e := range entries {
if !e.IsDir() {
continue
}
mountpoint := filepath.Join(root, e.Name())
// Не лезем в наши собственные /var/lib/bj/media/iso/*.
if strings.HasPrefix(mountpoint, mediaISODir) {
continue
}
if seen[mountpoint] {
continue
}
seen[mountpoint] = true
out = append(out, scanMountpoint("usb", mountpoint, ""))
}
}
return out
}
// listExtractedISOs возвращает все ранее распакованные ISO в /var/lib/bj/media/iso/.
func listExtractedISOs() []Medium {
entries, err := os.ReadDir(mediaISODir)
if err != nil {
return nil
}
var out []Medium
for _, e := range entries {
if !e.IsDir() {
continue
}
id := e.Name()
mountpoint := filepath.Join(mediaISODir, id)
source := readISOSource(id)
m := scanMountpoint("iso", mountpoint, source)
m.ID = id
out = append(out, m)
}
return out
}
// scanMountpoint сканирует точку монтирования на 3 уровня вглубь.
func scanMountpoint(kind, mountpoint, source string) Medium {
m := Medium{
ID: sha1Path(mountpoint),
Kind: kind,
Mountpoint: mountpoint,
Source: source,
}
containers, certs, profile := walkForArtifacts(mountpoint)
m.Containers = containers
m.Certificates = certs
m.Profile = profile
// Отмечаем контейнеры, уже импортированные в /var/lib/bj/containers/.
for i := range m.Containers {
if _, err := os.Stat(filepath.Join(containersDir, m.Containers[i].Name)); err == nil {
m.Containers[i].Imported = true
}
}
// Профиль помечается импортированным, если в /var/lib/bj/profiles/
// есть директория с тем же именем (имя берётся от носителя).
if m.Profile != nil {
name := filepath.Base(mountpoint)
if _, err := os.Stat(filepath.Join(profilesDir, name)); err == nil {
m.Profile.Imported = true
}
}
return m
}
// walkForArtifacts проходит дерево mountpoint (до 3 уровней) и собирает:
// - директории-контейнеры (>=2 *.key или >=1 *.vdk файла);
// - отдельные сертификаты (.cer/.pfx/...);
// - полный профиль Валидаты (наличие *.pse + *.gdbm + *.vdk в дереве).
func walkForArtifacts(root string) ([]KeyContainer, []CertFile, *ValidataProfile) {
var containers []KeyContainer
var certs []CertFile
prof := &ValidataProfile{Root: root}
_ = filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
rel, _ := filepath.Rel(root, p)
depth := strings.Count(rel, string(filepath.Separator))
if depth > 4 {
return filepath.SkipDir
}
if info.IsDir() {
if p != root {
if c, ok := classifyContainer(p); ok {
containers = append(containers, c)
// НЕ делаем SkipDir: внутри vdkeys/ нужно собрать
// .vdk-файлы для определения профиля Валидаты.
}
}
return nil
}
lower := strings.ToLower(info.Name())
switch {
case strings.HasSuffix(lower, ".pse"):
prof.PSEFiles = append(prof.PSEFiles, rel)
case strings.HasSuffix(lower, ".gdbm"):
prof.GDBMFiles = append(prof.GDBMFiles, rel)
case strings.HasSuffix(lower, ".vdk"):
prof.KeyFiles = append(prof.KeyFiles, rel)
default:
if cert := classifyCertFile(p); cert != nil {
certs = append(certs, *cert)
}
}
return nil
})
// Профилем считаем носитель если есть и pse, и vdk (gdbm
// опционален — но обычно тоже присутствует).
if len(prof.PSEFiles) == 0 || len(prof.KeyFiles) == 0 {
prof = nil
}
return containers, certs, prof
}
// classifyContainer — директория является ключевым контейнером, если:
// - в ней >=2 файлов *.key (старый формат КриптоПро/Валидата); или
// - в ней >=1 файл *.vdk (Валидата Linux).
func classifyContainer(dir string) (KeyContainer, bool) {
entries, err := os.ReadDir(dir)
if err != nil {
return KeyContainer{}, false
}
var keyFiles, vdkFiles, allFiles []string
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
allFiles = append(allFiles, name)
lower := strings.ToLower(name)
switch {
case strings.HasSuffix(lower, ".vdk"):
vdkFiles = append(vdkFiles, name)
case strings.HasSuffix(lower, ".key"):
keyFiles = append(keyFiles, name)
}
}
if len(vdkFiles) == 0 && len(keyFiles) < keyFileMinPerDir {
return KeyContainer{}, false
}
return KeyContainer{
Path: dir,
Name: filepath.Base(dir),
Files: allFiles,
}, true
}
// classifyCertFile парсит один файл — возвращает CertFile если это
// похоже на сертификат.
func classifyCertFile(path string) *CertFile {
lower := strings.ToLower(path)
var format string
switch {
case strings.HasSuffix(lower, ".cer"), strings.HasSuffix(lower, ".crt"):
format = "cer"
case strings.HasSuffix(lower, ".pem"):
format = "pem"
case strings.HasSuffix(lower, ".pfx"), strings.HasSuffix(lower, ".p12"):
format = "pfx"
default:
return nil
}
cf := &CertFile{
Path: path,
Name: filepath.Base(path),
Format: format,
}
if format == "pfx" {
// PKCS#12 шифрован PIN'ом — мета без него не вытащить.
cf.HasPrivateKey = true
return cf
}
data, err := os.ReadFile(path)
if err != nil {
cf.ParseError = "read: " + err.Error()
return cf
}
if len(data) > 32*1024 {
// Странно большой файл для сертификата — режем.
data = data[:32*1024]
}
der := data
if block, _ := pem.Decode(data); block != nil && block.Type == "CERTIFICATE" {
der = block.Bytes
}
cert, err := x509.ParseCertificate(der)
if err != nil {
cf.ParseError = "x509: " + err.Error()
return cf
}
cf.SubjectCN = cert.Subject.CommonName
cf.IssuerCN = cert.Issuer.CommonName
cf.Serial = cert.SerialNumber.Text(16)
cf.NotBefore = cert.NotBefore
cf.NotAfter = cert.NotAfter
cf.INN = extractCertINN(cert)
return cf
}
// extractCertINN — ИНН из OID 1.2.643.3.131.1.1 в Subject.
func extractCertINN(c *x509.Certificate) string {
innOID := asn1.ObjectIdentifier{1, 2, 643, 3, 131, 1, 1}
for _, name := range c.Subject.Names {
if name.Type.Equal(innOID) {
if s, ok := name.Value.(string); ok {
return s
}
}
}
return ""
}
// ExtractISO распаковывает образ диска (.iso/.img/.zip и т.п.) в
// /var/lib/bj/media/iso/<id>/ через 7z. password — опциональный пароль
// архива (пустая строка = без пароля). id — sha256-prefix от исходного
// пути. Возвращает Medium или ошибку.
func ExtractISO(ctx context.Context, isoPath, password string) (Medium, error) {
abs, err := filepath.Abs(isoPath)
if err != nil {
return Medium{}, fmt.Errorf("ISO путь: %w", err)
}
info, err := os.Stat(abs)
if err != nil {
return Medium{}, fmt.Errorf("ISO не найден: %w", err)
}
if info.IsDir() {
return Medium{}, errors.New("ISO путь — это директория, нужен файл")
}
id := isoID(abs)
dst := filepath.Join(mediaISODir, id)
if err := os.MkdirAll(dst, 0o755); err != nil {
return Medium{}, fmt.Errorf("создать %s: %w", dst, err)
}
if isEmpty, _ := dirEmpty(dst); !isEmpty {
// Уже распакован раньше — просто пересканируем.
writeISOSource(id, abs)
m := scanMountpoint("iso", dst, abs)
m.ID = id
return m, nil
}
cctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()
// 7z x -y -o<dst> [-p<pass>] <archive> — рекурсивное извлечение.
args := []string{"x", "-y", "-o" + dst}
if password != "" {
// 7z требует пароль через -p без пробела.
args = append(args, "-p"+password)
} else {
// -p- запрещает интерактивный запрос пароля (нам нечего вводить).
args = append(args, "-p-")
}
args = append(args, abs)
cmd := exec.CommandContext(cctx, "7z", args...)
out, err := cmd.CombinedOutput()
if err != nil {
_ = os.RemoveAll(dst)
return Medium{}, fmt.Errorf("7z x: %w / %s", err, strings.TrimSpace(string(out)))
}
writeISOSource(id, abs)
m := scanMountpoint("iso", dst, abs)
m.ID = id
return m, nil
}
// UnmountISO удаляет всё, что относится к загруженному образу:
// - распакованную директорию /var/lib/bj/media/iso/<id>/;
// - .src-meta файл с записанным источником;
// - сам исходный .img/.iso в /var/lib/bj/iso/, если он находится
// в наших границах (защита: путь должен начинаться с /var/lib/bj/iso/).
//
// Безопасно только для тех id, что лежат в нашем mediaISODir.
func UnmountISO(id string) error {
if strings.ContainsAny(id, "/.") {
return errors.New("неверный id")
}
dst := filepath.Join(mediaISODir, id)
if !strings.HasPrefix(dst, mediaISODir+"/") {
return errors.New("путь вне media-root")
}
// Сначала забираем путь исходника, потом удаляем .src.
src := readISOSource(id)
if err := os.RemoveAll(dst); err != nil {
return err
}
_ = os.Remove(filepath.Join(mediaISODir, id+".src"))
// Если запись об источнике существовала и путь — внутри /var/lib/bj/iso/,
// удаляем и сам файл .img/.iso.
if src != "" {
abs, _ := filepath.Abs(src)
if strings.HasPrefix(abs, "/var/lib/bj/iso/") {
_ = os.Remove(abs)
}
}
return nil
}
// ImportKeyContainer копирует контейнер в /var/lib/bj/containers/<name>/.
// Возвращает целевой путь.
func ImportKeyContainer(src string) (string, error) {
info, err := os.Stat(src)
if err != nil {
return "", fmt.Errorf("источник: %w", err)
}
if !info.IsDir() {
return "", errors.New("источник не директория")
}
if _, ok := classifyContainer(src); !ok {
return "", errors.New("в директории не найдено >=2 файлов *.key — не похоже на контейнер")
}
if err := os.MkdirAll(containersDir, 0o755); err != nil {
return "", fmt.Errorf("создать %s: %w", containersDir, err)
}
name := filepath.Base(src)
dst := filepath.Join(containersDir, name)
if _, err := os.Stat(dst); err == nil {
return "", fmt.Errorf("контейнер %q уже импортирован", name)
}
if err := os.MkdirAll(dst, 0o700); err != nil {
return "", fmt.Errorf("создать %s: %w", dst, err)
}
entries, err := os.ReadDir(src)
if err != nil {
return "", err
}
for _, e := range entries {
if e.IsDir() {
continue
}
sp := filepath.Join(src, e.Name())
dp := filepath.Join(dst, e.Name())
data, err := os.ReadFile(sp)
if err != nil {
return "", fmt.Errorf("чтение %s: %w", e.Name(), err)
}
if err := os.WriteFile(dp, data, 0o600); err != nil {
return "", fmt.Errorf("запись %s: %w", e.Name(), err)
}
}
return dst, nil
}
// ImportProfileResult — результат импорта профиля Валидаты.
type ImportProfileResult struct {
Path string // /var/lib/bj/profiles/<name>/
Pki1ConfSection string // готовая секция для pki1.conf
ConfWritten bool // удалось ли дописать в /opt/Validata/VDCSP/etc/pki1.conf
ConfWriteError string // если не удалось — причина
}
const validataPki1Conf = "/opt/Validata/VDCSP/etc/pki1.conf"
// ImportProfile копирует профиль Валидаты (pse/gdbm/vdkeys) в
// /var/lib/bj/profiles/<name>/, генерирует секцию для pki1.conf и
// пробует дописать её в системный конфиг Валидаты. Имя берётся от
// носителя, если name пуст. Возвращает деталь — что получилось.
func ImportProfile(root, name string) (ImportProfileResult, error) {
if name == "" {
name = filepath.Base(root)
}
if !validProfileName(name) {
return ImportProfileResult{}, errors.New("имя профиля: допустимы латинские буквы, цифры, '-' и '_'")
}
if err := os.MkdirAll(profilesDir, 0o755); err != nil {
return ImportProfileResult{}, fmt.Errorf("создать %s: %w", profilesDir, err)
}
dst := filepath.Join(profilesDir, name)
if _, err := os.Stat(dst); err == nil {
return ImportProfileResult{}, fmt.Errorf("профиль %q уже импортирован", name)
}
if err := copyTree(root, dst); err != nil {
_ = os.RemoveAll(dst)
return ImportProfileResult{}, err
}
// Ищем фактический pse и gdbm внутри импортированной папки —
// обычно spr*/local.pse + spr*/local.gdbm.
psePath, gdbmPath := findProfileFiles(dst)
if psePath == "" {
return ImportProfileResult{}, errors.New("после копирования не найден .pse — формат профиля нестандартный")
}
section := buildPki1ConfSection(name, psePath, gdbmPath)
// Сохраняем секцию рядом с профилем — чтобы оператор мог
// посмотреть/перечитать.
_ = os.WriteFile(filepath.Join(dst, "pki1.conf-section.txt"),
[]byte(section), 0o644)
res := ImportProfileResult{
Path: dst,
Pki1ConfSection: section,
}
// Пробуем дописать в pki1.conf — если файл доступен на запись.
if err := appendToPki1Conf(name, section); err != nil {
res.ConfWriteError = err.Error()
} else {
res.ConfWritten = true
}
return res, nil
}
func validProfileName(s string) bool {
if s == "" || len(s) > 64 {
return false
}
for _, r := range s {
ok := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') || r == '-' || r == '_'
if !ok {
return false
}
}
return true
}
// findProfileFiles ищет .pse и .gdbm внутри директории профиля.
// Возвращает абсолютные пути или пустые строки.
func findProfileFiles(dir string) (psePath, gdbmPath string) {
_ = filepath.Walk(dir, func(p string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
lower := strings.ToLower(info.Name())
if psePath == "" && strings.HasSuffix(lower, ".pse") {
psePath = p
}
if gdbmPath == "" && strings.HasSuffix(lower, ".gdbm") {
gdbmPath = p
}
return nil
})
return
}
// buildPki1ConfSection формирует блок pki1.conf для нашего профиля.
func buildPki1ConfSection(name, psePath, gdbmPath string) string {
var b strings.Builder
b.WriteString("\n# --- bj-server: профиль " + name + " ---\n")
b.WriteString("local: " + name + "\n")
b.WriteString("pse: pse://signed/" + psePath + "\n")
if gdbmPath != "" {
b.WriteString("localstore: file://" + gdbmPath + "\n")
}
return b.String()
}
// appendToPki1Conf пишет секцию в системный pki1.conf, если процесс
// имеет права. Возвращает ошибку при отсутствии прав или I/O-сбое.
// Дедуп — если в файле уже есть блок с тем же `local: <name>`, не
// пишем повторно.
func appendToPki1Conf(name, section string) error {
existing, err := os.ReadFile(validataPki1Conf)
if err != nil {
return fmt.Errorf("read %s: %w", validataPki1Conf, err)
}
marker := "local: " + name
if strings.Contains(string(existing), marker) {
return fmt.Errorf("в pki1.conf уже есть секция %q — пропускаем", name)
}
f, err := os.OpenFile(validataPki1Conf, os.O_WRONLY|os.O_APPEND, 0)
if err != nil {
return fmt.Errorf("open %s: %w", validataPki1Conf, err)
}
defer f.Close()
if _, err := f.WriteString(section); err != nil {
return fmt.Errorf("write %s: %w", validataPki1Conf, err)
}
return nil
}
// copyTree рекурсивно копирует src в dst, сохраняя структуру директорий.
// Права на новые директории — 0700, на файлы — 0600 (приватные ключи).
func copyTree(src, dst string) error {
return filepath.Walk(src, func(p string, info os.FileInfo, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(src, p)
if err != nil {
return err
}
target := filepath.Join(dst, rel)
if info.IsDir() {
return os.MkdirAll(target, 0o700)
}
data, err := os.ReadFile(p)
if err != nil {
return err
}
return os.WriteFile(target, data, 0o600)
})
}
// DeleteImportedContainer сносит /var/lib/bj/containers/<name>/.
func DeleteImportedContainer(name string) error {
if !validProfileName(name) {
return errors.New("неверное имя контейнера")
}
dir := filepath.Join(containersDir, name)
if _, err := os.Stat(dir); err != nil {
return fmt.Errorf("контейнер не найден: %w", err)
}
return os.RemoveAll(dir)
}
// DeleteImportedProfile сносит и директорию профиля
// /var/lib/bj/profiles/<name>/, и связанную секцию из pki1.conf
// (между маркерами «# --- bj-server: профиль <name> ---» и следующим
// «# --- bj-server: ...» или концом файла).
func DeleteImportedProfile(name string) error {
if !validProfileName(name) {
return errors.New("неверное имя профиля")
}
dir := filepath.Join(profilesDir, name)
if _, err := os.Stat(dir); err != nil {
return fmt.Errorf("профиль не найден: %w", err)
}
if err := os.RemoveAll(dir); err != nil {
return fmt.Errorf("удалить %s: %w", dir, err)
}
// Чистим секцию в pki1.conf — best effort, если файл недоступен на
// запись, профиль всё равно удалён, но в конфиге останется огрызок.
if err := removeFromPki1Conf(name); err != nil {
return fmt.Errorf("директория удалена, но pki1.conf не почистился: %w", err)
}
return nil
}
// removeFromPki1Conf удаляет блок профиля из pki1.conf.
// Блок начинается с «# --- bj-server: профиль <name> ---» и кончается
// перед следующим таким маркером или до конца файла. Если блок не
// найден — успех (idempotent).
func removeFromPki1Conf(name string) error {
data, err := os.ReadFile(validataPki1Conf)
if err != nil {
return err
}
startMarker := "# --- bj-server: профиль " + name + " ---"
startIdx := strings.Index(string(data), startMarker)
if startIdx < 0 {
return nil
}
// Найдём конец блока: следующий маркер «# --- bj-server: профиль» или EOF.
rest := string(data)[startIdx+len(startMarker):]
endRel := strings.Index(rest, "# --- bj-server: профиль ")
var newContent string
if endRel < 0 {
newContent = string(data)[:startIdx]
} else {
newContent = string(data)[:startIdx] + rest[endRel:]
}
// Убираем хвостовые пустые строки от секции.
newContent = strings.TrimRight(newContent, "\n") + "\n"
return os.WriteFile(validataPki1Conf, []byte(newContent), 0o644)
}
// ListImportedProfiles возвращает имена директорий в /var/lib/bj/profiles/.
func ListImportedProfiles() []string {
entries, err := os.ReadDir(profilesDir)
if err != nil {
return nil
}
var out []string
for _, e := range entries {
if e.IsDir() {
out = append(out, e.Name())
}
}
return out
}
// ListImportedContainers возвращает уже импортированные контейнеры.
func ListImportedContainers() []KeyContainer {
entries, err := os.ReadDir(containersDir)
if err != nil {
return nil
}
var out []KeyContainer
for _, e := range entries {
if !e.IsDir() {
continue
}
dir := filepath.Join(containersDir, e.Name())
if c, ok := classifyContainer(dir); ok {
c.Imported = true
out = append(out, c)
}
}
return out
}
func isoID(absPath string) string {
h := sha256.Sum256([]byte(absPath))
return hex.EncodeToString(h[:8])
}
func sha1Path(s string) string {
h := sha256.Sum256([]byte(s))
return hex.EncodeToString(h[:6])
}
func dirEmpty(path string) (bool, error) {
f, err := os.Open(path)
if err != nil {
return false, err
}
defer f.Close()
names, err := f.Readdirnames(1)
if errors.Is(err, os.ErrInvalid) || err != nil {
return len(names) == 0, nil
}
return len(names) == 0, nil
}
func readISOSource(id string) string {
data, err := os.ReadFile(filepath.Join(mediaISODir, id+".src"))
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}
func writeISOSource(id, src string) {
_ = os.WriteFile(filepath.Join(mediaISODir, id+".src"), []byte(src), 0o644)
}
-185
View File
@@ -1,185 +0,0 @@
package lkgateway
import (
"context"
"encoding/json"
"log"
"os"
"strings"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/igw"
)
// pollerStateFile — постоянная память id применённых входящих пакетов ИШ.
// Без неё перезапуск bj-server заново вычитывал бы старые ответы НРД и
// повторно применял их к заявкам (для ответов с нулевым GUID это приводило
// к ложным «отклонениям» по FIFO). Файл переживает перезапуски.
const pollerStateFile = "/var/lib/bj/.bj/poller-processed.json"
// loadProcessed читает множество обработанных id; второй результат false,
// если файла нет (самый первый запуск).
func loadProcessed() (map[int]bool, bool) {
m := make(map[int]bool)
b, err := os.ReadFile(pollerStateFile)
if err != nil {
return m, false
}
var ids []int
if json.Unmarshal(b, &ids) != nil {
return m, false
}
for _, id := range ids {
m[id] = true
}
return m, true
}
// saveProcessed атомарно сохраняет множество обработанных id.
func saveProcessed(m map[int]bool) {
ids := make([]int, 0, len(m))
for id := range m {
ids = append(ids, id)
}
b, err := json.Marshal(ids)
if err != nil {
return
}
tmp := pollerStateFile + ".tmp"
if os.WriteFile(tmp, b, 0o640) == nil {
_ = os.Rename(tmp, pollerStateFile)
}
}
// pollIncoming периодически опрашивает ИШ на входящие пакеты от НРД
// (M2MTransferDecision / Response) и применяет их через svc.ApplyDecision.
// Замыкает цикл: bj-server отправил заявку → ИШ → НРД → робот ответил →
// ИШ забрал ответ во входящие → этот поллер применяет Decision (статус
// заявки переходит в confirmed/rejected, срабатывает callback в ЛК).
//
// Дедупликация по id обработанных пакетов: ИШ возвращает их повторно,
// пока мы не подтвердим, поэтому держим множество уже обработанных.
func (s *Server) pollIncoming(ctx context.Context) {
const interval = 30 * time.Second
ticker := time.NewTicker(interval)
defer ticker.Stop()
processed, existed := loadProcessed()
if !existed {
// Первый запуск: помечаем все уже лежащие во входящих пакеты как
// обработанные, чтобы не применять исторический backlog (старые ответы
// НРД) к текущим заявкам. Реальные новые ответы придут позже.
s.seedProcessed(ctx, processed)
saveProcessed(processed)
}
log.Printf("lk-gateway: поллер входящих ИШ запущен (канал %s, интервал %s, обработанных в памяти %d)", s.igwChannel, interval, len(processed))
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.fetchAndApply(ctx, processed)
}
}
}
// fetchAndApply — один проход поллера: список входящих → для каждого нового
// забираем тело, распаковываем, парсим Decision, применяем.
func (s *Server) fetchAndApply(ctx context.Context, processed map[int]bool) {
cctx, cancel := context.WithTimeout(ctx, 25*time.Second)
defer cancel()
// Тип не указываем — ИШ вернёт оба (M2MTD + M2MER). Date=сегодня.
pkgs, err := s.igwClient.ListIncoming(cctx, igw.ListFilter{
Channel: s.igwChannel,
Date: time.Now(),
})
if err != nil {
log.Printf("lk-gateway: поллер ListIncoming: %v", err)
return
}
for _, p := range pkgs {
if processed[p.ID] {
continue
}
if err := s.applyIncoming(cctx, p); err != nil {
log.Printf("lk-gateway: поллер пакет id=%d (%s): %v", p.ID, p.Type, err)
continue // не помечаем обработанным — повторим в след. раз
}
processed[p.ID] = true
saveProcessed(processed) // переживает перезапуск
log.Printf("lk-gateway: поллер применил входящий пакет id=%d тип=%s", p.ID, p.Type)
}
}
// seedProcessed помечает все текущие входящие пакеты как обработанные
// (используется при самом первом запуске поллера).
func (s *Server) seedProcessed(ctx context.Context, processed map[int]bool) {
cctx, cancel := context.WithTimeout(ctx, 25*time.Second)
defer cancel()
pkgs, err := s.igwClient.ListIncoming(cctx, igw.ListFilter{Channel: s.igwChannel, Date: time.Now()})
if err != nil {
log.Printf("lk-gateway: поллер seed: ListIncoming: %v", err)
return
}
for _, p := range pkgs {
processed[p.ID] = true
}
log.Printf("lk-gateway: поллер первый запуск — засеяно %d существующих пакетов как обработанные", len(pkgs))
}
// applyIncoming забирает тело пакета и применяет M2M-ответ к сделке.
// Среди входящих от НРД много служебных пакетов (квитанции ЭДО типа C/Z,
// конверты) — они не M2M-документы и пропускаются. Реальные ответы —
// M2MTransferDecision (решение принимающей стороны) и M2MTransferResponse
// (ответ сервиса МОСТ, в т.ч. ошибки M2Mxx).
func (s *Server) applyIncoming(ctx context.Context, p igw.Package) error {
zipBytes, err := s.igwClient.GetPackage(ctx, p.ID)
if err != nil {
return err // сетевая ошибка — повторим в след. раз
}
unpacked, err := igw.UnpackPackage(zipBytes)
if err != nil {
// Нет основного .xml — служебный пакет (квитанция/конверт ЭДО).
// Не ошибка: помечаем обработанным, чтобы не повторять.
log.Printf("lk-gateway: поллер пакет id=%d (%s) — служебный (квитанция/конверт), пропуск", p.ID, p.Type)
return nil
}
doc := string(unpacked.DocXML)
switch {
case strings.Contains(doc, "M2MTransferDecision"):
decision, err := igw.ParseDecision(unpacked.DocXML)
if err != nil {
log.Printf("lk-gateway: поллер Decision id=%d: разбор: %v", p.ID, err)
return nil
}
return s.svc.ApplyDecision(ctx, decision)
case strings.Contains(doc, "M2MTransferResponse"):
resp, err := igw.ParseResponse(unpacked.DocXML)
if err != nil {
log.Printf("lk-gateway: поллер Response id=%d: разбор: %v", p.ID, err)
return nil
}
// Ответ сервиса МОСТ: статус + код (M2Mxx). Применяем к сделке:
// INFO — приём в обработку (статус не меняется), ERROR — отказ сервиса
// (напр. M2M14 — отправитель не в справочнике), сделка → Отклонена.
// Ответ сохраняется в сделке и виден в карточке заявки.
var codes string
for _, rr := range resp.Responses {
codes += string(rr.Code) + " "
}
log.Printf("lk-gateway: поллер M2MTransferResponse id=%d: статус=%s коды=[%s] GUID=%s",
p.ID, resp.StatusCode, strings.TrimSpace(codes), resp.GUID)
if err := s.svc.ApplyServiceResponse(ctx, resp, unpacked.DocXML); err != nil {
// Сделка может быть не найдена (ответ на чужой/старый GUID) —
// логируем, но помечаем обработанным, чтобы не зациклиться.
log.Printf("lk-gateway: поллер ApplyServiceResponse id=%d GUID=%s: %v", p.ID, resp.GUID, err)
}
return nil
default:
log.Printf("lk-gateway: поллер пакет id=%d (%s) — неизвестный M2M-документ, пропуск", p.ID, p.Type)
return nil
}
}
+14 -54
View File
@@ -26,30 +26,10 @@ type Settings struct {
LK LKSettings `json:"lk"`
CACerts CACertsSettings `json:"ca_certs"`
News NewsSettings `json:"news"`
Update UpdateSettings `json:"update"`
License LicenseSettings `json:"license"`
LastTest *TestRunResult `json:"last_test,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
// LicenseSettings — лицензионный ключ (подписанный токен).
type LicenseSettings struct {
Key string `json:"key"` // компактный токен payload.signature.keyid
PublicKey string `json:"public_key"` // base64 (если не зашит в бинарь)
}
// UpdateSettings — авто-обновления из артефактории (#18/#20).
type UpdateSettings struct {
BaseURL string `json:"base_url"` // https://updates.example.com
Channel string `json:"channel"` // "stable" | "beta"
PublicKey string `json:"public_key"` // base64 Ed25519 (если не зашит в бинарь)
AutoCheck bool `json:"auto_check"` // проверять автоматически
LastCheck time.Time `json:"last_check"` // когда последний раз проверяли
LastResult string `json:"last_result"` // текст результата проверки
Available string `json:"available_version"` // доступная версия (если новее)
Notes string `json:"notes,omitempty"` // заметки доступного релиза
}
// NewsSettings — лента новостей (события системы, окна техработ, обновления
// документации НРД). События добавляются вручную через UI или автоматически
// doc-watcher'ом и cron-задачами. Каждое событие может быть скрыто (Dismissed)
@@ -84,7 +64,8 @@ type DocSource struct {
// CACertsSettings — URL'ы для авто-загрузки сертификатов УЦ НРД и нашего
// УЦ. Список редактируется пользователем; раз в сутки фоновая горутина
// перекачивает каждый URL и сохраняет сертификат, если он поменялся.
// перекачивает каждый URL и переустанавливает сертификат, если он
// поменялся. Все сертификаты идут в mroot/uRoot хранилища КриптоПро.
type CACertsSettings struct {
URLs []string `json:"urls"`
AutoUpdate bool `json:"auto_update"`
@@ -110,12 +91,12 @@ type PostgresSettings struct {
DSN string `json:"dsn"`
}
// CryptoSettings — путь к PKCS#11 модулю и тип провайдера.
// CryptoSettings — путь к JCP, провайдер, лицензионный ключ.
type CryptoSettings struct {
Provider string `json:"provider"` // "stub" | "validata"
Provider string `json:"provider"` // "stub" | "cryptopro" | "validata" | "vipnet"
SocketPath string `json:"socket_path"` // UDS crypto-service
ModulePath string `json:"module_path"` // путь до .so модуля PKCS#11
Profile string `json:"profile"` // активный профиль Валидаты (имя из pki1.conf)
JCPPath string `json:"jcp_path"` // путь до jcp.jar
LicenseKey string `json:"license_key"` // лицензионный ключ КриптоПро
}
// NSDSettings — профиль и подключение к ИШ НРД.
@@ -123,12 +104,6 @@ type NSDSettings struct {
Profile string `json:"profile"` // "guest-gost", "test3-gost", ...
IGWBaseURL string `json:"igw_base_url"` // http://host:port
KeyContainer string `json:"key_container"` // имя контейнера (на стороне ИШ)
// Депозитарные реквизиты клиента — откуда списываются бумаги
// (SettlementLocation в M2MTransferRequest). Из договора/письма НРД.
DeponentCode string `json:"deponent_code"` // депкод, напр. MC0413600000
AccountID string `json:"account_id"` // депозитарный счёт
SectionID string `json:"section_id"` // раздел депозитарного счёта
}
// LKSettings — настройки callback в ЛК клиента.
@@ -190,24 +165,6 @@ func (r *RuntimeConfig) UpdatePostgres(s PostgresSettings) error {
return r.save()
}
// SaveLicense сохраняет лицензионные настройки.
func (r *RuntimeConfig) SaveLicense(s LicenseSettings) error {
r.mu.Lock()
r.data.License = s
r.data.UpdatedAt = time.Now().UTC()
r.mu.Unlock()
return r.save()
}
// SaveUpdateSettings сохраняет настройки авто-обновлений.
func (r *RuntimeConfig) SaveUpdateSettings(s UpdateSettings) error {
r.mu.Lock()
r.data.Update = s
r.data.UpdatedAt = time.Now().UTC()
r.mu.Unlock()
return r.save()
}
// UpdateCrypto сохраняет crypto-настройки.
func (r *RuntimeConfig) UpdateCrypto(s CryptoSettings) error {
r.mu.Lock()
@@ -350,7 +307,7 @@ func (r *RuntimeConfig) ReadinessSummary() []Readiness {
},
{
Name: "crypto-service",
Configured: s.Crypto.Provider != "" && s.Crypto.Provider != "stub",
Configured: s.Crypto.Provider != "" && s.Crypto.Provider != "stub" && s.Crypto.JCPPath != "",
Ready: false,
Message: cryptoMsg(s.Crypto),
},
@@ -379,12 +336,15 @@ func posMsg(dsn string) string {
func cryptoMsg(c CryptoSettings) string {
if c.Provider == "" || c.Provider == "stub" {
return "Криптография не настроена (provider=stub) — реальная подпись недоступна."
return "Криптография не настроена (provider=stub). КриптоПро JCP не подключён."
}
if c.ModulePath == "" {
return "Провайдер " + c.Provider + ", путь к PKCS#11 модулю не задан."
if c.JCPPath == "" {
return "Провайдер " + c.Provider + ", но путь к JCP не задан."
}
return "Провайдер " + c.Provider + ", PKCS#11 модуль: " + c.ModulePath
if c.LicenseKey == "" {
return "Провайдер " + c.Provider + ", JCP есть, лицензия не введена."
}
return "Провайдер " + c.Provider + ", JCP подключён, лицензия введена."
}
func nsdMsg(n NSDSettings) string {
+1 -5
View File
@@ -43,12 +43,8 @@ func NewSeedStore() *SeedStore {
"DP789456", "31MC0021900000F01", "P001", "7702070139")
s.addAccount("11111111-1111-1111-1111-111111111111",
"AA789451", "33MC0021900000F02", "F002", "7802031669")
// Тест-инвестор робота: место расчётов = наш реальный тестовый счёт в НРД
// (депкод MC0413600000, счёт HL171004001C, раздел 36MC0413600000F00,
// расчётный депозитарий НРД ИНН 7702165310). Иначе НРД отвечает M2M19
// «недопустимое место расчетов».
s.addAccount("22222222-2222-2222-2222-222222222222",
"MC0413600000", "HL171004001C", "36MC0413600000F00", "7702165310")
"DP100200", "31MC0010000000A01", "A001", "7702070139")
s.addAccount("33333333-3333-3333-3333-333333333333",
"DP300400", "31MC0030000000B01", "B001", "0702345678")
s.addAccount("55555555-5555-5555-5555-555555555555",
+7 -75
View File
@@ -9,23 +9,9 @@ import (
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/igw"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/mock"
)
// igwClientAdapter адаптирует igw.Client под узкий nsdadapter.IGWClient:
// разворачивает (channel, since, type) в igw.ListFilter.
type igwClientAdapter struct{ c *igw.Client }
func (a igwClientAdapter) SendPackage(ctx context.Context, channel, packageType string, body []byte) (string, error) {
return a.c.SendPackage(ctx, channel, packageType, body)
}
func (a igwClientAdapter) ListIncoming(ctx context.Context, channel string, since time.Time, packageType string) ([]igw.Package, error) {
return a.c.ListIncoming(ctx, igw.ListFilter{Channel: channel, Date: since, Type: packageType})
}
// ServerConfig — конфигурация HTTP-сервера lk-gateway.
type ServerConfig struct {
Addr string
@@ -45,12 +31,6 @@ type Server struct {
rc *RuntimeConfig
mux *http.ServeMux
server *http.Server
// igwClient/igwChannel заполнены только в реальном режиме (ИШ настроен).
// На них работает поллер входящих pollIncoming — забирает ответы НРД
// (M2MTransferDecision/Response) и применяет через svc.ApplyDecision.
igwClient *igw.Client
igwChannel string
}
// NewServer собирает Server с репозиторием, mock NSDSender, SeedStore
@@ -65,44 +45,13 @@ func NewServer(cfg ServerConfig) (*Server, error) {
if cfg.MockDecisionDelay > 0 {
mockCfg.DecisionDelay = cfg.MockDecisionDelay
}
sender := mock.NewSender(mockCfg)
rc, err := NewRuntimeConfig(cfg.SetupPath)
if err != nil {
return nil, err
}
// Выбор NSD-сендера: если в runtime-конфиге задан профиль ИШ и URL —
// используем реальный nsdadapter поверх REST ИШ; иначе mock-эмуляция.
// mockSender остаётся не-nil только в mock-режиме — на нём висит
// consumeDecisions (реальные Decision приходят поллером входящих ИШ).
var sender m2mcore.NSDSender
var mockSender *mock.Sender
var igwClient *igw.Client
var igwChannel string
{
s := rc.Snapshot()
if s.NSD.IGWBaseURL != "" && s.NSD.Profile != "" {
prof, perr := nsdadapter.LookupProfile(s.NSD.Profile)
if perr != nil {
log.Printf("lk-gateway: профиль ИШ %q неизвестен (%v) — fallback mock", s.NSD.Profile, perr)
} else {
prof.IGWBaseURL = s.NSD.IGWBaseURL // override URL из setup.json
cl := igw.NewClient(s.NSD.IGWBaseURL)
sender = nsdadapter.NewSender(prof, igwClientAdapter{c: cl})
igwClient = cl
// Канал ИШ резолвится по составному коду <канал>+<депонент>.
igwChannel = prof.Channel + string(cfg.DefaultSender)
log.Printf("lk-gateway: реальный ИШ-адаптер — профиль %s, канал %s, ИШ %s",
prof.Name, igwChannel, s.NSD.IGWBaseURL)
}
}
if sender == nil {
mockSender = mock.NewSender(mockCfg)
sender = mockSender
log.Printf("lk-gateway: NSD mock-режим (Decision через эмуляцию)")
}
}
// Repository: pgx если DSN указан, иначе in-memory.
var repo m2mcore.Repository = m2mcore.NewMemoryRepository()
if dsn := rc.Snapshot().Postgres.DSN; dsn != "" {
@@ -166,14 +115,12 @@ func NewServer(cfg ServerConfig) (*Server, error) {
registerSeedListing(mux, store)
return &Server{
cfg: cfg,
svc: svc,
mock: mockSender,
store: store,
rc: rc,
mux: mux,
igwClient: igwClient,
igwChannel: igwChannel,
cfg: cfg,
svc: svc,
mock: sender,
store: store,
rc: rc,
mux: mux,
server: &http.Server{
Addr: cfg.Addr,
Handler: mux,
@@ -212,15 +159,6 @@ func (s *Server) Mux() http.Handler { return s.mux }
func (s *Server) Run(ctx context.Context) error {
go s.consumeDecisions(ctx)
// Поллер входящих от НРД (только в реальном режиме ИШ): забирает
// ответы робота/контрагента и применяет их через ApplyDecision.
if s.igwClient != nil && s.igwChannel != "" {
go s.pollIncoming(ctx)
}
// Фоновая авто-проверка обновлений из артефактории (если включена).
go NewUpdater(s.rc).updateLoop(ctx)
// Авто-обновление сертификатов УЦ раз в сутки (если оператор включил).
stopCACerts := StartCACertsAutoUpdater(s.rc)
defer stopCACerts()
@@ -255,12 +193,6 @@ func (s *Server) Run(ctx context.Context) error {
// consumeDecisions слушает Decisions от mock и обновляет соответствующие сделки.
func (s *Server) consumeDecisions(ctx context.Context) {
if s.mock == nil {
// Реальный ИШ-режим: Decision приходят не из mock-канала, а через
// поллер входящих пакетов ИШ (отдельный механизм). Здесь нечего слушать.
<-ctx.Done()
return
}
for {
select {
case <-ctx.Done():
+6 -11
View File
@@ -104,16 +104,11 @@ func TestAdminHome(t *testing.T) {
t.Fatalf("admin home code=%d", w.Code)
}
body := w.Body.String()
// Редизайн #26: оператор-дашборд в стиле Apple — бренд Bridge&Join,
// приветствие-hero и крупные плитки задач.
if !strings.Contains(body, "Bridge") {
t.Errorf("в дашборде нет бренда Bridge&Join")
if !strings.Contains(body, "lk-gateway") {
t.Errorf("в дашборде нет заголовка lk-gateway")
}
if !strings.Contains(body, "Добрый день") {
t.Errorf("в дашборде нет hero-приветствия")
}
if !strings.Contains(body, "Диагностика") {
t.Errorf("в дашборде нет плитки задач")
if !strings.Contains(body, "Состояние системы") {
t.Errorf("в дашборде нет блока статуса")
}
}
@@ -125,8 +120,8 @@ func TestAdminStatus(t *testing.T) {
if w.Code != http.StatusOK {
t.Fatalf("status code=%d", w.Code)
}
if !strings.Contains(w.Body.String(), "PostgreSQL") {
t.Errorf("в статусе нет проверки PostgreSQL")
if !strings.Contains(w.Body.String(), "postgres") {
t.Errorf("в статусе нет проверки postgres")
}
}
+1 -92
View File
@@ -12,7 +12,6 @@ import (
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdxml"
)
// Service — бизнес-логика lk-gateway: преобразует DTO в доменные сущности
@@ -68,13 +67,9 @@ func (s *Service) CreateClaim(ctx context.Context, in CreateClaimRequest) (Creat
return CreateClaimResponse{}, fmt.Errorf("lkgateway: dtoToClaim: %w", err)
}
receiver := s.defaultReceiver
if in.ReceiverCodeOverride != "" {
receiver = m2m.DeponentCode(in.ReceiverCodeOverride)
}
req, err := m2mcore.EnrichRequest(ctx, s.store, domainClaim, m2mcore.SenderReceiver{
SenderCode: s.defaultSender,
ReceiverCode: receiver,
ReceiverCode: s.defaultReceiver,
})
if err != nil {
return CreateClaimResponse{}, fmt.Errorf("lkgateway: EnrichRequest: %w", err)
@@ -168,80 +163,6 @@ func (s *Service) ApplyDecision(ctx context.Context, decision *m2m.M2MTransferDe
return nil
}
// ApplyServiceResponse применяет M2MTransferResponse (ответ сервиса МОСТ) к
// сделке: сохраняет ответ, при ERROR переводит сделку в Rejected и шлёт
// callback в ЛК. Сделку ищем по GUID ответа.
func (s *Service) ApplyServiceResponse(ctx context.Context, resp *m2m.M2MTransferResponse, raw []byte) error {
if resp == nil {
return errors.New("lkgateway: ApplyServiceResponse: resp=nil")
}
deal, err := s.findDealForResponse(ctx, resp)
if err != nil {
return fmt.Errorf("lkgateway: поиск сделки для ответа: %w", err)
}
prev := deal.State
if err := deal.ReceiveServiceResponse(ctx, resp, raw); err != nil {
return fmt.Errorf("lkgateway: ReceiveServiceResponse: %w", err)
}
if err := s.repo.Update(ctx, deal); err != nil {
return fmt.Errorf("lkgateway: repo.Update: %w", err)
}
// Состояние сменилось (ERROR → Rejected) — учитываем в метриках и шлём
// callback. На INFO состояние не меняется — callback не нужен.
if deal.State != prev {
s.recorder.IncDeal(deal.State)
if s.callbackURL != "" {
s.sendCallback(ctx, deal)
}
}
return nil
}
// zeroGUID — нулевой UUID, который НРД присылает в сервисных ошибках
// (напр. M2M14), когда не идентифицирует исходный запрос.
const zeroGUID = "00000000-0000-0000-0000-000000000000"
// findDealForResponse сопоставляет ответ МОСТ со сделкой. Сначала по GUID;
// если GUID нулевой/пустой или сделка по нему не найдена (реальное поведение
// НРД при M2M14 — ответ без нашего GUID и без ReferenceID), применяем
// эвристику: берём самую раннюю ожидающую решение заявку без ответа. Для
// тестового сценария «одна заявка в полёте» это однозначно; при множестве
// заявок in-flight такие сервисные ошибки в принципе неразличимы на стороне
// НРД, и FIFO — наилучшее доступное приближение.
func (s *Service) findDealForResponse(ctx context.Context, resp *m2m.M2MTransferResponse) (*m2mcore.Deal, error) {
guid := string(resp.GUID)
if guid != "" && guid != zeroGUID {
deal, err := s.repo.GetByGUID(ctx, resp.GUID)
if err == nil {
return deal, nil
}
if !errors.Is(err, m2mcore.ErrNotFound) {
return nil, err
}
}
// Fallback: ответ без идентифицируемого GUID.
st := m2mcore.StateAwaitingDecision
deals, err := s.repo.List(ctx, m2mcore.Filter{State: &st, Limit: 200})
if err != nil {
return nil, err
}
var cand *m2mcore.Deal
for _, d := range deals {
if d.Response != nil {
continue // этой заявке ответ уже сопоставлен
}
if cand == nil || d.CreatedAt.Before(cand.CreatedAt) {
cand = d
}
}
if cand == nil {
return nil, m2mcore.ErrNotFound
}
log.Printf("lkgateway: ответ МОСТ с GUID=%s сопоставлен по эвристике (FIFO) заявке id=%s status=%s",
guid, cand.ID, resp.StatusCode)
return cand, nil
}
// sendCallback отправляет PATCH в ЛК с обновлением статуса.
func (s *Service) sendCallback(ctx context.Context, deal *m2mcore.Deal) {
cb := callbackForDeal(deal)
@@ -264,14 +185,6 @@ func dtoToClaim(in CreateClaimRequest) (m2mcore.ClaimInput, error) {
TransferringDepositoryINN: m2m.OrganizationINN(in.TransferringDepositoryINN),
ReceivingDepositoryINN: m2m.OrganizationINN(in.ReceivingDepositoryINN),
}
// Переопределение документа инвестора (тест с роботом: серия = сценарий).
if d := in.InvestorDocumentOverride; d != nil {
out.InvestorDocument = &m2mcore.ClientDocument{
DocumentType: m2m.IdentityDocumentCode(d.DocumentType),
Series: d.Series,
Number: d.Number,
}
}
// CostInfo
if in.CostInfo.Yes != nil {
out.CostInfo = m2m.CostInfo{Yes: &m2m.CostInfoYes{Code: m2m.DeponentCode(in.CostInfo.Yes.Code)}}
@@ -391,10 +304,6 @@ func dealToView(d *m2mcore.Deal) ClaimView {
}
if d.Response != nil {
out.M2MResponse = responseToView(d.Response)
if len(d.RawResponse) > 0 {
// Ответ НРД в windows-1251 — декодируем в UTF-8 для показа.
out.M2MResponse.RawXML = string(nsdxml.DecodeWindows1251(d.RawResponse))
}
}
if d.Decision != nil {
out.M2MDecision = decisionToView(d.Decision)
File diff suppressed because it is too large Load Diff
-10
View File
@@ -18,13 +18,6 @@ type CreateClaimRequest struct {
Securities []ClaimSec `json:"securities"`
SignedDocument string `json:"signed_document"`
SignatureFormat string `json:"signature_format"`
// ReceiverCodeOverride — если задан, переопределяет код получателя
// (Header.ReceiverCode). Используется для тестовых пакетов роботу НРД
// (MC0012500000). Пусто = берётся defaultReceiver.
ReceiverCodeOverride string `json:"receiver_code_override,omitempty"`
// InvestorDocumentOverride — если задан, переопределяет документ инвестора
// из анкеты. Используется тестом с роботом НРД (серия ДУЛ = код сценария).
InvestorDocumentOverride *Document `json:"investor_document_override,omitempty"`
}
// Investor — анкета.
@@ -166,9 +159,6 @@ type NSDResponseView struct {
GUID string `json:"guid"`
StatusCode string `json:"status_code"`
Responses []NSDResponseEntry `json:"responses"`
// RawXML — точные байты ответа МОСТ от НРД (декодированные в UTF-8 для
// показа). Для дословной пересылки в техподдержку НРД.
RawXML string `json:"raw_xml,omitempty"`
}
// NSDResponseEntry — одна запись Response.
-204
View File
@@ -1,204 +0,0 @@
package lkgateway
import (
"context"
"fmt"
"log"
"os"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/release"
)
// BuildVersion — версия bj-server. Переопределяется при сборке:
//
// go build -ldflags "-X .../lkgateway.BuildVersion=1.2.0"
var BuildVersion = "0.1.0"
// DefaultUpdatePublicKey — публичный ключ артефактории, зашитый в релиз.
// Пустой в исходниках; подставляется при официальной сборке. Если задан в
// настройках (UpdateSettings.PublicKey) — приоритет у настроек.
var DefaultUpdatePublicKey = ""
// installPaths — куда устанавливать артефакты по логическому имени.
// Файлы не из этого списка при авто-обновлении пропускаются (скрипты/SQL
// обновляются отдельно, не на лету).
var installPaths = map[string]string{
"bj-server": "/opt/bj/bj-server",
"crypto-service.jar": "/opt/bj/crypto-service.jar",
}
// Updater — авто-обновление bj-server из артефактории (работает поверх rc).
type Updater struct{ rc *RuntimeConfig }
// NewUpdater создаёт Updater на текущем runtime-конфиге.
func NewUpdater(rc *RuntimeConfig) *Updater { return &Updater{rc: rc} }
// UpdateStatus — сводка для UI/handler.
type UpdateStatus struct {
Configured bool
CurrentVersion string
Available string
HasUpdate bool
Channel string
Notes string
Message string
}
func (u *Updater) updateClient() (*release.Client, error) {
cfg := u.rc.Snapshot().Update
pub := cfg.PublicKey
if pub == "" {
pub = DefaultUpdatePublicKey
}
if cfg.BaseURL == "" || pub == "" {
return nil, fmt.Errorf("обновления не настроены (нужны URL артефактории и публичный ключ)")
}
channel := cfg.Channel
if channel == "" {
channel = "stable"
}
return release.NewClient(cfg.BaseURL, channel, pub)
}
// CheckForUpdate скачивает манифест, проверяет подпись, сравнивает версии и
// сохраняет результат в настройки. Возвращает сводку.
func (u *Updater) CheckForUpdate(ctx context.Context) (UpdateStatus, error) {
st := UpdateStatus{CurrentVersion: BuildVersion, Channel: u.rc.Snapshot().Update.Channel}
cl, err := u.updateClient()
if err != nil {
st.Message = err.Error()
return st, nil // не настроено — не ошибка
}
st.Configured = true
m, err := cl.FetchManifest(ctx)
if err != nil {
st.Message = "проверка не удалась: " + err.Error()
u.saveCheckResult(st)
return st, err
}
st.Available = m.Version
st.Notes = m.Notes
st.HasUpdate = release.IsNewer(m.Version, BuildVersion)
if st.HasUpdate {
st.Message = fmt.Sprintf("доступна версия %s (текущая %s)", m.Version, BuildVersion)
} else {
st.Message = "установлена актуальная версия " + BuildVersion
}
u.saveCheckResult(st)
return st, nil
}
func (u *Updater) saveCheckResult(st UpdateStatus) {
cfg := u.rc.Snapshot().Update
cfg.LastCheck = time.Now().UTC()
cfg.LastResult = st.Message
cfg.Available = st.Available
cfg.Notes = st.Notes
if err := u.rc.SaveUpdateSettings(cfg); err != nil {
log.Printf("lk-gateway: сохранение результата проверки обновления: %v", err)
}
}
// ApplyUpdate скачивает обновлённые артефакты (с проверкой подписи манифеста
// и sha256 каждого файла), атомарно заменяет бинари и завершает процесс с
// ненулевым кодом — systemd (Restart=on-failure) поднимает новую версию.
func (u *Updater) ApplyUpdate(ctx context.Context) error {
// Гейт лицензией: если лицензирование включено — требуется валидная
// лицензия с фичей updates. Без лицензирования (открытый режим) — пропускаем.
if licensingEnabled(u.rc) {
ls := licenseStatus(u.rc)
if !ls.Valid {
return fmt.Errorf("обновления заблокированы — лицензия: %s", ls.Message)
}
if !ls.AllowsUpdates {
return fmt.Errorf("обновления не входят в план %q", ls.Plan)
}
}
cl, err := u.updateClient()
if err != nil {
return err
}
m, err := cl.FetchManifest(ctx)
if err != nil {
return fmt.Errorf("манифест: %w", err)
}
if !release.IsNewer(m.Version, BuildVersion) {
return fmt.Errorf("обновление не требуется (текущая %s, доступна %s)", BuildVersion, m.Version)
}
updated := 0
for _, a := range m.Artifacts {
dst, ok := installPaths[a.Name]
if !ok {
continue // скрипты/SQL не обновляем на лету
}
dir := dirOf(dst)
path, err := cl.DownloadArtifact(ctx, a, dir)
if err != nil {
return fmt.Errorf("скачивание %s: %w", a.Name, err)
}
// DownloadArtifact кладёт файл под именем a.File; если целевое имя
// иное — переименуем атомарно.
if path != dst {
if err := os.Rename(path, dst); err != nil {
return fmt.Errorf("установка %s: %w", a.Name, err)
}
}
log.Printf("lk-gateway: обновлён %s → %s (%s)", a.Name, dst, m.Version)
updated++
}
if updated == 0 {
return fmt.Errorf("в манифесте %s нет обновляемых бинарей", m.Version)
}
log.Printf("lk-gateway: обновление до %s применено (%d файлов), перезапуск через systemd…", m.Version, updated)
// Завершаемся с ненулевым кодом — systemd Restart=on-failure поднимет
// новый бинарь. Даём пару секунд на флаш логов/ответа.
go func() {
time.Sleep(800 * time.Millisecond)
os.Exit(42)
}()
return nil
}
// updateLoop — фоновая авто-проверка обновлений (если включена).
func (u *Updater) updateLoop(ctx context.Context) {
ticker := time.NewTicker(6 * time.Hour)
defer ticker.Stop()
check := func() {
if !u.rc.Snapshot().Update.AutoCheck {
return
}
cctx, cancel := context.WithTimeout(ctx, 90*time.Second)
defer cancel()
if st, err := u.CheckForUpdate(cctx); err == nil && st.HasUpdate {
log.Printf("lk-gateway: доступно обновление %s (текущая %s)", st.Available, st.CurrentVersion)
}
}
// первая проверка через минуту после старта
select {
case <-ctx.Done():
return
case <-time.After(time.Minute):
check()
}
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
check()
}
}
}
func dirOf(path string) string {
for i := len(path) - 1; i >= 0; i-- {
if path[i] == '/' {
return path[:i]
}
}
return "."
}
@@ -51,51 +51,16 @@
{{if .Claim.M2MResponse}}
<div class="card">
<h2>Ответ сервиса МОСТ (M2MTransferResponse)</h2>
<p>
{{if eq .Claim.M2MResponse.StatusCode "ERROR"}}
<span class="badge err">● ERROR — заявка отклонена сервисом НРД</span>
{{else}}
<span class="badge ok">● {{.Claim.M2MResponse.StatusCode}} — принято в обработку</span>
{{end}}
</p>
<p class="muted">GUID <code>{{.Claim.M2MResponse.GUID}}</code></p>
<h2>Ответ НРД (M2MTransferResponse)</h2>
<p class="muted">GUID <code>{{.Claim.M2MResponse.GUID}}</code> · Status <code>{{.Claim.M2MResponse.StatusCode}}</code></p>
<table>
<thead><tr><th>ReferenceID</th><th>Код</th><th>Текст ответа НРД</th></tr></thead>
<thead><tr><th>ReferenceID</th><th>Код</th><th>Текст</th></tr></thead>
<tbody>
{{range .Claim.M2MResponse.Responses}}
<tr><td><code>{{.ReferenceID}}</code></td><td><code>{{.Code}}</code></td><td>{{.Text}}</td></tr>
<tr><td><code>{{.ReferenceID}}</code></td><td>{{.Code}}</td><td>{{.Text}}</td></tr>
{{end}}
</tbody>
</table>
{{if eq .Claim.M2MResponse.StatusCode "ERROR"}}
<p class="muted" style="margin-top:10px">
Это отказ на сервисном уровне — запрос не дошёл до контрагента. Решение
(M2MTransferDecision) по такой заявке не придёт. Устраните причину по коду
выше и отправьте новую заявку.
</p>
{{end}}
{{if .Claim.M2MResponse.RawXML}}
<details style="margin-top:12px">
<summary style="cursor:pointer;font-weight:600">
Сырой ответ НРД (для техподдержки M2MOST@nsd.ru)
</summary>
<p class="muted" style="margin:8px 0">
Точные байты ответа сервиса МОСТ. Можно дословно переслать в поддержку НРД.
</p>
<button type="button" class="btn" onclick="copyRaw(this)">Скопировать</button>
<pre id="raw-response" style="white-space:pre-wrap;word-break:break-all;background:var(--surface-2,#f5f5f7);padding:12px;border-radius:8px;font-size:12px;overflow:auto;max-height:340px">{{.Claim.M2MResponse.RawXML}}</pre>
</details>
<script>
function copyRaw(btn){
var t=document.getElementById('raw-response').innerText;
navigator.clipboard.writeText(t).then(function(){
var o=btn.textContent; btn.textContent='Скопировано ✓';
setTimeout(function(){btn.textContent=o;},1500);
});
}
</script>
{{end}}
</div>
{{end}}
@@ -17,10 +17,10 @@
<p class="muted">REST-контракт ESIA Finance V1: <code>POST /api/v1/back_office/claims/</code>, GET/PATCH-операции, формат callback'ов, аутентификация Basic, примеры запросов curl.</p>
</div>
</a>
<a href="/admin/help/crypto" style="text-decoration:none">
<a href="/admin/help/cryptopro" style="text-decoration:none">
<div class="card" style="height:100%">
<h2 style="color:var(--accent)">Криптография (Валидата)</h2>
<p class="muted">Установка АПК «Валидата Клиент L» на Astra Linux SE, подключение через PKCS#11, тестирование подписи и проверки квитанций НРД.</p>
<h2 style="color:var(--accent)">КриптоПро и Рутокен</h2>
<p class="muted">Установка КриптоПро CSP на РЕД ОС / Ubuntu, ввод серийного номера, PKCS#11 модуль, серверная подпись и подпись оператора через Рутокен ЭЦП 2.0, тестирование.</p>
</div>
</a>
<a href="/admin/help/systems" style="text-decoration:none">
@@ -38,7 +38,7 @@
<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>
<p class="muted">Полная схема: bj-server → ИШ (на Astra Linux ВМ) → ONYX (НРД) → робот-автотест. Кто на чьей стороне, какие СКЗИ, какие сертификаты, FAQ. Куда воткнуть Валидату, куда КриптоПро, где сертификаты УЦ МБ.</p>
</div>
</a>
</div>
@@ -27,9 +27,9 @@
│ │ (history) │ │
│ └──────┬───────────┘ │
│ │ │
АПК «Валидата Клиент L» АПК «Валидата │
(PKCS#11) — общий для всех Клиент L»
компонентов
КриптоПро CSP — для нашей │ Валидата CSP
admin-стороны (PKCS#11) + АПК Валидата
Клиент L
└──────────────────────────────────────────────┼─────────────────┘
SOAP/REST/HTTPS │ Web-сервис ONYX
@@ -62,15 +62,15 @@
<tr>
<td><strong>bj-server</strong></td>
<td>наша</td>
<td>Astra Linux SE 1.7 / Linux</td>
<td>АПК «Валидата Клиент L» (PKCS#11)</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</td>
<td>АПК «Валидата Клиент L»</td>
<td>Astra Linux SE 1.6/1.7 <em>или</em> Windows 10/Server</td>
<td>Валидата CSP + АПК Валидата Клиент L</td>
<td>Подписывает наш XML сертификатом УЦ МБ, упаковывает в пакет ЭДО, отправляет в НРД</td>
</tr>
<tr>
@@ -98,13 +98,16 @@
<p>Нет. <strong>ИШ — это наша программа, поставленная у нас.</strong> НРД даёт дистрибутив (<code>igate_100.0-765_amd64.deb</code>, 117 МБ), но ставим у себя. ИШ — это «персональный почтовый клиент к НРД» с подписью.</p>
<h3>Q: ИШ можно поставить на ту же ВМ, что и bj-server?</h3>
<p>Да, если ВМ — Astra Linux. И bj-server, и ИШ работают на Astra Linux SE 1.6/1.7 и используют одно и то же СКЗИ — АПК «Валидата Клиент L». Можно собрать всё на одной ВМ или разнести по отдельным.</p>
<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>Дистрибутив для Astra Linux SE опубликован на сайте Московской Биржи: <a href="https://fs.moex.com/cdp/po/ClientL_ALSE.zip" target="_blank" rel="noopener">fs.moex.com/cdp/po/ClientL_ALSE.zip</a>. На Linux отдельной лицензии и регистрационных данных не требует — пакеты <code>zpki</code>/<code>zsdk</code> ставятся через <code>dpkg -i</code> и работают сразу.</p>
<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>
@@ -1,136 +0,0 @@
{{define "content"}}
<div class="card">
<h2>Криптография (АПК «Валидата Клиент L»)</h2>
<p class="muted">bj-server общается с СКЗИ «Валидата Клиент L» через сайдкар <code>bj-crypto</code> по UDS <code>/run/bj/crypto.sock</code>. Чтобы подпись и проверка квитанций НРД заработали, нужен <strong>ключевой профиль</strong> — папка с тремя сущностями: <code>local.pse</code> (зашифрованный контейнер), <code>local.gdbm</code> (база сертификатов) и <code>vdkeys/*.vdk</code> (сам ключ).</p>
<p class="muted"><strong>Архив от MOEX/НРД содержит «резервную копию», а не готовый профиль.</strong> На Linux рабочий <code>local.gdbm</code> нельзя восстановить headless — Валидата Linux требует GUI-операции «Восстановить справочники из резервной копии». Поэтому профиль готовится один раз на Windows и переносится на сервер через USB.</p>
</div>
<div class="card" style="border-left:4px solid var(--accent)">
<h2>Почему профиль готовится на Windows, а не на сервере</h2>
<p>Боевой Astra Linux SE-сервер с ГОСТ-криптографией <strong>обязан быть headless</strong>: чем меньше пакетов и поверхности атаки, тем проще сертификация ФСТЭК и тем меньше нарушений требований к контуру ЭДО НРД. Установка GUI (X-сервер, GTK, шрифты, VNC/RDP) тянет 50+ пакетов, расширяет surface attack и усложняет аудит — поэтому отказались.</p>
<p>Это <strong>стандартная практика</strong> в фин-секторе: на admin-станции (под Windows или отдельной защищённой ВМ) генерируются и обновляются профили; на боевые серверы они доставляются готовыми через выделенный USB или защищённый канал. Все инструкции MOEX/НРД написаны именно под Windows — этот путь поддерживается официально.</p>
<p class="muted">Альтернативный путь — Linux GUI через X11-forwarding или VNC на дев-стенды — допустим только в песочнице, не в проде. На боевых серверах <code>zcs</code>/<code>vdcsp_cfg</code> не должны запускаться.</p>
</div>
<div class="card" style="border-left:4px solid var(--ok)">
<h2>✅ Подготовка профиля (Windows → USB → bj-server)</h2>
<h3 style="margin-top:14px">Шаг A — на компьютере под Windows</h3>
<ol>
<li><strong>Установите СКЗИ Валидата CSP для Windows</strong>.<br>
Скачайте дистрибутив с <a href="https://www.moex.com/s1292" target="_blank" rel="noopener">moex.com/s1292</a> (раздел «СКЗИ для Windows», файл «Валидата CSP v.6.0.482.0 64bit»). Внутри архива есть <code>Readme.txt</code> с регистрационными данными — введите их во время установки.
</li>
<li><strong>Распакуйте архив-профиль от MOEX/НРД</strong>.<br>
Например <code>PrUser985.7z</code> с паролем <code>11</code> в папку <code>C:\moex-src\</code>. Получится структура:
<pre style="font-size:12px">C:\moex-src\
spr985\
local.pse
local.gdbm ← это «резервная копия», на Linux не работает напрямую
vdkeys\
XXXXXXXXXXXXXXXX.vdk
key.reg</pre>
</li>
<li><strong>Зарегистрируйте ключ в системе Windows</strong>.<br>
Двойной клик по <code>key.reg</code> → «Да» на запрос о записи в реестр. Это нужно, чтобы Валидата увидела ключ при восстановлении справочников.
</li>
<li><strong>Откройте «Справочник сертификатов x64»</strong> из меню «Пуск» → «АПК Валидата Клиент».</li>
<li><strong>Создайте профиль на флешке</strong>:
<ul>
<li>Вставьте чистую USB-флешку, запомните её букву (например <code>E:</code>).</li>
<li>В Справочнике: меню <em>Профили</em><em>Настройка профилей</em><em>Добавить</em>.</li>
<li>Имя профиля: например <code>moex</code>.</li>
<li><strong>Каталог профиля</strong>: создайте новую пустую папку <strong>на флешке</strong>, например <code>E:\moex\</code>. Это путь, куда Валидата положит рабочую копию.</li>
</ul>
</li>
<li><strong>Восстановите справочники из резервной копии</strong>:<br>
Меню <em>Сервис</em><em>Восстановить справочники из резервной копии</em>. В диалоге укажите папку <code>C:\moex-src\spr985\</code>. Дождитесь сообщения «Справочники восстановлены».<br>
После этого в <code>E:\moex\</code> появятся <code>local.pse</code> и <strong>рабочий</strong> <code>local.gdbm</code> (отличается от исходной резервной копии).
</li>
<li><strong>Скопируйте папку <code>vdkeys</code> на корень флешки</strong>.<br>
Скопируйте папку <code>C:\moex-src\vdkeys\</code> в корень флешки. Итоговая структура:
<pre style="font-size:12px">E:\
moex\ ← рабочий профиль, созданный Валидатой
local.pse
local.gdbm ← теперь правильный
vdkeys\
XXXXXXXXXXXXXXXX.vdk</pre>
</li>
<li><strong>Безопасно извлеките флешку</strong> через значок в системном трее Windows.</li>
</ol>
<h3 style="margin-top:18px">Шаг B — на сервере (этот веб-интерфейс)</h3>
<ol>
<li><strong>Вставьте флешку в сервер</strong> (физический USB-порт или прокинутая через гипервизор виртуальная флешка).</li>
<li>Откройте <a href="/admin/setup">/admin/setup</a>. Через 2-3 секунды (автодетект монтирования) в блоке <strong>«Носители ключей»</strong> появится строка <code>🔌 USB /run/media/...</code>. Внутри неё — сабблок <strong>«Профиль Валидаты»</strong> с тремя строками: <code>local.pse</code> / <code>local.gdbm</code> / <code>*.vdk</code>.</li>
<li>В поле <strong>«Имя профиля»</strong> введите осмысленное имя (например <code>moex</code>) и нажмите <strong>«Импортировать профиль в Валидату»</strong>.<br>
Сервер скопирует файлы в <code>/var/lib/bj/profiles/&lt;имя&gt;/</code>, допишет секцию в <code>/opt/Validata/VDCSP/etc/pki1.conf</code>. Toast подтвердит: «Секция дописана в pki1.conf».</li>
<li>В таблице <strong>«Импортированные профили Валидаты»</strong> ниже — нажмите <strong>«Активировать»</strong> в строке вашего профиля.<br>
Toast: «Валидата: контекст с профилем &lt;имя&gt; инициализирован» → готово.</li>
<li>Можно извлекать флешку — все нужные файлы уже скопированы в <code>/var/lib/bj/profiles/</code>.</li>
</ol>
<h3 style="margin-top:18px">Проверка</h3>
<ol>
<li>В блоке «СКЗИ» нажмите зелёную кнопку <strong>«✓ Проверить подключение СКЗИ»</strong>.</li>
<li>Toast должен показать что-то вроде: <code>СКЗИ validata: 0.1.0 (Валидата: контекст с профилем «moex» инициализирован)</code>.</li>
</ol>
</div>
<div class="card">
<h2>Что делать если профиль на флешке не виден</h2>
<ul>
<li><strong>USB не монтируется автоматически в Astra Linux SE.</strong> Подключите вручную: посмотрите <code>lsblk</code>, потом <code>sudo mount /dev/sdb1 /mnt</code>. Через секунду «Носители ключей» подхватит точку монтирования.</li>
<li><strong>Файлы лежат не в корне флешки.</strong> Сканер ищет в глубину 4 уровня — если поместили в <code>E:\very\deep\folder\moex\</code>, должно тоже найтись.</li>
<li><strong>На флешке нет <code>vdkeys\</code>.</strong> Без неё профиль не работает — ключ <code>.vdk</code> обязателен.</li>
<li><strong>«Ни контейнеров, ни сертификатов, ни профиля Валидаты не найдено».</strong> Это значит на носителе нет <em>одновременно</em> <code>.pse</code> и <code>.vdk</code> файлов. Перепроверьте Шаг 6-7 на Windows.</li>
</ul>
</div>
<div class="card">
<h2>Альтернатива: загрузка как ZIP-архив</h2>
<p>Если USB-доступ к серверу неудобен — можно собрать содержимое флешки в обычный <code>.zip</code> на Windows и загрузить через web-форму.</p>
<ol>
<li>После шага A.7 (когда на флешке готовая структура <code>moex\</code> + <code>vdkeys\</code>) — выделите обе папки, правый клик → <em>Отправить</em><em>Сжатая ZIP-папка</em>.</li>
<li>На сервере: <a href="/admin/setup">/admin/setup</a> → «Носители ключей» → форма «Загрузить образ или архив» → выберите ZIP, поле «Пароль» оставьте пустым.</li>
<li>Дальше как в Шаге B со 2-го пункта.</li>
</ol>
<p class="muted">Под капотом сервер распаковывает архив через <code>7z</code> в <code>/var/lib/bj/media/iso/</code>, сканирует на профиль Валидаты — далее всё то же самое, что с USB.</p>
</div>
<div class="card">
<h2>Справочные команды (диагностика)</h2>
<table>
<tbody>
<tr><td><code>systemctl status bj-crypto</code></td><td>Состояние Java-сайдкара (UDS-сокет, провайдер).</td></tr>
<tr><td><code>sudo journalctl -u bj-crypto -n 50</code></td><td>Последние строки лога сайдкара.</td></tr>
<tr><td><code>cat /opt/Validata/VDCSP/etc/pki1.conf</code></td><td>Список профилей, которые видит Валидата (наши секции помечены <code># --- bj-server: профиль ...</code>).</td></tr>
<tr><td><code>sudo ls -la /var/lib/bj/profiles/</code></td><td>Импортированные профили на сервере.</td></tr>
<tr><td><code>/opt/Validata/VDCSP/bin/amd64/testcsp -silent</code></td><td>Базовая проверка провайдера CSP.</td></tr>
</tbody>
</table>
</div>
<div class="card">
<h2>Установка Валидаты на сервер (если её ещё нет)</h2>
<p class="muted">Если этот раздел вам не показывает «✓ ready» — повторите установку:</p>
<pre>curl -fsSL https://fs.moex.com/cdp/po/ClientL_ALSE.zip -o ClientL_ALSE.zip
unzip ClientL_ALSE.zip
sudo apt-get install -y libccid pcscd execstack
sudo dpkg -i ClientL_ALSE/zpki-*.deb ClientL_ALSE/zsdk-*.deb
sudo apt-get -f install -y
sudo execstack -c /opt/Validata/VDCSP/lib/amd64/libvdcsp.so
sudo systemctl enable --now pcscd</pre>
<p class="muted">Дистрибутив для Astra Linux SE — <a href="https://fs.moex.com/cdp/po/ClientL_ALSE.zip" target="_blank" rel="noopener">fs.moex.com/cdp/po/ClientL_ALSE.zip</a>. Linux-версия отдельной лицензии не требует.</p>
</div>
{{end}}
@@ -0,0 +1,130 @@
{{define "content"}}
<p style="margin-bottom:16px"><a href="/admin/help">← все инструкции</a></p>
<div class="card">
<h2>КриптоПро и Рутокен</h2>
<p class="muted">Bridge-and-Join-s использует ГОСТ Р 34.10-2012 для подписи и проверки XMLDSig. Серверная криптография — КриптоПро CSP. Подпись оператора в admin-ui — Рутокен ЭЦП 2.0 (опционально). Оба продукта говорят со стандартным интерфейсом PKCS#11, поэтому Go-клиент общается с ними одинаково.</p>
</div>
<div class="card">
<h2>1. Что и зачем нужно</h2>
<table>
<thead><tr><th>Сценарий</th><th>СКЗИ</th><th>Цена (ориентир)</th></tr></thead>
<tbody>
<tr><td>Проверка XMLDSig входящих от НРД и брокеров</td><td>КриптоПро CSP «Сервер»</td><td>~30-50к ₽ (один раз)</td></tr>
<tr><td>Подпись пакетов в НРД (резервный канал WS ONYX)</td><td>КриптоПро CSP «Сервер»</td><td>включено</td></tr>
<tr><td>Подпись действий оператора в admin-ui</td><td>Рутокен ЭЦП 2.0 + лицензия CSP «Рабочее место»</td><td>~3-5к ₽ железо + ~2-3к ₽ лицензия</td></tr>
<tr><td>Проверка XMLDSig заявлений от ЛК</td><td>КриптоПро CSP «Сервер»</td><td>включено</td></tr>
</tbody>
</table>
<p class="muted">Если используется Интеграционный шлюз НРД (ИШ), он сам подписывает пакеты — наша серверная подпись нужна только для резервного канала ONYX и подписи действий оператора. Можно начать с минимума: только Рутокен оператора и отложить серверную лицензию.</p>
</div>
<div class="card">
<h2>2. Установка КриптоПро CSP на РЕД ОС (проверено)</h2>
<p><strong>Способ 1 — через веб-интерфейс (рекомендуется):</strong> <a href="/admin/setup">/admin/setup</a> → «СКЗИ» → «Установка КриптоПро CSP» → выбрать <code>linux-amd64.tar</code> с cryptopro.ru → «Загрузить и установить».</p>
<p><strong>Способ 2 — вручную из терминала.</strong> Скачать <code>linux-amd64.tgz</code> с <code>www.cryptopro.ru/products/csp/downloads</code> (доступ через личный кабинет), распаковать на ВМ и установить минимальный набор:</p>
<pre>tar -xzf linux-amd64.tgz
cd linux-amd64
sudo rpm -Uvh --replacepkgs --nodeps \
lsb-cprocsp-base-5.0.*.noarch.rpm \
lsb-cprocsp-ca-certs-5.0.*.noarch.rpm \
lsb-cprocsp-rdr-64-5.0.*.x86_64.rpm \
lsb-cprocsp-capilite-64-5.0.*.x86_64.rpm \
lsb-cprocsp-kc1-64-5.0.*.x86_64.rpm \
lsb-cprocsp-pkcs11-64-5.0.*.x86_64.rpm \
cprocsp-curl-64-5.0.*.x86_64.rpm \
cprocsp-rdr-gui-gtk-64-5.0.*.x86_64.rpm</pre>
<p>Ключевые пакеты:</p>
<ul>
<li><code>lsb-cprocsp-base</code> + <code>lsb-cprocsp-rdr-64</code> — базовая инфраструктура</li>
<li><code>lsb-cprocsp-capilite-64</code> — CAPILite (<code>libcapi20.so.4</code>, <code>libcpext.so.4</code>) — иначе libcppkcs11.so не загрузится</li>
<li><code>lsb-cprocsp-kc1-64</code> — CSP класса КС1 (без него Initialize упадёт с CKR_FUNCTION_FAILED)</li>
<li><code>lsb-cprocsp-pkcs11-64</code> — собственно <code>libcppkcs11.so</code></li>
</ul>
<p>Демо-лицензия на 3 месяца встроена в дистрибутив, отдельная активация не требуется. Проверка:</p>
<pre>/opt/cprocsp/sbin/amd64/cpconfig -license -view
/opt/cprocsp/bin/amd64/csptest -keyset -enum -unique</pre>
<p><strong>Важно — LD_LIBRARY_PATH.</strong> КриптоПро CSP кладёт .so в <code>/opt/cprocsp/lib/amd64</code> без записи в <code>/etc/ld.so.conf.d</code>. Bj-server при запуске должен иметь:</p>
<pre>Environment=LD_LIBRARY_PATH=/opt/cprocsp/lib/amd64</pre>
<p>В systemd-юните это уже прописано (<code>deploy/systemd/bj-server.service</code>). При ручном запуске из shell — <code>LD_LIBRARY_PATH=/opt/cprocsp/lib/amd64 ./bin/bj-server</code>.</p>
<p><strong>Активация коммерческой лицензии.</strong> После того как демо истечёт, серийник вводится через UI на <a href="/admin/setup">/admin/setup</a> → «Активация лицензии», или вручную:</p>
<pre>sudo /opt/cprocsp/sbin/amd64/cpconfig -license -set XXXX-XXXXX-XXXXX-XXXXX-XXXXX</pre>
</div>
<div class="card">
<h2>3. Установка на Ubuntu / Debian</h2>
<pre>sudo dpkg -i cprocsp-rdr-gui-gtk-64_5.0.*_amd64.deb \
cprocsp-rdr-64_5.0.*_amd64.deb \
lsb-cprocsp-base_5.0.*_all.deb \
lsb-cprocsp-rdr-64_5.0.*_amd64.deb
sudo apt-get install -f
sudo /opt/cprocsp/sbin/amd64/cpconfig -license -set XXXX-XXXXX-XXXXX-XXXXX-XXXXX</pre>
</div>
<div class="card">
<h2>4. PKCS#11 модуль</h2>
<p>Путь к библиотеке после установки:</p>
<pre>/opt/cprocsp/lib/amd64/libcppkcs11.so</pre>
<p>Эта же библиотека работает и с CSP-ключами (контейнеры на диске или в реестре), и с Рутокен ЭЦП 2.0 (подключённым по USB или в виде smart-card reader).</p>
<p>На <a href="/admin/setup">странице «Настройка»</a> в карточке «Криптография» укажите:</p>
<ul>
<li><strong>Провайдер</strong>: <code>cryptopro</code></li>
<li><strong>UDS-сокет</strong>: <code>/run/bj/crypto.sock</code> (для legacy crypto-service на Java — на M2+ переходим на Go-клиент напрямую через PKCS#11)</li>
<li><strong>Путь к jcp.jar / PKCS#11</strong>: <code>/opt/cprocsp/lib/amd64/libcppkcs11.so</code></li>
<li><strong>Лицензионный ключ</strong>: серийный номер CSP</li>
</ul>
</div>
<div class="card">
<h2>5. Подключение Рутокен ЭЦП 2.0</h2>
<p>Подключите Рутокен в USB. Драйверы КриптоПро CSP уже включают поддержку Рутокен:</p>
<pre># увидеть подключённые токены
/opt/cprocsp/bin/amd64/csptest -card -enum
# увидеть ключевые контейнеры на токене
/opt/cprocsp/bin/amd64/csptest -keyset -enum -unique</pre>
<p>Для подписи действий оператора в admin-ui:</p>
<ol>
<li>Запросить сертификат на физлицо у УЦ (через личный кабинет КриптоПро или через АРМ оператора УЦ).</li>
<li>Записать сертификат и контейнер на Рутокен.</li>
<li>На <a href="/admin/setup">странице «Настройка»</a> в карточке «Криптография» выбрать провайдер <code>cryptopro</code> и указать слот Рутокен.</li>
</ol>
</div>
<div class="card">
<h2>6. Импорт сертификата</h2>
<pre># сертификат корневого УЦ (если ещё нет в системе)
/opt/cprocsp/bin/amd64/certmgr -inst -store mroot -file /path/to/root-ca.cer
# сертификат подписанта (контейнер на токене)
/opt/cprocsp/bin/amd64/certmgr -inst -store uMy -cont '\\.\HDIMAGE\my-keys' \
-file /path/to/operator.cer
# проверить установленные сертификаты
/opt/cprocsp/bin/amd64/certmgr -list -store uMy</pre>
</div>
<div class="card">
<h2>7. Тестирование подписи</h2>
<p>Через CLI КриптоПро (быстрая проверка что криптография работает):</p>
<pre># подписать произвольный файл
/opt/cprocsp/bin/amd64/cryptcp -signf -dn 'CN=Иванов И.И.' \
-det -strict /tmp/test.txt
# проверить подпись
/opt/cprocsp/bin/amd64/cryptcp -vsignf -det /tmp/test.txt /tmp/test.txt.sgn</pre>
<p>Через нашу систему — раздел <a href="/admin/setup">Настройка</a> → кнопка «Запустить тестовую заявку». На странице «Заявка» появится результат и расшифровка проверки подписи.</p>
</div>
<div class="card">
<h2>8. Поддержка</h2>
<ul>
<li>Документация КриптоПро: <code>www.cryptopro.ru/products/csp</code></li>
<li>Установка на РЕД ОС: <code>www.cryptopro.ru/forum2/default.aspx?g=topics&f=43</code></li>
<li>Технические вопросы: <code>support@cryptopro.ru</code></li>
<li>Рутокен: <code>dev.rutoken.ru/display/PUB/Rutoken+EDS</code></li>
</ul>
<p class="muted">При проблемах с лицензией сначала проверьте <code>cpconfig -license -view</code> — лицензия должна быть валидна и не просрочена. Срок действия КриптоПро лицензии — обычно 1 год.</p>
</div>
{{end}}
@@ -87,7 +87,7 @@
<div class="card">
<h2>8. Подписание заявления</h2>
<p>ЛК должен подписать заявление XMLDSig (ГОСТ или RSA) и положить в поле <code>signed_document</code> (base64). Мы проверяем подпись через crypto-service — см. <a href="/admin/help/crypto">инструкцию по криптографии</a>.</p>
<p>ЛК должен подписать заявление XMLDSig (ГОСТ или RSA) и положить в поле <code>signed_document</code> (base64). Мы проверяем подпись через crypto-service — см. <a href="/admin/help/cryptopro">инструкцию по КриптоПро</a>.</p>
<p class="muted">На M2 проверка подписи отключена (stub). На M3-M4 включится после подключения СКЗИ.</p>
</div>
{{end}}
@@ -43,20 +43,26 @@
<ul>
<li>Профиль (например, <code>test3-gost</code>) — при выборе URL и контейнер заполняются автоматически</li>
<li>URL ONYX — например <code>https://gost-t3.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo</code></li>
<li>Ключевой контейнер — имя контейнера Валидаты с ключами ЭДО НРД (выдаются УЦ НРД, см. ниже)</li>
<li>Ключевой контейнер — имя контейнера КриптоПро с ключами ЭДО НРД (выдаются УЦ НРД, см. ниже)</li>
</ul>
<p class="muted">Без настроенного ИШ система работает в <strong>mock-режиме</strong>: bj-server эмитирует синтетический Decision через 3 секунды для каждой заявки. Это удобно для дев-демо и не требует подключения к НРД.</p>
</div>
<div class="card">
<h2>1а. Сертификаты УЦ НРД (для проверки квитанций)</h2>
<p>НРД подписывает все исходящие пакеты ЭДО ГОСТ-подписью своего УЦ. Для проверки этих подписей на нашей стороне нужно загрузить корневые и промежуточные сертификаты УЦ НРД.</p>
<p>НРД подписывает все исходящие пакеты ЭДО ГОСТ-подписью своего УЦ. Для проверки этих подписей на нашей стороне нужно импортировать корневые сертификаты УЦ НРД в хранилище <code>mroot</code> (доверенные корневые).</p>
<ol>
<li>Скачать сертификаты с сайта УЦ НРД: <code>www.nsd.ru/workflow/system/cryptography/</code> (или из дистрибутива ИШ).</li>
<li>В <a href="/admin/setup">/admin/setup</a> → раздел «Сертификаты УЦ» добавить прямые URL <code>.cer</code>-файлов и нажать «Скачать и импортировать сейчас». Файлы сохраняются в <code>/var/lib/bj/ca-certs/</code> (по SHA-256). Включите «Авто-обновление раз в сутки» — система перепроверит и обновит.</li>
<li>Загруженные через Валидату ключи и сертификаты управляются её собственным справочником (<code>zcs</code>/<code>vdcsp_cfg</code>).</li>
<li>В <a href="/admin/setup">/admin/setup</a> → раздел «Импорт сертификата» → выбрать файл <code>.cer</code>, тип хранилища <code>mroot — корневой УЦ</code>, нажать «Импортировать». Под капотом выполняется <code>certmgr -inst -file root.cer -store mroot</code>.</li>
<li>Промежуточные сертификаты УЦ — в хранилище <code>uRoot</code>.</li>
<li>Для проверки подписей самой системы НРД (квитанции ЭДО) — импортировать сертификат подписи НРД в <code>uMy</code> (как корреспондента), либо оставить в <code>mroot</code>, если он самоподписной.</li>
</ol>
<p><strong>Наши сертификаты для отправки в НРД</strong> (получаются из другого УЦ — нашей организации):</p>
<ol>
<li>Сертификат подписи нашей организации (с приватным ключом в виде <code>.pfx</code>/<code>.p12</code> или на Рутокен) — импортировать в <code>uMy</code> с PIN.</li>
<li>Цепочка сертификатов вашего УЦ — в <code>mroot</code> (корневой) и <code>uRoot</code> (промежуточные).</li>
<li>После импорта проверить: <code>certmgr -list -store uMy</code> и <code>cpverify</code>.</li>
</ol>
<p><strong>Наши сертификаты для отправки в НРД</strong> загружаются в профиль Валидаты её утилитой <code>zcs</code> (импорт ключевого контейнера и сертификата подписи).</p>
<p class="muted">Полный цикл обмена сертификатами с НРД описан в <code>DOC/Инструкция M2M.pdf</code> и <code>DOC/Презентация MOEX MOST.pdf</code>.</p>
</div>
<p><strong>Документация по подключению</strong>: <code>DOC/instr_podkl_stend_v3.pdf</code>, <code>DOC/Ссылки для доступа в тестовые контуры.pdf</code>.</p>
@@ -99,6 +105,7 @@
<tr><td>НРД (Национальный расчётный депозитарий)</td><td>Тестовые сертификаты GUEST/TEST3, дистрибутив ИШ, доступ к личному кабинету УЦ НРД</td></tr>
<tr><td>Команда ЛК (ESIA Finance)</td><td>Базовый URL ЛК, Basic-auth учётные данные, очерёдность подключения (сначала эмулятор, потом реальный ЛК)</td></tr>
<tr><td>Команда Fansy</td><td>Контракт <code>docs/fansy-contract/v1/</code>, SLA, окна обслуживания, IP-allowlist</td></tr>
<tr><td>КриптоПро</td><td>Серийный номер лицензии CSP, актуальный дистрибутив, поддержка <code>support@cryptopro.ru</code></td></tr>
<tr><td>Брокеры-контрагенты MOST</td><td>БКС (ИНН 5406121446), Ренессанс (7709258228), Альфа-Банк (7728168971) — уже в seed</td></tr>
</tbody>
</table>
@@ -1,75 +1,63 @@
{{define "content"}}
{{/* ===== Оператор-дашборд (Apple-стиль): приветствие → статус → плитки задач → сводка ===== */}}
<div class="hero">
<h1 class="hero-greeting">Добрый день</h1>
{{if .AllReady}}
<span class="hero-status ok">● Система готова к работе</span>
{{else}}
<div style="display:flex;align-items:center;gap:14px;flex-wrap:wrap">
<span class="hero-status warn">● Требуется настройка — {{.NotReadyCount}} из {{.TotalCount}} компонентов</span>
<a href="/admin/wizard" class="btn">Открыть мастер настройки →</a>
{{/* Активные новости — сразу под навигацией. Показываем 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="tiles">
<a href="/admin/claims?new=1" class="tile brand">
<span class="ico"></span>
<span class="t-title">Новый перевод</span>
<span class="t-sub">Заявка на перевод ценных бумаг M2M</span>
<span class="t-arrow"></span>
</a>
<a href="/admin/claims" class="tile">
<span class="ico">📋</span>
<span class="t-title">Переводы</span>
<span class="t-sub">{{.Counts.Total}} всего · {{.Counts.InProgress}} в работе</span>
<span class="t-arrow"></span>
</a>
<a href="/admin/status" class="tile">
<span class="ico">🔍</span>
<span class="t-title">Диагностика</span>
<span class="t-sub">Состояние СКЗИ, ИШ и базы</span>
<span class="t-arrow"></span>
</a>
<a href="/admin/setup" class="tile">
<span class="ico">⚙️</span>
<span class="t-title">Настройка</span>
<span class="t-sub">Криптография, НРД, подключения</span>
<span class="t-arrow"></span>
</a>
</div>
{{/* ===== Сводка по переводам ===== */}}
<div class="grid">
<div class="stat">
<div class="stat-label">Всего переводов</div>
<div class="stat-label">Всего сделок</div>
<div class="stat-value">{{.Counts.Total}}</div>
</div>
<div class="stat">
<div class="stat-label">Подтверждено</div>
<div class="stat-value" style="color:var(--ok)">{{.Counts.Confirmed}}</div>
<div class="stat-value" style="color: var(--ok)">{{.Counts.Confirmed}}</div>
</div>
<div class="stat">
<div class="stat-label">В ожидании</div>
<div class="stat-value" style="color:var(--warn)">{{.Counts.InProgress}}</div>
<div class="stat-value" style="color: var(--warn)">{{.Counts.InProgress}}</div>
</div>
<div class="stat">
<div class="stat-label">Отказы / таймауты</div>
<div class="stat-value" style="color:var(--err)">{{.Counts.Failed}}</div>
<div class="stat-value" style="color: var(--err)">{{.Counts.Failed}}</div>
</div>
</div>
{{/* ===== Последние переводы ===== */}}
<div class="section-head">
<h2>Последние переводы</h2>
<a href="/admin/claims">все →</a>
</div>
<div class="card">
<h2>Состояние системы</h2>
{{range .Status.Checks}}
<div style="padding: 6px 0">
<span class="dot {{if .OK}}ok{{else}}err{{end}}"></span>
<strong>{{.Name}}</strong> — {{.Message}}
{{if .Detail}}<span class="muted"> · <code>{{.Detail}}</code></span>{{end}}
</div>
{{end}}
<div class="muted" style="margin-top: 12px">
Профиль: <code>{{.Status.Profile}}</code> · Crypto-провайдер: <code>{{.Status.Provider}}</code>
</div>
</div>
<div class="card">
<h2>Последние заявки</h2>
{{if .Recent}}
<table>
<thead><tr><th>Время</th><th>ID</th><th>Инвестор</th><th>ЦБ</th><th>Статус</th><th></th></tr></thead>
<thead><tr><th>Создана</th><th>ID</th><th>Инвестор</th><th>ЦБ</th><th>Статус</th><th></th></tr></thead>
<tbody>
{{range .Recent}}
<tr>
@@ -84,25 +72,7 @@
</tbody>
</table>
{{else}}
<p class="muted" style="margin:0">Переводов ещё нет. Нажмите «Новый перевод», чтобы создать первый.</p>
{{end}}
</div>
{{/* ===== События (компактно, если есть) ===== */}}
{{if .News}}
<div class="section-head">
<h2>События</h2>
<a href="/admin/news">все →</a>
</div>
<div class="card">
{{range .News}}
<div style="padding:9px 0;border-bottom:1px solid var(--border)">
<div style="font-weight:600;font-size:13.5px">
{{if eq .Kind "maintenance"}}🔧 {{end}}{{if eq .Kind "feature"}}✨ {{end}}{{if eq .Kind "system"}}⚠️ {{end}}{{if eq .Kind "doc-update"}}📄 {{end}}{{.Title}}
</div>
{{if .Body}}<div class="muted" style="font-size:12px;margin-top:3px">{{.Body}}</div>{{end}}
</div>
<p class="muted">Заявок ещё нет. Подайте первую через lk-emulator или POST /api/v1/back_office/claims/.</p>
{{end}}
</div>
{{end}}
{{end}}
@@ -1,106 +0,0 @@
{{define "content"}}
{{/* Пошаговый мастер установки ключа Валидаты на флешку. */}}
<div class="hero">
<h1 class="hero-greeting">Установка ключа на флешку</h1>
<span class="hero-status">Загрузите архив НРД → запись на носитель → справочник сертификатов → проверка → готово</span>
</div>
{{$s := .State}}
{{/* ===== Лента шагов ===== */}}
<div class="card">
<ol style="list-style:none;padding:0;margin:0;display:grid;gap:12px">
{{range $i, $step := $s.Steps}}
<li style="display:flex;gap:12px;align-items:flex-start">
<span style="flex:0 0 28px;height:28px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:13px;
{{if eq $step.Status "ok"}}background:var(--ok-weak);color:var(--ok)
{{else if eq $step.Status "error"}}background:var(--err-weak);color:var(--err)
{{else if eq $step.Status "active"}}background:var(--accent-weak);color:var(--accent)
{{else}}background:var(--surface-2,#eee);color:var(--muted,#999){{end}}">
{{if eq $step.Status "ok"}}✓{{else if eq $step.Status "error"}}✕{{else}}{{add $i 1}}{{end}}
</span>
<div style="flex:1">
<div style="font-weight:600">{{$step.Title}}</div>
{{if $step.Detail}}<div class="muted" style="font-size:13px;margin-top:2px">{{$step.Detail}}</div>{{end}}
</div>
</li>
{{end}}
</ol>
</div>
{{/* ===== Действие в зависимости от состояния ===== */}}
{{if $s.Done}}
<div class="card" style="border-left:3px solid var(--ok)">
<h2>✓ Готово</h2>
<p>Ключ установлен на флешку, справочник сертификатов сформирован, Валидата проверена.</p>
{{if $s.Backup}}<p class="muted">Бэкап прежнего носителя: <code>{{$s.Backup}}</code></p>{{end}}
<div style="display:flex;gap:8px;margin-top:12px;flex-wrap:wrap">
<form method="post" action="/admin/setup/test-nsd" style="margin:0">
<input type="hidden" name="scenario" value="2001">
<button type="submit" class="btn btn-ok">→ Отправить тестовый документ роботу</button>
</form>
<form method="post" action="/admin/setup/keywizard/reset" style="margin:0">
<button type="submit" class="btn btn-secondary">Установить ещё один ключ</button>
</form>
</div>
</div>
{{else if $s.StagingID}}
{{/* Архив загружен — выбор флешки + запись */}}
<div class="card">
<h2>Шаг 2 — выбор флешки и запись</h2>
<p class="muted">Архив распакован. Ключ: <code>{{fallbackTpl $s.VDK "—"}}</code>.
Выберите носитель — запись сделает бэкап, запишет ключ и справочник
сертификатов, дотянет CRL и перезапустит ИШ.</p>
<form method="post" action="/admin/setup/keywizard/install" style="margin-top:12px;display:grid;gap:12px;max-width:640px"
onsubmit="this.querySelector('button[type=submit]').disabled=true;this.querySelector('button[type=submit]').textContent='Устанавливаю…';">
<div>
<label style="font-weight:600;display:block;margin-bottom:6px">Целевая флешка</label>
{{if .Drives}}
{{range $i, $d := .Drives}}
<label style="display:flex;gap:10px;align-items:flex-start;padding:10px;border:1px solid var(--border,#ddd);border-radius:8px;margin-bottom:8px;cursor:pointer">
<input type="radio" name="target_device" value="{{$d.Device}}" {{if $d.IsKeymedia}}checked{{else if and (eq $i 0) (not (anyKeymedia $.Drives))}}checked{{end}} style="margin-top:3px">
<span>
<b>{{fallbackTpl $d.Model "USB-носитель"}}</b> · {{$d.Size}} · {{$d.FSType}}
{{if $d.Label}}· метка «{{$d.Label}}»{{end}}<br>
<span class="muted" style="font-size:12px">{{$d.Device}}{{if $d.Mountpoint}} · {{$d.Mountpoint}}{{end}}
{{if $d.IsKeymedia}}<b style="color:var(--accent)">← текущий ключевой носитель ИШ (рекомендуется)</b>{{end}}</span>
</span>
</label>
{{end}}
{{else}}
<p class="muted">Съёмные носители не обнаружены — будет использован текущий ключевой носитель ИШ по умолчанию.</p>
{{end}}
</div>
<div>
<label style="font-weight:600;display:block;margin-bottom:6px">Имя профиля в справочнике (необязательно)</label>
<input type="text" name="profile_name" placeholder="Авто из архива (напр. PrUser1046)" autocomplete="off"
pattern="[A-Za-z0-9_-]*" style="width:100%">
<span class="muted" style="font-size:12px">Пусто = имя берётся из архива автоматически.</span>
</div>
<button type="submit" class="btn btn-ok">Записать на флешку, сформировать справочник и проверить ИШ</button>
</form>
<form method="post" action="/admin/setup/keywizard/reset" style="margin-top:8px">
<button type="submit" class="btn btn-secondary">Отмена / загрузить другой архив</button>
</form>
</div>
{{else}}
{{/* Начало — форма загрузки */}}
<div class="card">
<h2>Шаг 1 — загрузка архива</h2>
<p class="muted">Выберите .7z-архив с ключом от НРД и введите пароль архива.</p>
<form method="post" action="/admin/setup/keywizard/upload" enctype="multipart/form-data"
style="margin-top:12px;display:grid;gap:10px;max-width:560px">
<input type="file" name="archive" accept=".7z,.zip" required>
<input type="password" name="password" placeholder="Пароль архива (например 11)" autocomplete="off">
<button type="submit" class="btn btn-ok">Загрузить и распаковать</button>
</form>
</div>
{{end}}
<p style="margin-top:16px"><a href="/admin/setup" class="muted">← Назад к настройкам</a></p>
{{end}}
@@ -11,7 +11,7 @@
.news-title { font-size:15px; font-weight:600; margin:0 0 6px 0; }
.news-body { font-size:13px; white-space:pre-wrap; }
.news-validity { margin-top:6px; padding:4px 8px; background:var(--bg); border-radius:4px; display:inline-block; font-size:12px; }
.news-validity.active { background:var(--warn-weak); color:var(--warn); }
.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}}
+317 -377
View File
@@ -1,384 +1,324 @@
{{define "content"}}
{{if .Flash}}<div style="padding:12px 16px;background:var(--ok-weak);border-left:3px solid var(--ok);border-radius:8px;margin-bottom:16px">{{.Flash}}</div>{{end}}
{{if .Flash}}<div style="padding:12px 16px;background:rgba(63,191,108,0.1);border-left:3px solid var(--ok);border-radius:4px;margin-bottom:16px">{{.Flash}}</div>{{end}}
<div class="settings">
{{/* ===== Боковая навигация разделов с индикаторами ===== */}}
<nav class="settings-nav">
<button data-sec="overview" class="active"><span class="nico"></span>Обзор</button>
<button data-sec="db"><span class="nico">🗄️</span>База данных<span class="ind {{if .Settings.Postgres.DSN}}ok{{else}}err{{end}}"></span></button>
<button data-sec="crypto"><span class="nico">🔐</span>Криптография<span class="ind {{if .Settings.Crypto.Profile}}ok{{else}}warn{{end}}"></span></button>
<button data-sec="nsd"><span class="nico">🏛️</span>НРД<span class="ind {{if .Settings.NSD.IGWBaseURL}}ok{{else}}warn{{end}}"></span></button>
<button data-sec="tests"><span class="nico">🧪</span>Тесты</button>
<button data-sec="update"><span class="nico">⬆️</span>Обновления{{if .Settings.Update.Available}}<span class="ind warn"></span>{{end}}</button>
<button data-sec="license"><span class="nico">🔑</span>Лицензия{{if .License.Present}}<span class="ind {{if .License.Valid}}ok{{else}}err{{end}}"></span>{{end}}</button>
</nav>
<div class="settings-body">
{{/* ============ ОБЗОР ============ */}}
<section class="settings-section active" id="sec-overview">
<h1>Обзор</h1>
<div class="card">
<h2>Готовность системы: {{.ReadyCount}} из {{.TotalCount}}</h2>
<div class="grid" style="margin-top:12px">
{{range .Readiness}}
<div class="stat">
<div><span class="dot {{if .Configured}}ok{{else}}err{{end}}"></span><strong>{{.Name}}</strong></div>
<div class="muted" style="font-size:12px;margin-top:4px">{{if .Configured}}настроено{{else}}не настроено{{end}}</div>
</div>
{{end}}
<div class="card">
<h2>Готовность системы: {{.ReadyCount}} из {{.TotalCount}}</h2>
<div style="display:flex;gap:8px;margin-top:8px">
{{range .Readiness}}
<div style="flex:1;text-align:center;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px">
<span class="dot {{if .Configured}}ok{{else}}err{{end}}"></span>
<strong>{{.Name}}</strong><br>
<span class="muted" style="font-size:11px">{{if .Configured}}настроено{{else}}не настроено{{end}}</span>
</div>
</div>
</section>
{{/* ============ БАЗА ДАННЫХ ============ */}}
<section class="settings-section" id="sec-db">
<h1>База данных</h1>
<div class="card">
<h2><span class="dot {{if .Settings.Postgres.DSN}}ok{{else}}err{{end}}"></span>PostgreSQL</h2>
<p class="muted">Журнал сделок m2m-core и принимающая БД. Сейчас: {{if .Settings.Postgres.DSN}}<code>подключено</code>{{else}}<code>in-memory</code> (данные не сохраняются){{end}}.</p>
{{if not .Settings.Postgres.DSN}}
<div style="background:var(--card-2);border:1px solid var(--accent);border-radius:10px;padding:16px;margin-top:12px">
<h3 style="margin:0 0 8px">Подключить автоматически</h3>
<p class="muted" style="margin:0 0 12px">Поднимем локальный PostgreSQL в контейнере, применим миграции и запишем DSN. Для дев-стенда. Для прода — укажите свой DSN ниже.</p>
<form method="post" action="/admin/setup/postgres/quick-start" style="margin:0">
<button type="submit" class="btn">⚡ Поднять локальный PostgreSQL</button>
</form>
</div>
{{end}}
<details {{if not .Settings.Postgres.DSN}}open{{end}} style="margin-top:14px">
<summary style="cursor:pointer;color:var(--accent);font-size:13px">{{if .Settings.Postgres.DSN}}Изменить подключение{{else}}…или ввести DSN вручную{{end}}</summary>
<form method="post" action="/admin/setup/postgres" style="margin-top:12px;display:grid;gap:10px;max-width:640px">
<label>DSN</label>
<input type="text" name="dsn" value="{{.Settings.Postgres.DSN}}" placeholder="postgres://bj:secret@127.0.0.1:5432/bj?sslmode=disable">
<p class="muted" style="margin:0">При сохранении выполняется Ping.</p>
<div><button type="submit" class="btn">Сохранить и проверить</button></div>
</form>
</details>
</div>
</section>
{{/* ============ КРИПТОГРАФИЯ ============ */}}
<section class="settings-section" id="sec-crypto">
<h1>Криптография</h1>
<div class="card" style="border-left:3px solid var(--accent)">
<h2>🔑 Установка ключа на флешку</h2>
<p class="muted">Пошаговый мастер: загрузить архив НРД с паролем → запись на флешку → справочник сертификатов → проверка Валидаты → готово.</p>
<a href="/admin/setup/keywizard" class="btn btn-ok" style="margin-top:10px;display:inline-block">Открыть мастер установки ключа →</a>
</div>
<div class="card">
<h2><span class="dot {{if .Settings.Crypto.Profile}}ok{{else}}warn{{end}}"></span>СКЗИ «Валидата Клиент L»</h2>
<p class="muted">Активный профиль: <code>{{if .Settings.Crypto.Profile}}{{.Settings.Crypto.Profile}}{{else}}—{{end}}</code> · провайдер <code>{{.Settings.Crypto.Provider}}</code>. Подробно — <a href="/admin/help/crypto">справка</a>.</p>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:12px">
<form method="post" action="/admin/setup/crypto/check" style="margin:0"><button type="submit" class="btn btn-ok">✓ Проверить СКЗИ</button></form>
<form method="post" action="/admin/setup/crypto/test-sign" style="margin:0"><button type="submit" class="btn">✎ Тестовая подпись</button></form>
<form method="post" action="/admin/setup/restart-crypto" style="margin:0" onsubmit="return confirm('Перезапустить crypto-service? Поднимется через ~5 сек.');"><button type="submit" class="btn btn-warn">↻ crypto-service</button></form>
<form method="post" action="/admin/setup/restart-server" style="margin:0" onsubmit="return confirm('Перезапустить bj-server? Через 5-10 сек страница вернётся.');"><button type="submit" class="btn btn-secondary">↻ bj-server</button></form>
</div>
<details style="margin-top:14px">
<summary style="cursor:pointer;color:var(--accent);font-size:13px">Параметры провайдера (для совместимости)</summary>
<form method="post" action="/admin/setup/crypto" style="margin-top:12px;display:grid;gap:10px;max-width:640px">
<label>Провайдер</label>
<select name="provider">
<option value="stub" {{if eq .Settings.Crypto.Provider "stub"}}selected{{end}}>stub — без криптографии (демо)</option>
<option value="validata" {{if eq .Settings.Crypto.Provider "validata"}}selected{{end}}>Валидата Клиент L</option>
</select>
<label>Путь к модулю PKCS#11</label>
<input type="text" name="module_path" value="{{.Settings.Crypto.ModulePath}}" placeholder="/opt/Validata/VDCSP/lib/amd64/libvdpkcs11.so">
<label>UDS-сокет</label>
<input type="text" name="socket_path" value="{{.Settings.Crypto.SocketPath}}" placeholder="/run/bj/crypto.sock">
<div><button type="submit" class="btn">Сохранить</button></div>
</form>
</details>
</div>
{{/* Носители ключей */}}
<div class="card">
<h2><span class="dot {{if .Media}}ok{{else}}warn{{end}}"></span>Носители ключей</h2>
<p class="muted">USB-флешки сканируются автоматически. Образы (.iso/.img/.zip/.7z) загружаются ниже — bj-server распакует и найдёт профиль Валидаты, контейнеры, сертификаты.</p>
<form method="post" action="/admin/setup/media/iso/upload" enctype="multipart/form-data" style="margin-top:12px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<input type="file" name="iso" accept=".iso,.img,.zip,.7z" required style="flex:1;min-width:240px">
<input type="password" name="password" placeholder="Пароль архива (для MOEX — 11)" autocomplete="off" style="min-width:200px">
<button type="submit" class="btn">Загрузить</button>
</form>
<p class="muted" style="margin-top:6px;font-size:12px">Лимит 500 МБ. Распаковка через 7z.</p>
{{if .Media}}
{{range .Media}}
<div style="margin-top:14px;padding:14px;background:var(--card-2);border:1px solid var(--border);border-radius:10px">
<div style="display:flex;justify-content:space-between;align-items:center">
<div><strong>{{if eq .Kind "iso"}}📀 ISO{{else}}🔌 USB{{end}}</strong> <code style="font-size:12px;margin-left:8px">{{.Mountpoint}}</code>{{if .Source}}<span class="muted" style="font-size:11px;margin-left:8px">{{.Source}}</span>{{end}}</div>
{{if eq .Kind "iso"}}
<form method="post" action="/admin/setup/media/iso/unmount" style="margin:0" onsubmit="return confirm('Удалить распаковку {{.Mountpoint}}?');">
<input type="hidden" name="id" value="{{.ID}}"><button type="submit" class="btn btn-secondary" style="padding:5px 11px;font-size:12px">Удалить распаковку</button>
</form>
{{end}}
</div>
{{if .Profile}}
<h3>Профиль Валидаты</h3>
<table>
<tbody>
<tr><td>ПСП (.pse)</td><td class="muted" style="font-size:11px">{{range .Profile.PSEFiles}}{{.}}<br>{{end}}</td></tr>
<tr><td>ЛСП (.gdbm)</td><td class="muted" style="font-size:11px">{{range .Profile.GDBMFiles}}{{.}}<br>{{end}}</td></tr>
<tr><td>Ключи (.vdk)</td><td class="muted" style="font-size:11px">{{range .Profile.KeyFiles}}{{.}}<br>{{end}}</td></tr>
</tbody>
</table>
<form method="post" action="/admin/setup/media/import-profile" style="margin-top:8px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<input type="hidden" name="root" value="{{.Profile.Root}}">
<input type="text" name="name" placeholder="Имя профиля, напр. nrd-edo">
{{if .Profile.Imported}}<span style="color:var(--ok)">✓ импортирован</span>{{else}}<button type="submit" class="btn btn-ok" style="padding:6px 12px;font-size:12px">Импортировать профиль</button>{{end}}
</form>
{{end}}
{{if .Containers}}
<h3>Контейнеры ({{len .Containers}})</h3>
<table>
<thead><tr><th>Имя</th><th>Статус</th><th></th></tr></thead>
<tbody>{{range .Containers}}
<tr><td><strong>{{.Name}}</strong> <span class="muted" style="font-size:11px">{{.Path}}</span></td><td>{{if .Imported}}<span style="color:var(--ok)">импортирован</span>{{else}}<span class="muted">нет</span>{{end}}</td>
<td>{{if not .Imported}}<form method="post" action="/admin/setup/media/import-container" style="margin:0"><input type="hidden" name="path" value="{{.Path}}"><button type="submit" class="btn btn-ok" style="padding:5px 11px;font-size:12px">Импортировать</button></form>{{end}}</td></tr>
{{end}}</tbody>
</table>
{{end}}
{{if and (not .Containers) (not .Certificates) (not .Profile)}}<p class="muted" style="margin-top:8px;font-size:12px">Профиль Валидаты не найден на носителе.</p>{{end}}
</div>
{{end}}
{{else}}<p class="muted" style="margin-top:10px">Носители не обнаружены. Подключите USB или загрузите образ.</p>{{end}}
{{if .ImportedProfiles}}
<h3 style="margin-top:18px">Импортированные профили</h3>
<table>
<thead><tr><th>Имя</th><th>Состояние</th><th>Действия</th></tr></thead>
<tbody>{{range .ImportedProfiles}}
<tr><td><strong>{{.}}</strong></td><td>{{if eq . $.Settings.Crypto.Profile}}<span style="color:var(--ok)">✓ активен</span>{{else}}<span class="muted">не активен</span>{{end}}</td>
<td style="display:flex;gap:6px;flex-wrap:wrap">
{{if ne . $.Settings.Crypto.Profile}}<form method="post" action="/admin/setup/media/activate-profile" style="margin:0"><input type="hidden" name="name" value="{{.}}"><button type="submit" class="btn" style="padding:5px 11px;font-size:12px">Активировать</button></form>{{end}}
<form method="post" action="/admin/setup/media/delete-profile" style="margin:0" onsubmit="return confirm('Удалить профиль «{{.}}»?');"><input type="hidden" name="name" value="{{.}}"><button type="submit" class="btn btn-danger" style="padding:5px 11px;font-size:12px">Удалить</button></form>
</td></tr>
{{end}}</tbody>
</table>
{{end}}
</div>
{{/* Сертификаты УЦ */}}
<div class="card">
<h2><span class="dot {{if .Settings.CACerts.URLs}}ok{{else}}warn{{end}}"></span>Сертификаты УЦ (авто-загрузка)</h2>
<p class="muted">URL .cer-файлов УЦ НРД (<a href="https://www.nsd.ru/workflow/system/cryptography/" target="_blank" rel="noopener">nsd.ru</a>). Скачиваются, парсятся и сохраняются в <code>/var/lib/bj/ca-certs/</code>.</p>
<form method="post" action="/admin/setup/cacerts" style="margin-top:10px;display:grid;gap:10px;max-width:720px">
<label>URL'ы (один на строку)</label>
<textarea name="urls" rows="3" style="font-family:ui-monospace,monospace;font-size:12px">{{range .Settings.CACerts.URLs}}{{.}}
{{end}}</textarea>
<label style="display:flex;align-items:center;gap:8px;cursor:pointer"><input type="checkbox" name="auto_update" {{if .Settings.CACerts.AutoUpdate}}checked{{end}} style="width:auto"> Авто-обновление раз в сутки</label>
<div style="display:flex;gap:8px"><button type="submit" class="btn">Сохранить</button></div>
</form>
<form method="post" action="/admin/setup/cacerts/fetch" style="margin-top:8px"><button type="submit" class="btn btn-ok">⬇ Скачать сейчас</button>{{if not .Settings.CACerts.LastFetch.IsZero}}<span class="muted" style="margin-left:10px">обновлено {{.Settings.CACerts.LastFetch.Format "02.01.2006 15:04"}}</span>{{end}}</form>
{{if .Settings.CACerts.FetchedCerts}}
<table style="margin-top:14px">
<thead><tr><th>Владелец</th><th>Тип</th><th>До</th><th>Статус</th></tr></thead>
<tbody>{{range .Settings.CACerts.FetchedCerts}}<tr><td>{{.SubjectCN}}</td><td><code>{{.Store}}</code></td><td>{{if not .NotAfter.IsZero}}{{.NotAfter.Format "02.01.2006"}}{{end}}</td><td>{{if .Error}}<span style="color:var(--err)" title="{{.Error}}">ошибка</span>{{else}}<span style="color:var(--ok)">ок</span>{{end}}</td></tr>{{end}}</tbody>
</table>
{{end}}
</div>
</section>
{{/* ============ НРД ============ */}}
<section class="settings-section" id="sec-nsd">
<h1>НРД</h1>
<div class="card">
<h2><span class="dot {{if .Settings.NSD.IGWBaseURL}}ok{{else}}warn{{end}}"></span>Интеграционный шлюз (ИШ)</h2>
<p class="muted">{{if not .Settings.NSD.IGWBaseURL}}<code>mock-режим</code> — без реального ИШ.{{else}}Профиль <code>{{.Settings.NSD.Profile}}</code>, ИШ <code>{{.Settings.NSD.IGWBaseURL}}</code>.{{end}} Стенды и установка — <a href="/admin/help/systems">справка</a>.</p>
<form method="post" action="/admin/setup/nsd" style="margin-top:12px;display:grid;gap:10px;max-width:680px">
<label>Профиль / контур</label>
<select name="profile" id="nsd-profile">
<option value="" {{if eq .Settings.NSD.Profile ""}}selected{{end}}>— mock (демо) —</option>
<option value="guest-gost" {{if eq .Settings.NSD.Profile "guest-gost"}}selected{{end}}>guest-gost — GUEST, ГОСТ</option>
<option value="guest-rsa" {{if eq .Settings.NSD.Profile "guest-rsa"}}selected{{end}}>guest-rsa — GUEST, RSA</option>
<option value="test3-gost" {{if eq .Settings.NSD.Profile "test3-gost"}}selected{{end}}>test3-gost — TEST3, ГОСТ</option>
<option value="test3-rsa" {{if eq .Settings.NSD.Profile "test3-rsa"}}selected{{end}}>test3-rsa — TEST3, RSA</option>
<option value="prod-gost" {{if eq .Settings.NSD.Profile "prod-gost"}}selected{{end}}>prod-gost — ПРОМ, ГОСТ</option>
<option value="prod-rsa" {{if eq .Settings.NSD.Profile "prod-rsa"}}selected{{end}}>prod-rsa — ПРОМ, RSA</option>
</select>
<label>URL ONYX (WSL) НРД</label>
<input type="text" name="igw_base_url" id="nsd-url" value="{{.Settings.NSD.IGWBaseURL}}" placeholder="автозаполнится по профилю">
<label>Ключевой контейнер</label>
<input type="text" name="key_container" id="nsd-container" value="{{.Settings.NSD.KeyContainer}}" placeholder="напр. TEST3_GOST_CONTAINER">
<hr style="border:none;border-top:1px solid var(--border);margin:6px 0">
<p class="muted" style="margin:0">Депозитарные реквизиты (откуда списываются бумаги) — из договора/письма НРД. Нужны для формирования заявки на перевод.</p>
<label>Депозитарный код</label>
<input type="text" name="deponent_code" value="{{.Settings.NSD.DeponentCode}}" placeholder="напр. MC0413600000">
<label>Депозитарный счёт</label>
<input type="text" name="account_id" value="{{.Settings.NSD.AccountID}}" placeholder="депозитарный счёт">
<label>Раздел счёта</label>
<input type="text" name="section_id" value="{{.Settings.NSD.SectionID}}" placeholder="раздел депозитарного счёта">
<div><button type="submit" class="btn">Сохранить и проверить</button></div>
</form>
<script>
(function() {
var urls = {
"guest-gost": ["https://gost-gt.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "GUEST_GOST_CONTAINER"],
"guest-rsa": ["https://rsa-gt.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "GUEST_RSA_CONTAINER"],
"test3-gost": ["https://gost-t3.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "TEST3_GOST_CONTAINER"],
"test3-rsa": ["https://rsa-t3.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "TEST3_RSA_CONTAINER"],
"prod-gost": ["https://gost.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "PROD_GOST_CONTAINER"],
"prod-rsa": ["https://rsa.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "PROD_RSA_CONTAINER"]
};
var p = document.getElementById("nsd-profile"), u = document.getElementById("nsd-url"), c = document.getElementById("nsd-container");
if (p) p.addEventListener("change", function() {
var v = p.value;
if (urls[v] && (!u.value || confirm("Заменить URL и контейнер на дефолт для " + v + "?"))) { u.value = urls[v][0]; c.value = urls[v][1]; }
});
})();
</script>
</div>
<div class="card">
<h2><span class="dot {{if .Settings.LK.CallbackURL}}ok{{else}}warn{{end}}"></span>Callback в личный кабинет <span class="muted" style="font-size:12px;font-weight:400">(необязательно)</span></h2>
<p class="muted">{{if .Settings.LK.CallbackURL}}<code>{{.Settings.LK.CallbackURL}}</code>{{else}}Не настроен — уведомления в ЛК отключены. Для работы с НРД не требуется.{{end}}</p>
<form method="post" action="/admin/setup/lk" style="margin-top:12px;display:grid;gap:10px;max-width:640px">
<label>Callback URL</label>
<input type="text" name="callback_url" value="{{.Settings.LK.CallbackURL}}" placeholder="http://lk.example.com">
<div><button type="submit" class="btn">Сохранить и проверить</button></div>
</form>
</div>
</section>
{{/* ============ ТЕСТЫ ============ */}}
<section class="settings-section" id="sec-tests">
<h1>Тесты</h1>
<div class="card">
<h2>Тестовый пакет роботу НРД</h2>
<p class="muted">Робот <code>MC0012500000</code> эмулирует вторую сторону перевода. Выберите сценарий — bj-server отправит эталонный запрос через ИШ, ответ придёт во входящие. Требуется настроенный ИШ + профиль Валидаты.</p>
<form method="post" action="/admin/setup/test-nsd" style="margin-top:12px;display:flex;gap:8px;flex-wrap:wrap;align-items:center">
<select name="scenario">
<option value="2001">2001 — Принять все бумаги</option>
<option value="2002">2002 — Принять частично</option>
<option value="1111">1111 — Ответ с отказом</option>
<option value="3333">3333 — Робот принимающая сторона</option>
</select>
<button type="submit" class="btn">Отправить роботу</button>
</form>
<p class="muted" style="margin-top:6px;font-size:12px">Ответ робота — асинхронно (~30-60 сек) во входящие ИШ.</p>
</div>
<div class="card">
<h2>Сквозной тестовый прогон (mock/реальный)</h2>
<p class="muted">Заявка с предзаполненными данными через всю цепочку до финального статуса.</p>
<form method="post" action="/admin/setup/test-run" style="margin-top:12px"><button type="submit" class="btn">Запустить тестовую заявку</button></form>
{{if .Settings.LastTest}}
<div style="margin-top:16px;padding:14px;background:var(--card-2);border:1px solid var(--border);border-radius:10px">
<strong>Последний прогон</strong>
<table style="margin-top:8px">
<tbody>
<tr><td style="width:160px" class="muted">Статус</td><td>{{if .Settings.LastTest.OK}}<span style="color:var(--ok)">✓ успешно</span>{{else}}<span style="color:var(--err)">✗ не прошёл</span>{{end}}</td></tr>
<tr><td class="muted">FSM-статус</td><td><code>{{.Settings.LastTest.FinalStatus}}</code></td></tr>
<tr><td class="muted">ClaimID</td><td><code>{{.Settings.LastTest.ClaimID}}</code> {{if .Settings.LastTest.ClaimID}}<a href="/admin/claims/{{.Settings.LastTest.ClaimID}}">→ карточка</a>{{end}}</td></tr>
<tr><td class="muted">Сообщение</td><td>{{.Settings.LastTest.Message}}</td></tr>
</tbody>
</table>
</div>
{{end}}
</div>
</section>
{{/* ============ ОБНОВЛЕНИЯ ============ */}}
<section class="settings-section" id="sec-update">
<h1>Обновления</h1>
<div class="card">
<h2>Версия bj-server</h2>
<div style="display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin-top:6px">
<span class="stat-value" style="font-size:20px">{{.CurrentVersion}}</span>
{{if .Settings.Update.Available}}
{{if ne .Settings.Update.Available .CurrentVersion}}
<span class="hero-status warn">● Доступна {{.Settings.Update.Available}}</span>
<form method="post" action="/admin/setup/update/apply" style="margin:0" onsubmit="return confirm('Скачать и установить {{.Settings.Update.Available}}? bj-server перезапустится.');">
<button type="submit" class="btn btn-ok">⬆ Установить {{.Settings.Update.Available}}</button>
</form>
{{else}}<span class="hero-status ok">● Актуальная версия</span>{{end}}
{{end}}
<form method="post" action="/admin/setup/update/check" style="margin:0"><button type="submit" class="btn btn-secondary">Проверить обновления</button></form>
</div>
{{if .Settings.Update.Notes}}<p class="muted" style="margin-top:10px">Что нового: {{.Settings.Update.Notes}}</p>{{end}}
{{if not .Settings.Update.LastCheck.IsZero}}<p class="muted" style="margin-top:6px;font-size:12px">Последняя проверка: {{.Settings.Update.LastCheck.Format "02.01.2006 15:04"}} — {{.Settings.Update.LastResult}}</p>{{end}}
{{if and .License.Present (not .License.AllowsUpdates)}}<p class="muted" style="margin-top:6px;font-size:12px;color:var(--warn)">⚠ Текущий план «{{.License.Plan}}» не включает обновления.</p>{{end}}
</div>
<div class="card">
<h2><span class="dot {{if .Settings.Update.BaseURL}}ok{{else}}warn{{end}}"></span>Источник обновлений</h2>
<p class="muted">Артефактория раздаёт подписанные релизы. Обновления проверяются по подписи Ed25519 и sha256 — без валидной подписи установка не выполняется.</p>
<form method="post" action="/admin/setup/update" style="margin-top:12px;display:grid;gap:10px;max-width:680px">
<label>URL артефактории</label>
<input type="text" name="base_url" value="{{.Settings.Update.BaseURL}}" placeholder="https://updates.example.com">
<label>Канal</label>
<select name="channel">
<option value="stable" {{if eq .Settings.Update.Channel "stable"}}selected{{end}}>stable — стабильный</option>
<option value="beta" {{if eq .Settings.Update.Channel "beta"}}selected{{end}}>beta — предварительный</option>
</select>
<label>Публичный ключ издателя (base64 Ed25519)</label>
<input type="text" name="public_key" value="{{.Settings.Update.PublicKey}}" placeholder="зашит в релиз; переопределить здесь">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer"><input type="checkbox" name="auto_check" {{if .Settings.Update.AutoCheck}}checked{{end}} style="width:auto"> Проверять автоматически (раз в 6 часов)</label>
<div><button type="submit" class="btn">Сохранить</button></div>
</form>
</div>
</section>
{{/* ============ ЛИЦЕНЗИЯ ============ */}}
<section class="settings-section" id="sec-license">
<h1>Лицензия</h1>
<div class="card">
<h2>
{{if .License.Valid}}<span class="dot ok"></span>Активна
{{else if .License.Present}}<span class="dot err"></span>Недействительна
{{else}}<span class="dot warn"></span>Не активирована{{end}}
</h2>
{{if .License.Present}}
<table style="margin-top:8px">
<tbody>
{{if .License.Tenant}}<tr><td style="width:180px" class="muted">Организация</td><td><strong>{{.License.Tenant}}</strong></td></tr>{{end}}
{{if .License.Plan}}<tr><td class="muted">План</td><td><span class="badge confirmed">{{.License.Plan}}</span></td></tr>{{end}}
{{if not .License.ExpiresAt.IsZero}}<tr><td class="muted">Действует до</td><td>{{.License.ExpiresAt.Format "02.01.2006"}} {{if .License.Valid}}<span class="muted">(осталось {{.License.DaysLeft}} дн.)</span>{{end}}</td></tr>{{end}}
<tr><td class="muted">Обновления</td><td>{{if .License.AllowsUpdates}}<span style="color:var(--ok)">включены</span>{{else}}<span class="muted">не входят в план</span>{{end}}</td></tr>
<tr><td class="muted">Статус</td><td>{{.License.Message}}</td></tr>
</tbody>
</table>
{{else}}
<p class="muted">Лицензионный ключ не введён. Без лицензии сервис работает, но автообновления заблокированы.</p>
{{end}}
</div>
<div class="card">
<h2>Активация</h2>
<p class="muted">Вставьте лицензионный ключ, полученный от поставщика. Проверка офлайн по подписи — связь с сервером лицензий не требуется.</p>
<form method="post" action="/admin/setup/license" style="margin-top:12px;display:grid;gap:10px;max-width:720px">
<label>Лицензионный ключ</label>
<textarea name="key" rows="3" style="font-family:ui-monospace,monospace;font-size:11px" placeholder="payload.signature.keyid">{{.Settings.License.Key}}</textarea>
<details>
<summary style="cursor:pointer;color:var(--accent);font-size:13px">Публичный ключ издателя (если не зашит)</summary>
<input type="text" name="public_key" value="{{.Settings.License.PublicKey}}" placeholder="base64 Ed25519" style="margin-top:8px;width:100%">
</details>
<div><button type="submit" class="btn">Активировать</button></div>
</form>
</div>
</section>
{{end}}
</div>
</div>
<script>
(function() {
// Переключение разделов админ-центра + запоминание выбранного (hash).
var navs = document.querySelectorAll('.settings-nav button');
var secs = document.querySelectorAll('.settings-section');
function show(id) {
navs.forEach(function(b){ b.classList.toggle('active', b.dataset.sec === id); });
secs.forEach(function(s){ s.classList.toggle('active', s.id === 'sec-' + id); });
try { history.replaceState(null, '', '#' + id); } catch(e) {}
}
navs.forEach(function(b){ b.addEventListener('click', function(){ show(b.dataset.sec); }); });
var h = (location.hash || '').replace('#','');
if (h && document.getElementById('sec-' + h)) show(h);
})();
</script>
<!-- PostgreSQL -->
<div class="card">
<h2><span class="dot {{if .Settings.Postgres.DSN}}ok{{else}}err{{end}}"></span>PostgreSQL</h2>
<p class="muted">Принимающая БД (fansy-store) и журнал сделок m2m-core. Сейчас:
{{if .Settings.Postgres.DSN}}<code>настроено</code>{{else}}<code>in-memory</code> (M2-демо){{end}}.</p>
{{if not .Settings.Postgres.DSN}}
<div style="background:var(--bg);border:1px solid var(--accent);border-radius:6px;padding:14px;margin-top:12px">
<h3 style="margin:0 0 8px 0;font-size:15px">Самый простой вариант — подключить автоматически</h3>
<p class="muted" style="margin:0 0 10px 0">Если у вас ещё нет своего PostgreSQL, мы поднимем его сами в контейнере (podman-compose), применим все миграции и запишем DSN. Подходит для дев-стенда и тестирования. Для прода — лучше указать свой DSN ниже.</p>
<form method="post" action="/admin/setup/postgres/quick-start" style="margin:0">
<button type="submit" class="btn" style="background:var(--accent);color:white;border:none;padding:10px 18px;border-radius:4px;font-weight:600;cursor:pointer">⚡ Поднять локальный PostgreSQL автоматически</button>
<span class="muted" style="margin-left:10px;font-size:12px">Займёт ~10-30 секунд. Требуется установленный <code>podman-compose</code>.</span>
</form>
</div>
{{end}}
<details {{if not .Settings.Postgres.DSN}}style="margin-top:12px"{{end}}>
<summary style="cursor:pointer;color:var(--accent);font-size:13px">{{if .Settings.Postgres.DSN}}Изменить параметры подключения{{else}}…или ввести параметры подключения вручную (для существующего PostgreSQL){{end}}</summary>
<form method="post" action="/admin/setup/postgres" style="margin-top:12px">
<div class="form-row" style="display:grid;grid-template-columns:200px 1fr;gap:12px;align-items:center">
<label>DSN <span class="muted" title="DSN = Data Source Name. Строка вида postgres://пользователь:пароль@хост:порт/база?опции" style="cursor:help">(?)</span></label>
<input type="text" name="dsn" value="{{.Settings.Postgres.DSN}}" placeholder="postgres://bj:secret@127.0.0.1:5432/bj?sslmode=disable" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
</div>
<p class="muted" style="margin-top:8px">При сохранении выполняется Ping. Если БД недоступна — будет ошибка.</p>
<button type="submit" class="btn" style="background:var(--accent);color:white;border:none;padding:8px 16px;border-radius:4px;margin-top:8px">Сохранить и проверить</button>
</form>
</details>
</div>
<!-- СКЗИ через PKCS#11: КриптоПро CSP / Рутокен / Валидата / ViPNet -->
<div class="card">
<h2><span class="dot {{if and .Settings.Crypto.JCPPath .Settings.Crypto.LicenseKey}}ok{{else}}err{{end}}"></span>СКЗИ (КриптоПро CSP, Рутокен и др. через PKCS#11)</h2>
<p class="muted">Go-клиент подключается к СКЗИ напрямую через стандартный PKCS#11 интерфейс. Поддерживаются КриптоПро CSP, Рутокен ЭЦП 2.0, Валидата, ViPNet — один клиент, разные .so модули. Подробно — раздел <a href="/admin/help/cryptopro">«КриптоПро»</a> в инструкциях.</p>
<table style="margin-bottom:12px">
<tr><td style="width:220px" class="muted">Текущий провайдер</td><td><code>{{.Settings.Crypto.Provider}}</code></td></tr>
<tr><td class="muted">Путь к модулю PKCS#11</td><td><code>{{if .Settings.Crypto.JCPPath}}{{.Settings.Crypto.JCPPath}}{{else}}—{{end}}</code></td></tr>
<tr><td class="muted">UDS-сокет (legacy)</td><td><code>{{.Settings.Crypto.SocketPath}}</code></td></tr>
<tr><td class="muted">Лицензия введена</td><td>{{if .Settings.Crypto.LicenseKey}}<span style="color:var(--ok)">да</span>{{else}}<span style="color:var(--err)">нет</span>{{end}}</td></tr>
</table>
<details {{if or (eq .Settings.Crypto.Provider "stub") (not .Settings.Crypto.JCPPath)}}open{{end}}>
<summary style="cursor:pointer;color:var(--accent);font-size:13px">Изменить параметры СКЗИ</summary>
<form method="post" action="/admin/setup/crypto" style="margin-top:12px;display:grid;gap:10px">
<div class="form-row" style="display:grid;grid-template-columns:220px 1fr;gap:12px;align-items:center">
<label>Провайдер СКЗИ</label>
<select name="provider" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
<option value="stub" {{if eq .Settings.Crypto.Provider "stub"}}selected{{end}}>stub — без криптографии (демо)</option>
<option value="cryptopro" {{if eq .Settings.Crypto.Provider "cryptopro"}}selected{{end}}>КриптоПро CSP (через PKCS#11)</option>
<option value="rutoken" {{if eq .Settings.Crypto.Provider "rutoken"}}selected{{end}}>Рутокен ЭЦП 2.0 (для подписи оператора)</option>
<option value="validata" {{if eq .Settings.Crypto.Provider "validata"}}selected{{end}}>Валидата</option>
<option value="vipnet" {{if eq .Settings.Crypto.Provider "vipnet"}}selected{{end}}>ViPNet</option>
</select>
</div>
<div class="form-row" style="display:grid;grid-template-columns:220px 1fr;gap:12px;align-items:center">
<label>Путь к модулю PKCS#11</label>
<input type="text" name="jcp_path" value="{{.Settings.Crypto.JCPPath}}" placeholder="/opt/cprocsp/lib/amd64/libcppkcs11.so" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
</div>
<div class="form-row" style="display:grid;grid-template-columns:220px 1fr;gap:12px;align-items:center">
<label>UDS-сокет (legacy)</label>
<input type="text" name="socket_path" value="{{.Settings.Crypto.SocketPath}}" placeholder="/run/bj/crypto.sock (только для совместимости со старым Java crypto-service)" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
</div>
<div class="form-row" style="display:grid;grid-template-columns:220px 1fr;gap:12px;align-items:flex-start">
<label>Серийный номер лицензии</label>
<textarea name="license_key" rows="3" placeholder="XXXX-XXXXX-XXXXX-XXXXX-XXXXX (серийный номер КриптоПро CSP)" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;font-family:monospace;font-size:12px">{{.Settings.Crypto.LicenseKey}}</textarea>
</div>
<p class="muted">
<strong>КриптоПро CSP</strong>: установить пакеты <code>rpm -i cprocsp-*.rpm</code>, активировать лицензию командой <code>cpconfig -license -set XXXX-...</code>, указать <code>/opt/cprocsp/lib/amd64/libcppkcs11.so</code>.<br>
<strong>Рутокен</strong>: подключить токен USB, указать <code>/usr/lib64/librtpkcs11ecp.so</code>.<br>
Полная инструкция: <a href="/admin/help/cryptopro">/admin/help/cryptopro</a>. При сохранении проверим, что файл модуля существует.
</p>
<div style="display:flex;gap:8px">
<button type="submit" class="btn" style="background:var(--accent);color:white;border:none;padding:8px 16px;border-radius:4px">Сохранить</button>
</div>
</form>
<form method="post" action="/admin/setup/crypto/check" style="margin-top:12px">
<button type="submit" class="btn" style="background:var(--border);color:var(--text);border:none;padding:8px 16px;border-radius:4px">Проверить подключение СКЗИ</button>
<span class="muted" style="margin-left:8px">Загрузит PKCS#11 модуль, опросит список токенов, покажет результат сверху страницы.</span>
</form>
<hr style="margin:18px 0;border:none;border-top:1px solid var(--border)">
<h3 style="font-size:14px;margin:0 0 8px">Установка КриптоПро CSP</h3>
<p class="muted">Дистрибутив с <a href="https://www.cryptopro.ru/products/csp/downloads" target="_blank" rel="noopener">cryptopro.ru</a> (например, <code>linux-amd64.tgz</code> или <code>linux-amd64.tar</code> для РЕД ОС/ALT/ROSA). Загрузите файл здесь — он будет распакован и установлен через <code>sudo rpm -Uvh</code>. Установка длится ~30 секунд.</p>
<form method="post" action="/admin/setup/crypto/install" enctype="multipart/form-data" style="margin-top:12px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<input type="file" name="dist" accept=".tar,.tgz,.gz,.rpm" required style="padding:6px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;flex:1;min-width:300px">
<button type="submit" class="btn" style="background:var(--accent);color:white;border:none;padding:8px 16px;border-radius:4px">Загрузить и установить</button>
</form>
<hr style="margin:18px 0;border:none;border-top:1px solid var(--border)">
<h3 style="font-size:14px;margin:0 0 8px">Сертификаты на токенах</h3>
{{if .Certificates}}
<table>
<thead><tr><th>Кому</th><th>Кем выдан</th><th>ИНН</th><th>Действителен</th><th>Токен</th><th>Приватный ключ</th></tr></thead>
<tbody>
{{range .Certificates}}
<tr>
<td>{{.SubjectCN}}</td>
<td class="muted">{{.IssuerCN}}</td>
<td><code>{{.INN}}</code></td>
<td class="muted">до {{.NotAfter.Format "02.01.2006"}}</td>
<td class="muted">«{{.TokenLabel}}» (slot {{.SlotID}})</td>
<td>{{if .HasPrivateKey}}<span style="color:var(--ok)">есть</span>{{else}}<span class="muted">нет</span>{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="muted">На подключенных токенах сертификатов не найдено. Загрузите .pfx ниже или подключите Рутокен с сертификатом.</p>
{{end}}
<hr style="margin:18px 0;border:none;border-top:1px solid var(--border)">
<h3 style="font-size:14px;margin:0 0 8px">Импорт сертификата (.pfx / .cer / .crt)</h3>
<p class="muted">PFX с приватным ключом (с PIN) — для серверной подписи и подписи оператора. CER/CRT без приватного ключа — для проверки чужих подписей (например, сертификаты УЦ НРД для проверки квитанций). Подробно — <a href="/admin/help/cryptopro">/admin/help/cryptopro</a>.</p>
<form method="post" action="/admin/setup/crypto/import-cert" enctype="multipart/form-data" style="margin-top:8px;display:grid;gap:8px;grid-template-columns:auto auto auto auto;align-items:center">
<input type="file" name="cert" accept=".pfx,.p12,.cer,.crt" required style="padding:6px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
<input type="text" name="pin" placeholder="PIN (только для .pfx/.p12)" style="padding:6px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;font-family:monospace">
<select name="store" style="padding:6px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
<option value="uMy">uMy — личный (для подписи)</option>
<option value="mroot">mroot — корневой УЦ (для проверки)</option>
<option value="uRoot">uRoot — промежуточные УЦ</option>
</select>
<button type="submit" class="btn" style="background:var(--accent);color:white;border:none;padding:8px 16px;border-radius:4px">Импортировать</button>
</form>
<hr style="margin:18px 0;border:none;border-top:1px solid var(--border)">
<h3 style="font-size:14px;margin:0 0 8px">Активация лицензии</h3>
<form method="post" action="/admin/setup/crypto/activate" style="margin-top:6px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<input type="text" name="license_key" value="{{.Settings.Crypto.LicenseKey}}" placeholder="XXXX-XXXXX-XXXXX-XXXXX-XXXXX (серийный номер КриптоПро CSP)" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;font-family:monospace;font-size:12px;min-width:340px">
<button type="submit" class="btn" style="background:var(--ok);color:#0a0f1a;border:none;padding:8px 16px;border-radius:4px;font-weight:600">Активировать лицензию</button>
</form>
<p class="muted" style="margin-top:8px">Вызовет <code>cpconfig -license -set</code> и сохранит серийник. Если КриптоПро CSP ещё не установлен — покажет инструкцию.</p>
</details>
</div>
<!-- Контейнеры КриптоПро на флешке -->
<div class="card">
<h2><span class="dot {{if .FlashContainers}}ok{{else}}warn{{end}}"></span>Контейнеры на USB-носителях (флешка/Рутокен)</h2>
{{if .FlashContainers}}
<p class="muted">Найдено {{len .FlashContainers}} контейнер(а) формата <code>name.000</code> на смонтированных USB-носителях. Кнопка ниже копирует папку в <code>/var/opt/cprocsp/keys/$USER/</code> — после этого контейнер виден как <code>\\.\HDIMAGE\name</code> и работает без вставленной флешки.</p>
<table style="margin-top:8px">
<thead><tr><th>Носитель</th><th>Имя контейнера</th><th>Файлы</th><th>Статус</th><th></th></tr></thead>
<tbody>
{{range .FlashContainers}}
<tr>
<td><code style="font-size:12px">{{.Mountpoint}}</code></td>
<td><strong>{{.Name}}</strong></td>
<td><span class="muted" style="font-size:11px">{{len .Files}} файлов</span></td>
<td>{{if .AlreadyImported}}<span style="color:var(--ok)">уже в HDIMAGE</span>{{else}}<span class="muted">только на флешке</span>{{end}}</td>
<td>
{{if not .AlreadyImported}}
<form method="post" action="/admin/setup/crypto/copy-container" style="margin:0">
<input type="hidden" name="src" value="{{.Path}}">
<button type="submit" class="btn" style="background:var(--ok);color:#0a0f1a;padding:6px 12px;font-size:12px;font-weight:600">Скопировать в локальное хранилище</button>
</form>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="muted">Подключённые USB-носители с контейнерами КриптоПро (папки <code>name.000</code> с *.key) не обнаружены. Поиск идёт в <code>/run/media/$USER/</code>, <code>/media/$USER/</code>, <code>/media/</code>, <code>/mnt/</code>. Вставьте флешку и обновите страницу.</p>
{{end}}
</div>
<!-- Авто-загрузка сертификатов УЦ НРД -->
<div class="card">
<h2><span class="dot {{if .Settings.CACerts.URLs}}ok{{else}}warn{{end}}"></span>Сертификаты УЦ (НРД и др.) — авто-загрузка</h2>
<p class="muted">Прямые URL .cer-файлов УЦ НРД (см. <a href="https://www.nsd.ru/workflow/system/cryptography/" target="_blank" rel="noopener">nsd.ru/workflow/system/cryptography/</a>) и других УЦ. Каждый URL скачивается, парсится X.509, и автоматически импортируется в КриптоПро (<code>mroot</code> для корневых, <code>uRoot</code> для промежуточных). Включите авто-обновление — раз в сутки система перепроверит и переустановит, если сертификат изменился.</p>
<form method="post" action="/admin/setup/cacerts" style="margin-top:10px;display:grid;gap:10px">
<label>URL'ы .cer-файлов (один на строку)</label>
<textarea name="urls" rows="4" placeholder="https://www.nsd.ru/path/to/root-ca.cer&#10;https://www.nsd.ru/path/to/sub-ca.cer" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;font-family:monospace;font-size:12px">{{range .Settings.CACerts.URLs}}{{.}}
{{end}}</textarea>
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" name="auto_update" {{if .Settings.CACerts.AutoUpdate}}checked{{end}}>
<span>Авто-обновление раз в сутки</span>
</label>
<div style="display:flex;gap:8px;align-items:center">
<button type="submit" class="btn">Сохранить</button>
</div>
</form>
<form method="post" action="/admin/setup/cacerts/fetch" style="margin-top:8px">
<button type="submit" class="btn" style="background:var(--ok);color:#0a0f1a;font-weight:600">⬇ Скачать и импортировать сейчас</button>
{{if not .Settings.CACerts.LastFetch.IsZero}}
<span class="muted" style="margin-left:10px">Последнее обновление: {{.Settings.CACerts.LastFetch.Format "02.01.2006 15:04:05"}}</span>
{{end}}
</form>
{{if .Settings.CACerts.FetchedCerts}}
<table style="margin-top:14px">
<thead><tr><th>URL</th><th>Владелец</th><th>Хранилище</th><th>Действителен до</th><th>SHA-256</th><th>Статус</th></tr></thead>
<tbody>
{{range .Settings.CACerts.FetchedCerts}}
<tr>
<td style="max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="{{.URL}}"><code style="font-size:11px">{{.URL}}</code></td>
<td>{{.SubjectCN}}</td>
<td><code>{{.Store}}</code></td>
<td>{{if not .NotAfter.IsZero}}{{.NotAfter.Format "02.01.2006"}}{{end}}</td>
<td><code style="font-size:11px">{{if .SHA256}}{{slice .SHA256 0 12}}…{{end}}</code></td>
<td>{{if .Error}}<span style="color:var(--err)" title="{{.Error}}">ошибка</span>{{else}}<span style="color:var(--ok)">ок</span>{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
{{if .Settings.CACerts.LastFetchLog}}
<details style="margin-top:10px">
<summary style="cursor:pointer;color:var(--accent);font-size:13px">Лог последнего обновления</summary>
<pre style="margin-top:8px">{{.Settings.CACerts.LastFetchLog}}</pre>
</details>
{{end}}
</div>
<!-- nsd-adapter / ИШ НРД -->
<div class="card">
<h2><span class="dot {{if .Settings.NSD.IGWBaseURL}}ok{{else}}err{{end}}"></span>Интеграционный шлюз НРД (ИШ)</h2>
<p class="muted">{{if not .Settings.NSD.IGWBaseURL}}Сейчас <code>mock-режим</code> — Decision эмитируется через 3 секунды после Send.{{else}}Профиль <code>{{.Settings.NSD.Profile}}</code>, ИШ <code>{{.Settings.NSD.IGWBaseURL}}</code>.{{end}}</p>
<p class="muted">Подключение к стендам: <a href="/admin/help/systems">/admin/help/systems</a> — там полная таблица URL контуров GUEST/TEST3/PROD и инструкция по установке ИШ. Дистрибутив ИШ скачивается с <code>www.nsd.ru/workflow/system/programs/#0-widget-faq-0-4</code>.</p>
<details {{if not .Settings.NSD.IGWBaseURL}}open{{end}}>
<summary style="cursor:pointer;color:var(--accent);font-size:13px">Изменить параметры ИШ</summary>
<form method="post" action="/admin/setup/nsd" style="margin-top:12px;display:grid;gap:10px">
<div class="form-row" style="display:grid;grid-template-columns:200px 1fr;gap:12px;align-items:center">
<label>Профиль</label>
<select name="profile" id="nsd-profile" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
<option value="" {{if eq .Settings.NSD.Profile ""}}selected{{end}}>— mock (демо без реального ИШ) —</option>
<option value="guest-gost" {{if eq .Settings.NSD.Profile "guest-gost"}}selected{{end}}>guest-gost — контур GUEST, ГОСТ ключи</option>
<option value="guest-rsa" {{if eq .Settings.NSD.Profile "guest-rsa"}}selected{{end}}>guest-rsa — контур GUEST, RSA ключи</option>
<option value="test3-gost" {{if eq .Settings.NSD.Profile "test3-gost"}}selected{{end}}>test3-gost — контур TEST3, ГОСТ ключи</option>
<option value="test3-rsa" {{if eq .Settings.NSD.Profile "test3-rsa"}}selected{{end}}>test3-rsa — контур TEST3, RSA ключи</option>
<option value="prod-gost" {{if eq .Settings.NSD.Profile "prod-gost"}}selected{{end}}>prod-gost — продуктивный, ГОСТ</option>
<option value="prod-rsa" {{if eq .Settings.NSD.Profile "prod-rsa"}}selected{{end}}>prod-rsa — продуктивный, RSA</option>
</select>
</div>
<div class="form-row" style="display:grid;grid-template-columns:200px 1fr;gap:12px;align-items:center">
<label>URL ONYX (WSL) НРД</label>
<input type="text" name="igw_base_url" id="nsd-url" value="{{.Settings.NSD.IGWBaseURL}}" placeholder="будет заполнено по профилю" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
</div>
<div class="form-row" style="display:grid;grid-template-columns:200px 1fr;gap:12px;align-items:center">
<label>Ключевой контейнер</label>
<input type="text" name="key_container" id="nsd-container" value="{{.Settings.NSD.KeyContainer}}" placeholder="GUEST_GOST_CONTAINER (или ваш контейнер УЦ НРД)" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
</div>
<p class="muted">При смене профиля URL ONYX автозаполнится по таблице НРД (из <code>DOC/Ссылки для доступа в тестовые контуры.pdf</code>). При сохранении проверяется доступность URL.</p>
<button type="submit" class="btn" style="background:var(--accent);color:white;border:none;padding:8px 16px;border-radius:4px">Сохранить и проверить</button>
</form>
<script>
// Автозаполнение URL ONYX и дефолтного контейнера по выбранному профилю.
(function() {
var urls = {
"guest-gost": ["https://gost-gt.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "GUEST_GOST_CONTAINER"],
"guest-rsa": ["https://rsa-gt.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "GUEST_RSA_CONTAINER"],
"test3-gost": ["https://gost-t3.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "TEST3_GOST_CONTAINER"],
"test3-rsa": ["https://rsa-t3.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "TEST3_RSA_CONTAINER"],
"prod-gost": ["https://gost.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "PROD_GOST_CONTAINER"],
"prod-rsa": ["https://rsa.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "PROD_RSA_CONTAINER"]
};
var profile = document.getElementById("nsd-profile");
var urlInput = document.getElementById("nsd-url");
var contInput = document.getElementById("nsd-container");
profile.addEventListener("change", function() {
var p = profile.value;
if (urls[p]) {
if (!urlInput.value || confirm("Заменить URL и контейнер на дефолт для профиля " + p + "?")) {
urlInput.value = urls[p][0];
contInput.value = urls[p][1];
}
}
});
})();
</script>
</details>
</div>
<!-- LK callback -->
<div class="card">
<h2><span class="dot {{if .Settings.LK.CallbackURL}}ok{{else}}err{{end}}"></span>Callback в ЛК</h2>
<p class="muted">{{if .Settings.LK.CallbackURL}}Callback URL: <code>{{.Settings.LK.CallbackURL}}</code>{{else}}Сейчас используется встроенный lk-emulator (он сам зарегистрировал свой адрес при старте).{{end}}</p>
<details {{if not .Settings.LK.CallbackURL}}open{{end}}>
<summary style="cursor:pointer;color:var(--accent);font-size:13px">Указать URL реального ЛК</summary>
<form method="post" action="/admin/setup/lk" style="margin-top:12px">
<div class="form-row" style="display:grid;grid-template-columns:200px 1fr;gap:12px;align-items:center">
<label>Callback URL</label>
<input type="text" name="callback_url" value="{{.Settings.LK.CallbackURL}}" placeholder="http://lk.example.com" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
</div>
<p class="muted" style="margin-top:8px">URL до базового хоста ЛК (без /api). При сохранении выполняется GET <code>{URL}/healthz</code>.</p>
<button type="submit" class="btn" style="background:var(--accent);color:white;border:none;padding:8px 16px;border-radius:4px;margin-top:8px">Сохранить и проверить</button>
</form>
</details>
</div>
<!-- Тестовый прогон -->
<div class="card">
<h2>Тестовый прогон сквозной заявки</h2>
<p class="muted">Создаст заявку с предзаполненными данными (инвестор Иванов И.И., 1500 акций Газпрома, ИИС T03), отправит её через всю цепочку и дождётся финального статуса. Если ИШ НРД настроен — пойдёт в реальный ИШ; иначе через mock с задержкой 3 сек.</p>
<form method="post" action="/admin/setup/test-run" style="margin-top:12px">
<button type="submit" class="btn" style="background:var(--accent);color:white;border:none;padding:10px 20px;border-radius:4px;font-weight:600">Запустить тестовую заявку</button>
</form>
{{if .Settings.LastTest}}
<div style="margin-top:16px;padding:12px;background:var(--bg);border:1px solid var(--border);border-radius:4px">
<strong>Последний прогон:</strong>
<table style="margin-top:8px">
<tr><td style="width:160px" class="muted">Статус</td><td>{{if .Settings.LastTest.OK}}<span style="color:var(--ok)">✓ успешно</span>{{else}}<span style="color:var(--err)">✗ не прошёл</span>{{end}}</td></tr>
<tr><td class="muted">Финальный FSM-статус</td><td><code>{{.Settings.LastTest.FinalStatus}}</code></td></tr>
<tr><td class="muted">ClaimID</td><td><code>{{.Settings.LastTest.ClaimID}}</code> {{if .Settings.LastTest.ClaimID}}<a href="/admin/claims/{{.Settings.LastTest.ClaimID}}">→ открыть карточку</a>{{end}}</td></tr>
<tr><td class="muted">Когда</td><td>{{.Settings.LastTest.StartedAt.Format "02.01.2006 15:04:05"}} — длительность {{.Settings.LastTest.FinishedAt.Sub .Settings.LastTest.StartedAt}}</td></tr>
<tr><td class="muted">Сообщение</td><td>{{.Settings.LastTest.Message}}</td></tr>
</table>
</div>
{{end}}
</div>
{{end}}
@@ -22,7 +22,7 @@
<h2>Что подключается на следующих этапах</h2>
<table>
<tr><td class="muted" style="width:240px">PostgreSQL (схема m2m_core)</td><td>M2-шаг-3: pgx-репозиторий вместо MemoryRepository. Миграция готова — <code>migrations/m2m-core/001__deals.sql</code>.</td></tr>
<tr><td class="muted">crypto-service · Валидата PKCS#11</td><td>M4: установить АПК «Валидата Клиент L» (<code>zpki</code>, <code>zsdk</code>), выставить <code>BJ_CRYPTO_PROVIDER=validata</code> и путь к <code>libvdpkcs11.so</code>. Проверка — Health PKCS#11 должна вернуть <code>provider=validata, ok=true</code>.</td></tr>
<tr><td class="muted">crypto-service · КриптоПро JCP</td><td>M4: положить <code>jcp.jar</code> в <code>services/crypto-service/libs/</code>, выставить <code>BJ_CRYPTO_PROVIDER=cryptopro</code>, заполнить keystore профиля. Проверка — gRPC Health должна вернуть <code>provider=cryptopro, ok=true</code>.</td></tr>
<tr><td class="muted">nsd-adapter · ИШ НРД</td><td>M3: установить ИШ, выставить <code>BJ_NSD_PROFILE=guest-gost</code> или иной, <code>BJ_NSD_IGW_URL=http://localhost:8080</code>. Без этого сейчас используется <code>nsdadapter/mock</code> с эмуляцией ответов через 3 сек.</td></tr>
<tr><td class="muted">Реальный ЛК (ESIA Finance)</td><td>M4: согласовать <code>docs/lk-contract/v1/openapi.yaml</code> с командой ЛК, выставить <code>BJ_LK_CALLBACK_URL</code> на реальный адрес. Сейчас callback идёт в встроенный lk-emulator.</td></tr>
</table>
@@ -1,42 +1,48 @@
{{define "content"}}
<style>
.wiz-head { text-align:center; padding:8px 0 4px; }
.wiz-head h1 { font-size:26px; font-weight:720; letter-spacing:-0.02em; margin:0 0 6px; }
.wiz-head p { color:var(--muted); margin:0; }
.wizard-progress { display:flex; align-items:center; justify-content:center; gap:0; margin:24px auto 28px; max-width:680px; }
.wstep { display:flex; flex-direction:column; align-items:center; gap:7px; flex:1; position:relative; }
.wstep .bub { width:34px; height:34px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:14px; font-weight:650; background:var(--card-2); border:1.5px solid var(--border-strong); color:var(--muted); z-index:1; transition:all .2s; }
.wstep .lbl { font-size:12px; color:var(--muted); font-weight:550; }
.wstep::before { content:""; position:absolute; top:17px; left:-50%; width:100%; height:2px; background:var(--border); z-index:0; }
.wstep:first-child::before { display:none; }
.wstep.done .bub { background:var(--ok); border-color:var(--ok); color:#fff; }
.wstep.done .lbl { color:var(--text-2); }
.wstep.done::before { background:var(--ok); }
.wstep.current .bub { background:var(--accent); border-color:var(--accent); color:#fff; box-shadow:0 0 0 4px var(--accent-weak); }
.wstep.current .lbl { color:var(--accent); font-weight:650; }
.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:var(--accent-weak); border-left:3px solid var(--accent); padding:10px 14px; margin:10px 0; font-size:13px; border-radius:0 8px 8px 0; }
.help-block { 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); }
.wiz-card { max-width:680px; margin:0 auto; }
</style>
<div class="wiz-head">
<h1>Настройка Bridge&amp;Join</h1>
<p>Пошаговый мастер первого запуска — состояние сохраняется после каждого шага</p>
<div class="card">
<h2>Мастер настройки</h2>
<p class="muted">Пошаговая настройка системы. Подходит для первого запуска. После каждого шага состояние сохраняется и можно вернуться позже.</p>
</div>
<div class="wizard-progress">
<div class="wstep {{if .Done.Postgres}}done{{end}} {{if eq .Step 1}}current{{end}}"><span class="bub">{{if .Done.Postgres}}✓{{else}}1{{end}}</span><span class="lbl">База</span></div>
<div class="wstep {{if .Done.Crypto}}done{{end}} {{if eq .Step 2}}current{{end}}"><span class="bub">{{if .Done.Crypto}}✓{{else}}2{{end}}</span><span class="lbl">Валидата</span></div>
<div class="wstep {{if .Done.Certs}}done{{end}} {{if eq .Step 3}}current{{end}}"><span class="bub">{{if .Done.Certs}}✓{{else}}3{{end}}</span><span class="lbl">Сертификаты</span></div>
<div class="wstep {{if .Done.NSD}}done{{end}} {{if eq .Step 4}}current{{end}}"><span class="bub">{{if .Done.NSD}}✓{{else}}4{{end}}</span><span class="lbl">Шлюз НРД</span></div>
<div class="wstep {{if .Done.TestRun}}done{{end}} {{if eq .Step 5}}current{{end}}"><span class="bub">{{if .Done.TestRun}}✓{{else}}5{{end}}</span><span class="lbl">Проверка</span></div>
<div 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 wiz-card" style="border-left:3px solid var(--accent)"><p style="margin:0">{{.Flash}}</p></div>{{end}}
{{if .Flash}}<div class="card" style="border-left:3px solid var(--accent)"><p style="margin:0">{{.Flash}}</p></div>{{end}}
<div class="wiz-card">
{{/* ============= ШАГ 1: PostgreSQL ============= */}}
{{if eq .Step 1}}
<div class="card">
@@ -76,35 +82,60 @@
</div>
{{end}}
{{/* ============= ШАГ 2: Крипто (Валидата) ============= */}}
{{/* ============= ШАГ 2: Крипто ============= */}}
{{if eq .Step 2}}
<div class="card">
<h2><span class="dot {{if .Done.Crypto}}ok{{else}}err{{end}}"></span>Шаг 2. СКЗИ «Валидата Клиент L»</h2>
<h2><span class="dot {{if .Done.Crypto}}ok{{else}}err{{end}}"></span>Шаг 2. Крипто-провайдер (КриптоПро CSP или Рутокен)</h2>
<p>СКЗИ нужен для подписи XMLDSig и проверки квитанций НРД.</p>
<div class="help-block">
<strong>Что это?</strong> АПК «Валидата Клиент L» — российское СКЗИ с поддержкой ГОСТ Р 34.10-2012, поставляемое НРД для подключения к ЭДО. На Linux работает напрямую через PKCS#11 — отдельной лицензии и регистрационных данных <em>не требует</em>.<br>
<strong>Где взять?</strong> Дистрибутив для Astra Linux SE<a href="https://fs.moex.com/cdp/po/ClientL_ALSE.zip" target="_blank">fs.moex.com/cdp/po/ClientL_ALSE.zip</a>. Установка — <code>sudo dpkg -i zpki-*.deb zsdk-*.deb</code> (см. <a href="/admin/help/crypto">/admin/help/crypto</a>).
<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)">Параметры провайдера</summary>
<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="validata — АПК «Валидата Клиент L»; stub — без криптографии (демо-режим без подписи)">?</span></label>
<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="validata" {{if eq .Settings.Crypto.Provider "validata"}}selected{{end}}>Валидата Клиент L</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="После установки пакета zpki модуль находится в /opt/Validata/VDCSP/lib/amd64/libvdpkcs11.so">?</span></label>
<input type="text" name="module_path" value="{{if .Settings.Crypto.ModulePath}}{{.Settings.Crypto.ModulePath}}{{else}}/opt/Validata/VDCSP/lib/amd64/libvdpkcs11.so{{end}}" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;width:100%">
<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}}
@@ -125,17 +156,72 @@
<li>В режиме <strong>ИШ НРД</strong>: подписывает <em>сам ИШ</em> — наш ключ настраивается <em>в ИШ</em>, не здесь. Bj-server нужен только для проверки квитанций НРД и (опц.) расшифровки 4BROKER01.</li>
<li>В режиме <strong>прямого ONYX без ИШ</strong>: bj-server подписывает сам — нужен наш ключ с приватной частью.</li>
</ul>
<strong>Что куда загружать:</strong>
<ul style="margin:6px 0 6px 16px">
<li>Корневой сертификат <strong>УЦ МБ</strong> (<a href="https://ca.moex.com/" target="_blank">ca.moex.com</a>) — для проверки цепочки нашей подписи и подписей контрагентов.</li>
<li>Корневой и подписной <strong>УЦ НРД</strong> (<a href="https://www.nsd.ru/workflow/system/cryptography/" target="_blank">nsd.ru/workflow/system/cryptography/</a>) — для проверки квитанций от НРД.</li>
<li>Наш сертификат с приватным ключом <em>(только если без ИШ)</em> — для подписи пакетов и расшифровки 4BROKER01.</li>
</ul>
<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>) и включить авто-обновление. Раз в сутки система перепроверит и сохранит изменённые сертификаты в <code>/var/lib/bj/ca-certs/</code>.</p>
<p class="muted">Самый простой способ — добавить прямые URL <code>.cer</code>-файлов УЦ НРД (с <a href="https://www.nsd.ru/workflow/system/cryptography/" target="_blank" rel="noopener">nsd.ru/workflow/system/cryptography/</a>) и включить авто-обновление. Раз в сутки система перепроверит и переустановит изменённые сертификаты.</p>
<form method="post" action="/admin/setup/cacerts" style="margin-top:8px;display:grid;gap:10px">
<textarea name="urls" rows="3" placeholder="https://www.nsd.ru/path/to/root-ca.cer&#10;https://www.nsd.ru/path/to/sub-ca.cer" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;font-family:monospace;font-size:12px">{{range .Settings.CACerts.URLs}}{{.}}
{{end}}</textarea>
@@ -206,8 +292,8 @@
<input type="text" name="igw_url" id="nsd-url" value="{{.Settings.NSD.IGWBaseURL}}" placeholder="https://gost-t3.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;width:100%">
</div>
<div>
<label>Ключевой контейнер НРД <span class="tooltip" title="Имя контейнера Валидаты с ключами ЭДО НРД (выдаются УЦ НРД)">?</span></label>
<input type="text" name="key_container" value="{{.Settings.NSD.KeyContainer}}" placeholder="nrd-edo" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;width:100%">
<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>
@@ -273,5 +359,4 @@
</div>
{{end}}
</div>
{{end}}
+51 -266
View File
@@ -1,287 +1,72 @@
{{define "layout"}}<!DOCTYPE html>
<html lang="ru" data-theme="light">
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} · Bridge&Join</title>
<title>{{.Title}} · lk-gateway</title>
<style>
/* ===================== Дизайн-система ===================== */
/* Светлая тема (по умолчанию) */
:root {
--bg:#f5f6f8; --bg-elev:#ffffff; --card:#ffffff; --card-2:#fafbfc;
--border:#e4e7ec; --border-strong:#d0d5dd;
--text:#1a1f29; --text-2:#475067; --muted:#7a8499;
--accent:#2563eb; --accent-weak:rgba(37,99,235,0.10); --accent-strong:#1d4ed8;
--ok:#16a34a; --ok-weak:rgba(22,163,74,0.12);
--warn:#d97706; --warn-weak:rgba(217,119,6,0.12);
--err:#dc2626; --err-weak:rgba(220,38,38,0.12);
--brand:#c5203e; /* MOEX-красный для акцентов бренда */
--shadow:0 1px 2px rgba(16,24,40,0.06), 0 1px 3px rgba(16,24,40,0.10);
--shadow-lg:0 8px 24px rgba(16,24,40,0.12);
--radius:10px; --radius-sm:6px;
}
/* Тёмная тема */
[data-theme="dark"] {
--bg:#0f1115; --bg-elev:#161922; --card:#1a1d24; --card-2:#20242e;
--border:#2a2f3a; --border-strong:#3a4150;
--text:#e8eaed; --text-2:#b4bcc9; --muted:#8b94a3;
--accent:#5b9dff; --accent-weak:rgba(91,157,255,0.14); --accent-strong:#7db0ff;
--ok:#3fbf6c; --ok-weak:rgba(63,191,108,0.16);
--warn:#e8b13a; --warn-weak:rgba(232,177,58,0.16);
--err:#e85a5a; --err-weak:rgba(232,90,90,0.16);
--brand:#ff5a78;
--shadow:0 1px 2px rgba(0,0,0,0.3); --shadow-lg:0 8px 28px rgba(0,0,0,0.5);
}
:root { --bg:#0f1115; --card:#1a1d24; --border:#2a2f3a; --text:#e8eaed; --muted:#8b94a3; --accent:#5b9dff; --ok:#3fbf6c; --warn:#e8b13a; --err:#e85a5a; }
* { box-sizing: border-box; }
html, body { margin:0; padding:0; }
body {
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
background: var(--bg); color: var(--text); line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
/* ---- Шапка ---- */
.topbar {
display:flex; align-items:center; gap:20px;
padding:0 24px; height:58px;
background: var(--bg-elev); border-bottom:1px solid var(--border);
position:sticky; top:0; z-index:100;
}
.brand { display:flex; align-items:center; gap:9px; font-weight:700; font-size:16px; letter-spacing:-0.01em; }
.brand .logo { width:26px; height:26px; border-radius:7px; background:linear-gradient(135deg,var(--accent),var(--brand)); display:inline-block; }
.nav { display:flex; align-items:center; gap:2px; margin-left:8px; }
.nav .group-label { font-size:10px; text-transform:uppercase; letter-spacing:0.06em; color:var(--muted); padding:0 8px 0 14px; border-left:1px solid var(--border); margin-left:6px; }
.nav a {
color:var(--text-2); text-decoration:none; font-size:13.5px; font-weight:500;
padding:7px 11px; border-radius:7px; white-space:nowrap;
}
.nav a:hover { background:var(--card-2); color:var(--text); }
.nav a.active { background:var(--accent-weak); color:var(--accent); }
.topbar-right { margin-left:auto; display:flex; align-items:center; gap:14px; }
.theme-toggle {
background:var(--card-2); border:1px solid var(--border); color:var(--text-2);
width:34px; height:34px; border-radius:8px; cursor:pointer; font-size:15px;
display:flex; align-items:center; justify-content:center; padding:0;
}
.theme-toggle:hover { background:var(--border); color:var(--text); }
.topbar .clock { font-size:12.5px; color:var(--muted); font-variant-numeric:tabular-nums; }
/* ---- Контент ---- */
main { padding:24px; max-width:1200px; margin:0 auto; }
h1 { font-size:22px; font-weight:680; margin:0 0 4px; letter-spacing:-0.01em; }
h2 { font-size:15px; font-weight:650; margin:0 0 12px; }
h3 { font-size:13.5px; font-weight:600; margin:14px 0 6px; }
p { margin:0 0 10px; }
a { color:var(--accent); text-decoration:none; }
a:hover { text-decoration:underline; }
/* ---- Карточки ---- */
.card {
background:var(--card); border:1px solid var(--border); border-radius:var(--radius);
padding:18px 20px; margin-bottom:16px; box-shadow:var(--shadow);
}
.card h2:first-child { margin-top:0; }
.grid { display:grid; gap:14px; grid-template-columns:repeat(auto-fit, minmax(220px,1fr)); }
/* ---- Статы ---- */
.stat { padding:16px 18px; background:var(--card); border:1px solid var(--border); border-radius:var(--radius); box-shadow:var(--shadow); }
.stat-label { font-size:11px; color:var(--muted); text-transform:uppercase; letter-spacing:.05em; font-weight:600; }
.stat-value { font-size:26px; font-weight:700; margin-top:6px; letter-spacing:-0.02em; }
/* ---- Индикаторы ---- */
.dot { display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:7px; vertical-align:middle; }
.dot.ok { background:var(--ok); } .dot.warn { background:var(--warn); } .dot.err { background:var(--err); }
/* ---- Таблицы ---- */
table { width:100%; border-collapse:collapse; font-size:13.5px; }
th, td { text-align:left; padding:9px 12px; border-bottom:1px solid var(--border); }
th { color:var(--muted); font-weight:600; font-size:11px; text-transform:uppercase; letter-spacing:.04em; }
tbody tr:hover td { background:var(--accent-weak); }
/* ---- Бейджи статусов ---- */
.badge { display:inline-block; padding:3px 9px; border-radius:20px; font-size:11px; font-weight:600; }
.badge.draft, .badge.validated, .badge.submitted_to_nsd { background:var(--accent-weak); color:var(--accent); }
.badge.awaiting_decision, .badge.manual_approval { background:var(--warn-weak); color:var(--warn); }
.badge.confirmed, .badge.awaiting_sub16, .badge.done { background:var(--ok-weak); color:var(--ok); }
.badge.rejected, .badge.timed_out, .badge.err { background:var(--err-weak); color:var(--err); }
.badge.ok { background:var(--ok-weak); color:var(--ok); }
/* ---- Код / preformatted ---- */
code { background:var(--card-2); border:1px solid var(--border); padding:1.5px 6px; border-radius:5px; font-size:12px; font-family:ui-monospace,"SF Mono",Menlo,monospace; }
pre { background:var(--card-2); border:1px solid var(--border); border-radius:var(--radius-sm); padding:14px; font-size:12px; overflow:auto; max-height:420px; font-family:ui-monospace,"SF Mono",Menlo,monospace; }
.muted { color:var(--muted); font-size:13px; }
/* ---- Кнопки ---- */
button, .btn {
background:var(--accent); color:#fff; border:1px solid var(--accent);
padding:9px 16px; border-radius:8px; cursor:pointer; font-size:13.5px; font-weight:550;
font-family:inherit; transition:filter .15s;
}
button:hover, .btn:hover { filter:brightness(1.07); text-decoration:none; }
.btn-secondary { background:var(--card-2); color:var(--text); border-color:var(--border-strong); }
.btn-ok { background:var(--ok); border-color:var(--ok); }
.btn-warn { background:var(--warn); border-color:var(--warn); color:#fff; }
.btn-danger { background:var(--err); border-color:var(--err); }
.btn-ghost { background:transparent; color:var(--accent); border-color:transparent; }
/* ---- Формы ---- */
input, select, textarea {
padding:9px 11px; background:var(--bg-elev); border:1px solid var(--border-strong);
color:var(--text); border-radius:8px; font:inherit; font-size:13.5px;
}
input:focus, select:focus, textarea:focus { outline:2px solid var(--accent-weak); border-color:var(--accent); }
label { font-size:13px; font-weight:500; }
/* ---- Баннер режима эмуляции ---- */
.banner-mock { background:var(--warn-weak); border-bottom:1px solid var(--warn); padding:10px 24px; display:flex; align-items:center; gap:12px; font-size:13px; }
/* ---- Hero (приветствие + статус) ---- */
.hero { padding:8px 0 22px; }
.hero-greeting { font-size:28px; font-weight:720; letter-spacing:-0.02em; margin:0 0 6px; }
.hero-status { display:inline-flex; align-items:center; gap:9px; font-size:15px; font-weight:550; padding:7px 16px; border-radius:24px; }
.hero-status.ok { background:var(--ok-weak); color:var(--ok); }
.hero-status.warn { background:var(--warn-weak); color:var(--warn); }
.hero-status.err { background:var(--err-weak); color:var(--err); }
/* ---- Плитки задач (task tiles) ---- */
.tiles { display:grid; gap:16px; grid-template-columns:repeat(auto-fit, minmax(210px,1fr)); margin:8px 0 24px; }
.tile {
display:flex; flex-direction:column; gap:10px;
padding:22px; background:var(--card); border:1px solid var(--border);
border-radius:16px; box-shadow:var(--shadow); cursor:pointer;
text-decoration:none; color:var(--text); transition:transform .14s, box-shadow .14s, border-color .14s;
min-height:128px;
}
.tile:hover { transform:translateY(-3px); box-shadow:var(--shadow-lg); border-color:var(--accent); text-decoration:none; }
.tile .ico { width:46px; height:46px; border-radius:12px; display:flex; align-items:center; justify-content:center; font-size:24px; background:var(--accent-weak); }
.tile.brand .ico { background:linear-gradient(135deg,var(--accent),var(--brand)); }
.tile .t-title { font-size:16px; font-weight:640; letter-spacing:-0.01em; }
.tile .t-sub { font-size:12.5px; color:var(--muted); margin-top:-4px; }
.tile .t-arrow { margin-top:auto; color:var(--muted); font-size:18px; }
.tile:hover .t-arrow { color:var(--accent); }
/* ---- Секция (заголовок + контент) ---- */
.section-head { display:flex; align-items:baseline; justify-content:space-between; margin:24px 0 12px; }
.section-head h2 { margin:0; font-size:17px; }
.section-head a { font-size:13px; }
/* ---- Админ-центр: боковые разделы + контент (macOS System Settings) ---- */
.settings { display:grid; grid-template-columns:236px 1fr; gap:26px; align-items:start; }
.settings-nav { position:sticky; top:78px; display:flex; flex-direction:column; gap:2px; }
.settings-nav button {
display:flex; align-items:center; gap:11px; justify-content:flex-start;
background:transparent; border:1px solid transparent; color:var(--text-2);
padding:10px 13px; border-radius:9px; cursor:pointer; font-size:14px; font-weight:520;
width:100%; text-align:left; transition:background .12s;
}
.settings-nav button:hover { background:var(--card-2); color:var(--text); }
.settings-nav button.active { background:var(--accent-weak); color:var(--accent); }
.settings-nav button .nico { font-size:16px; width:20px; text-align:center; }
.settings-nav button .ind { margin-left:auto; width:8px; height:8px; border-radius:50%; flex:none; }
.settings-nav button .ind.ok { background:var(--ok); }
.settings-nav button .ind.warn { background:var(--warn); }
.settings-nav button .ind.err { background:var(--err); }
.settings-section { display:none; }
.settings-section.active { display:block; }
.settings-section > h1 { margin-bottom:18px; }
@media (max-width:820px) {
.settings { grid-template-columns:1fr; }
.settings-nav { flex-direction:row; overflow-x:auto; position:static; padding-bottom:8px; }
.settings-nav button { white-space:nowrap; width:auto; }
}
/* ---- Toast ---- */
#bj-toast { position:fixed; top:72px; right:24px; z-index:9999; max-width:520px; padding:14px 18px; background:var(--card); border-left:4px solid var(--ok); border-radius:var(--radius-sm); color:var(--text); box-shadow:var(--shadow-lg); font-size:13px; line-height:1.45; opacity:0; transform:translateY(-12px); transition:opacity .25s, transform .25s; }
#bj-toast.visible { opacity:1; transform:translateY(0); }
#bj-toast .close { position:absolute; top:6px; right:10px; cursor:pointer; color:var(--muted); font-size:15px; }
#bj-toast .close:hover { color:var(--text); }
body { margin:0; font-family: system-ui, -apple-system, sans-serif; background: var(--bg); color: var(--text); }
header { padding: 16px 24px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 24px; }
header h1 { margin: 0; font-size: 18px; font-weight: 600; }
header nav a { color: var(--muted); text-decoration: none; margin-right: 16px; font-size: 14px; }
header nav a:hover, header nav a.active { color: var(--text); }
main { padding: 24px; max-width: 1280px; margin: 0 auto; }
h2 { font-size: 16px; margin: 0 0 12px; font-weight: 600; }
.card { background: var(--card); border: 1px solid var(--border); border-radius: 6px; padding: 16px; margin-bottom: 16px; }
.grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
.stat { padding: 12px; background: var(--card); border: 1px solid var(--border); border-radius: 6px; }
.stat-label { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
.stat-value { font-size: 22px; font-weight: 600; margin-top: 4px; }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
.dot.ok { background: var(--ok); }
.dot.warn { background: var(--warn); }
.dot.err { background: var(--err); }
table { width: 100%; border-collapse: collapse; font-size: 14px; }
th, td { text-align: left; padding: 8px 12px; border-bottom: 1px solid var(--border); }
th { color: var(--muted); font-weight: 500; font-size: 12px; text-transform: uppercase; letter-spacing: .04em; }
tr:hover td { background: rgba(91,157,255,0.05); }
a { color: var(--accent); }
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; }
.badge.draft, .badge.validated, .badge.submitted_to_nsd { background: rgba(91,157,255,0.15); color: #5b9dff; }
.badge.awaiting_decision { background: rgba(232,177,58,0.15); color: var(--warn); }
.badge.confirmed, .badge.awaiting_sub16, .badge.done { background: rgba(63,191,108,0.15); color: var(--ok); }
.badge.rejected, .badge.timed_out { background: rgba(232,90,90,0.15); color: var(--err); }
.badge.manual_approval { background: rgba(232,177,58,0.15); color: var(--warn); }
code { background: var(--border); padding: 2px 6px; border-radius: 3px; font-size: 12px; }
.muted { color: var(--muted); font-size: 13px; }
pre { background: #0a0c10; border: 1px solid var(--border); border-radius: 4px; padding: 12px; font-size: 12px; overflow: auto; max-height: 400px; }
button, .btn { background: var(--accent); color: white; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer; font-size: 14px; }
button:hover, .btn:hover { opacity: .9; }
</style>
</head>
<body>
<div id="bj-toast"><span class="close" onclick="document.getElementById('bj-toast').classList.remove('visible')">×</span><div id="bj-toast-text"></div></div>
<header class="topbar">
<span class="brand"><span class="logo"></span>Bridge&amp;Join</span>
<nav class="nav">
<span class="group-label">Оператор</span>
<header>
<h1>lk-gateway</h1>
<nav>
<a href="/admin/" class="{{if eq .Active "home"}}active{{end}}">Дашборд</a>
<a href="/admin/claims" class="{{if eq .Active "claims"}}active{{end}}">Переводы</a>
<a href="/admin/news" class="{{if eq .Active "news"}}active{{end}}">События</a>
<span class="group-label">Администратор</span>
<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/status" class="{{if eq .Active "status"}}active{{end}}">Статус</a>
<a href="/admin/help" class="{{if eq .Active "help"}}active{{end}}">Справка</a>
<a href="/admin/news" class="{{if eq .Active "news"}}active{{end}}">Новости</a>
<a href="/admin/claims" class="{{if eq .Active "claims"}}active{{end}}">Заявки</a>
<a href="/admin/status" class="{{if eq .Active "status"}}active{{end}}">Статус системы</a>
<a href="/admin/help" class="{{if eq .Active "help"}}active{{end}}">Инструкции</a>
</nav>
<div class="topbar-right">
<span class="clock">{{.Now}}</span>
<button class="theme-toggle" id="theme-toggle" title="Светлая/тёмная тема" aria-label="Переключить тему">🌙</button>
</div>
<span class="muted" style="margin-left:auto">{{.Now}}</span>
</header>
{{if .IsMockMode}}
<div class="banner-mock">
<span style="font-size:16px">🟡</span>
<div><strong style="color:var(--warn)">Режим эмуляции</strong> — реального обмена с НРД нет. <span class="muted">{{.MockReason}}</span></div>
<a href="/admin/wizard" style="margin-left:auto">Открыть мастер настройки →</a>
<div style="background:rgba(232,177,58,0.15);border-bottom:2px solid var(--warn);padding:10px 24px;display:flex;align-items:center;gap:12px;font-size:13px">
<span style="font-size:18px">🟡</span>
<div>
<strong style="color:var(--warn)">РЕЖИМ ЭМУЛЯЦИИ</strong> — реального обмена с НРД нет.
<span class="muted" style="margin-left:6px">{{.MockReason}}</span>
</div>
<a href="/admin/wizard" style="margin-left:auto;font-size:13px">Настроить →</a>
</div>
{{end}}
<main>
{{template "content" .}}
</main>
<script>
(function() {
// --- Тема: light/dark, сохранение в localStorage ---
var root = document.documentElement;
var toggle = document.getElementById('theme-toggle');
function applyTheme(t) {
root.setAttribute('data-theme', t);
toggle.textContent = (t === 'dark') ? '☀️' : '🌙';
try { localStorage.setItem('bj-theme', t); } catch(e) {}
}
var saved = 'light';
try { saved = localStorage.getItem('bj-theme') || (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); } catch(e) {}
applyTheme(saved);
toggle.addEventListener('click', function() {
applyTheme(root.getAttribute('data-theme') === 'dark' ? 'light' : 'dark');
});
// --- Toast из ?flash= ---
var toast = document.getElementById('bj-toast');
var toastText = document.getElementById('bj-toast-text');
function showToast(msg) {
toastText.textContent = msg;
toast.classList.add('visible');
var ms = Math.max(5000, Math.min(20000, msg.length * 60));
setTimeout(function() { toast.classList.remove('visible'); }, ms);
}
var params = new URLSearchParams(window.location.search);
var flash = params.get('flash');
if (flash) {
showToast(flash);
params.delete('flash');
var qs = params.toString();
window.history.replaceState({}, '', window.location.pathname + (qs ? '?' + qs : '') + window.location.hash);
}
// --- Сохранение позиции прокрутки через POST-редиректы ---
document.addEventListener('submit', function(ev) {
var f = ev.target;
if (f && f.method && f.method.toLowerCase() === 'post') {
try { sessionStorage.setItem('bj-scroll', String(window.scrollY)); } catch(e) {}
}
}, true);
var sc = null;
try { sc = sessionStorage.getItem('bj-scroll'); } catch(e) {}
if (sc !== null) {
window.requestAnimationFrame(function() {
window.scrollTo(0, parseInt(sc, 10));
try { sessionStorage.removeItem('bj-scroll'); } catch(e) {}
});
}
})();
</script>
</body>
</html>
{{end}}
+1 -5
View File
@@ -725,12 +725,8 @@ func (m M2MTransferDecision) Validate() error {
// GUID/StatusCode/Response объявлены локально в M2MTransferResponse.xsd
// (elementFormDefault="qualified") — namespace элементов response, не
// types, хотя их типы из types.
// XMLName без namespace: реальный НРД-ответ присылает корневой
// M2MTransferResponse БЕЗ namespace на root (дочерние qualified в response
// ns через префикс ns3). Матчим root по локальному имени — толерантно к
// тому, объявлен ли default namespace на корне.
type M2MTransferResponse struct {
XMLName xml.Name `xml:"M2MTransferResponse"`
XMLName xml.Name `xml:"http://nsd.ru/schemas/m2m/response M2MTransferResponse"`
GUID UUID `xml:"http://nsd.ru/schemas/m2m/response GUID"`
StatusCode StatusCode `xml:"http://nsd.ru/schemas/m2m/response StatusCode"`
Responses []Response `xml:"http://nsd.ru/schemas/m2m/response Response"`
-28
View File
@@ -1,7 +1,6 @@
package m2m_test
import (
"encoding/xml"
"errors"
"os"
"path/filepath"
@@ -36,24 +35,6 @@ var roundTripCases = []roundTripCase{
{filepath.Join("..", "..", "DOC", "Эталонные сообщения", "M2MTransferDecision_эталон.xml"), func() any { return new(m2m.M2MTransferDecision) }},
}
// clearXMLNameSpace обнуляет поле Space у верхнеуровневого xml.Name (если
// есть), чтобы round-trip-сравнение не зависело от namespace корня входного
// документа.
func clearXMLNameSpace(v any) {
rv := reflect.ValueOf(v)
if rv.Kind() != reflect.Ptr || rv.IsNil() {
return
}
rv = rv.Elem()
if rv.Kind() != reflect.Struct {
return
}
f := rv.FieldByName("XMLName")
if f.IsValid() && f.CanSet() && f.Type() == reflect.TypeOf(xml.Name{}) {
f.Set(reflect.ValueOf(xml.Name{Local: f.Interface().(xml.Name).Local}))
}
}
func TestRoundTrip(t *testing.T) {
for _, c := range roundTripCases {
c := c
@@ -89,15 +70,6 @@ func TestRoundTrip(t *testing.T) {
t.Fatalf("unmarshal после Marshal: %v", err)
}
// XMLName.Space — это метаданные пространства имён входного XML,
// а не полезная нагрузка. Часть документов НРД присылает с
// namespace на корне (официальные примеры), часть — без (реальный
// ответ робота МОСТ). Парсер намеренно namespace-agnostic, поэтому
// после re-marshal корневой namespace может отличаться. Для
// приёмочных (receive-only) документов это несущественно — сравниваем
// без учёта XMLName.Space.
clearXMLNameSpace(s1)
clearXMLNameSpace(s2)
if !reflect.DeepEqual(s1, s2) {
t.Errorf("round-trip структуры разошлись:\nS1 = %+v\nS2 = %+v", s1, s2)
}
-66
View File
@@ -34,7 +34,6 @@ type Deal struct {
SignedClaim []byte
Request *m2m.M2MTransferRequest
Response *m2m.M2MTransferResponse
RawResponse []byte // точные байты ответа МОСТ от НРД (для пересылки в ТП)
Decision *m2m.M2MTransferDecision
CreatedAt time.Time
UpdatedAt time.Time
@@ -170,71 +169,6 @@ func (d *Deal) ReceiveDecision(_ context.Context, decision *m2m.M2MTransferDecis
return nil
}
// ReceiveServiceResponse принимает M2MTransferResponse — ответ сервиса МОСТ
// (НРД), а не контрагента. Это сервисный уровень: подтверждение приёма в
// обработку (StatusCode=INFO) либо отказ сервиса (StatusCode=ERROR, код
// M2Mxx — напр. M2M14 «код отправителя отсутствует в справочнике M2M»).
//
// - INFO — заявка принята МОСТ в обработку; состояние не меняем, ждём
// M2MTransferDecision контрагента. Сохраняем ответ для отображения.
// - ERROR — сервис отклонил заявку до контрагента; Decision уже не придёт,
// переводим сделку в Rejected. Сохраняем ответ, чтобы в карточке был
// виден код и текст отказа НРД.
// raw — точные байты ответа (unpacked doc.xml от НРД); сохраняются как есть
// для дословной пересылки в техподдержку НРД. Может быть nil (mock-режим).
func (d *Deal) ReceiveServiceResponse(_ context.Context, resp *m2m.M2MTransferResponse, raw []byte) error {
d.mu.Lock()
defer d.mu.Unlock()
if resp == nil {
return fmt.Errorf("m2mcore: ReceiveServiceResponse: resp=nil")
}
d.Response = resp
if len(raw) > 0 {
d.RawResponse = raw
}
d.UpdatedAt = time.Now().UTC()
if resp.StatusCode != m2m.StatusError {
// INFO/приём в обработку — фиксируем, но не двигаем FSM.
d.recordEvent("service_ack", responsePayload(resp), "nsd")
return nil
}
// ERROR — отказ сервиса МОСТ. Если сделка уже в терминальном состоянии,
// повторный ответ игнорируем (идемпотентность поллера).
if IsTerminal(d.State) {
d.recordEvent("service_error_ignored_terminal", responsePayload(resp), "nsd")
return nil
}
if err := d.shiftTo(StateRejected, "service_error:"+firstCode(resp)); err != nil {
return err
}
d.recordEvent("service_error", responsePayload(resp), "nsd")
return nil
}
// responsePayload сворачивает M2MTransferResponse в компактную запись для
// журнала событий (коды + тексты ответов сервиса).
func responsePayload(resp *m2m.M2MTransferResponse) map[string]any {
codes := make([]map[string]string, 0, len(resp.Responses))
for _, r := range resp.Responses {
codes = append(codes, map[string]string{"code": r.Code, "text": r.Text})
}
return map[string]any{
"status": string(resp.StatusCode),
"guid": string(resp.GUID),
"codes": codes,
}
}
// firstCode возвращает первый код ответа (для reason перехода).
func firstCode(resp *m2m.M2MTransferResponse) string {
if len(resp.Responses) > 0 {
return resp.Responses[0].Code
}
return "unknown"
}
// Timeout переводит сделку в TimedOut (когда не дождались Decision).
func (d *Deal) Timeout(_ context.Context) error {
d.mu.Lock()
-10
View File
@@ -19,11 +19,6 @@ type ClaimInput struct {
CostInfo m2m.CostInfo
IIAAgreement *m2m.IIAAgreementDetails
Securities []ClaimSecurityInput
// InvestorDocument, если задан, переопределяет документ инвестора из
// анкеты Fansy. Нужен для тестов с роботом НРД, где код сценария
// кодируется в серии ДУЛ (DocumentSeries). Для обычных заявок — nil
// (личность берётся из анкеты-источника).
InvestorDocument *ClientDocument
}
// ClaimSecurityInput — одна ЦБ в заявке.
@@ -81,11 +76,6 @@ func EnrichRequest(
if err != nil {
return nil, fmt.Errorf("m2mcore: GetClientByID: %w", err)
}
// Переопределение документа инвестора (тест с роботом: серия ДУЛ = код
// сценария). Меняем только удостоверение личности, ФИО оставляем из анкеты.
if claim.InvestorDocument != nil {
client.Document = *claim.InvestorDocument
}
accounts, err := store.GetDepoAccounts(ctx, claim.InvestorClientID, claim.TransferringDepositoryINN)
if err != nil {
return nil, fmt.Errorf("m2mcore: GetDepoAccounts: %w", err)
-51
View File
@@ -105,57 +105,6 @@ func TestEnrichRequestHappyPath(t *testing.T) {
}
}
// TestEnrichRequestDocumentOverride — для теста с роботом НРД серия ДУЛ
// кодирует сценарий. Override должен заместить паспорт из анкеты, оставив ФИО.
func TestEnrichRequestDocumentOverride(t *testing.T) {
store := &fakeStore{
client: &m2mcore.Client{
ID: "inv-1", LastName: "Иванов", FirstName: "Иван", MiddleName: "Иванович",
Document: m2mcore.ClientDocument{
DocumentType: m2m.DocCode21, Series: "4513", Number: "654322",
},
},
accounts: []m2mcore.DepoAccount{{
ID: "acc-1", ClientID: "inv-1", DeponentCode: "DP789456",
AccountID: m2m.AccountID("31MC0021900000F01"), SectionID: "P001",
DepositoryINN: m2m.OrganizationINN("7702070139"),
}},
}
whole := uint64(1)
isin := m2m.ISIN("RU0007661625")
claim := m2mcore.ClaimInput{
InvestorClientID: "inv-1",
TransferringDepositoryINN: m2m.OrganizationINN("0702345678"),
ReceivingDepositoryINN: m2m.OrganizationINN("0710987654"),
CostInfo: m2m.CostInfo{No: &m2m.CostInfoNo{}},
Securities: []m2mcore.ClaimSecurityInput{{
SecurityCode: m2m.SecurityCode("RU0007661625"),
Details: m2m.SecurityDetails{ISIN: &isin}, Quantity: m2m.Quantity{Whole: &whole},
}},
// Сценарий робота 2001 в серии ДУЛ.
InvestorDocument: &m2mcore.ClientDocument{
DocumentType: m2m.DocCode21, Series: "2001", Number: "111111",
},
}
req, err := m2mcore.EnrichRequest(context.Background(), store, claim, m2mcore.SenderReceiver{
SenderCode: m2m.DeponentCode("MC0413600000"), ReceiverCode: m2m.DeponentCode("MC0012500000"),
})
if err != nil {
t.Fatalf("EnrichRequest: %v", err)
}
doc := req.Data.InvestorInformation.IdentityDocument
if doc.DocumentSeries == nil || string(*doc.DocumentSeries) != "2001" {
t.Errorf("DocumentSeries = %v, ожидалось 2001 (override сценария)", doc.DocumentSeries)
}
if string(doc.DocumentNumber) != "111111" {
t.Errorf("DocumentNumber = %q, ожидалось 111111", doc.DocumentNumber)
}
// ФИО берётся из анкеты, не из override.
if req.Data.InvestorInformation.LastName != "Иванов" {
t.Errorf("ФИО должно остаться из анкеты, получено %q", req.Data.InvestorInformation.LastName)
}
}
func TestEnrichRequestNoAccounts(t *testing.T) {
store := &fakeStore{
client: &m2mcore.Client{LastName: "X", FirstName: "Y", Document: m2mcore.ClientDocument{DocumentType: m2m.DocCode21, Number: "1"}},
+2 -16
View File
@@ -54,7 +54,7 @@ func (r *PostgresRepository) Close() {
// guid уже есть, возвращает её (без модификации).
func (r *PostgresRepository) Create(ctx context.Context, deal *Deal) (*Deal, error) {
reqXML, _ := marshalXMLIfPresent(deal.Request)
respXML := responseBytes(deal)
respXML, _ := marshalXMLIfPresent(deal.Response)
decisionXML, _ := marshalXMLIfPresent(deal.Decision)
stages, err := json.Marshal(deal.Stages)
if err != nil {
@@ -97,7 +97,7 @@ func (r *PostgresRepository) GetByID(ctx context.Context, id string) (*Deal, err
// Update сохраняет полное состояние сделки (для простоты — без diff).
func (r *PostgresRepository) Update(ctx context.Context, deal *Deal) error {
reqXML, _ := marshalXMLIfPresent(deal.Request)
respXML := responseBytes(deal)
respXML, _ := marshalXMLIfPresent(deal.Response)
decisionXML, _ := marshalXMLIfPresent(deal.Decision)
stages, err := json.Marshal(deal.Stages)
if err != nil {
@@ -229,8 +229,6 @@ func scanRow(r rowScanner) (*Deal, error) {
}
}
if len(respXML) > 0 {
// Сохраняем точные байты для дословного показа/пересылки в ТП НРД.
d.RawResponse = respXML
var v m2m.M2MTransferResponse
if err := nsdxml.Unmarshal(respXML, &v); err == nil {
d.Response = &v
@@ -256,18 +254,6 @@ func dealsSelectSQL() string {
FROM m2m_core.deals`
}
// responseBytes возвращает байты ответа МОСТ для записи в response_xml:
// точные байты от НРД (RawResponse), если они есть, иначе пере-сериализация
// разобранной структуры. Точные байты предпочтительны — их можно дословно
// переслать в техподдержку НРД.
func responseBytes(deal *Deal) []byte {
if len(deal.RawResponse) > 0 {
return deal.RawResponse
}
b, _ := marshalXMLIfPresent(deal.Response)
return b
}
// marshalXMLIfPresent сериализует *T в windows-1251 XML (или возвращает nil).
func marshalXMLIfPresent(v any) ([]byte, error) {
if v == nil {
-85
View File
@@ -1,85 +0,0 @@
package m2mcore_test
import (
"context"
"testing"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
)
// makeResponse строит M2MTransferResponse сервиса МОСТ с заданным статусом и
// одним кодом/текстом (как в реальном ответе НРД, напр. M2M14).
func makeResponse(guid m2m.UUID, status m2m.StatusCode, code, text string) *m2m.M2MTransferResponse {
return &m2m.M2MTransferResponse{
GUID: guid,
StatusCode: status,
Responses: []m2m.Response{{Code: code, Text: text}},
}
}
// TestReceiveServiceResponseError — ERROR (отказ сервиса, напр. M2M14)
// переводит сделку в Rejected и сохраняет ответ для отображения в карточке.
func TestReceiveServiceResponseError(t *testing.T) {
d := newTestDeal(t)
resp := makeResponse(d.GUID, m2m.StatusError, "M2M14",
"Код ЭДО НРД отправителя отсутствует в справочнике участников M2M")
if err := d.ReceiveServiceResponse(context.Background(), resp, nil); err != nil {
t.Fatalf("ReceiveServiceResponse(ERROR): %v", err)
}
if d.State != m2mcore.StateRejected {
t.Errorf("состояние %s, ожидалось %s", d.State, m2mcore.StateRejected)
}
if d.Response == nil || d.Response.StatusCode != m2m.StatusError {
t.Errorf("ответ НРД не сохранён в сделке: %+v", d.Response)
}
if len(d.Response.Responses) == 0 || d.Response.Responses[0].Code != "M2M14" {
t.Errorf("код ответа не сохранён: %+v", d.Response)
}
}
// TestReceiveServiceResponseInfo — INFO (принято в обработку) сохраняет ответ,
// но НЕ меняет состояние: ждём M2MTransferDecision контрагента.
func TestReceiveServiceResponseInfo(t *testing.T) {
d := newTestDeal(t)
before := d.State
resp := makeResponse(d.GUID, m2m.StatusInfo, "01", "Запрос на перевод принят в обработку")
if err := d.ReceiveServiceResponse(context.Background(), resp, nil); err != nil {
t.Fatalf("ReceiveServiceResponse(INFO): %v", err)
}
if d.State != before {
t.Errorf("состояние изменилось на %s, ожидалось без изменений (%s)", d.State, before)
}
if d.Response == nil || d.Response.StatusCode != m2m.StatusInfo {
t.Errorf("ответ НРД не сохранён: %+v", d.Response)
}
}
// TestReceiveServiceResponseTerminalIdempotent — повторный ERROR на уже
// терминальной (Rejected) сделке не валит FSM, ответ обновляется.
func TestReceiveServiceResponseTerminalIdempotent(t *testing.T) {
d := newTestDeal(t)
first := makeResponse(d.GUID, m2m.StatusError, "M2M14", "первый отказ")
if err := d.ReceiveServiceResponse(context.Background(), first, nil); err != nil {
t.Fatalf("первый ERROR: %v", err)
}
// Поллер ИШ может прислать тот же пакет повторно — не должно паниковать
// и не должно ломать состояние недопустимым переходом Rejected→Rejected.
second := makeResponse(d.GUID, m2m.StatusError, "M2M14", "повторный отказ")
if err := d.ReceiveServiceResponse(context.Background(), second, nil); err != nil {
t.Fatalf("повторный ERROR на терминальной сделке: %v", err)
}
if d.State != m2mcore.StateRejected {
t.Errorf("состояние %s, ожидалось %s", d.State, m2mcore.StateRejected)
}
}
// TestReceiveServiceResponseNil — защита от nil.
func TestReceiveServiceResponseNil(t *testing.T) {
d := newTestDeal(t)
if err := d.ReceiveServiceResponse(context.Background(), nil, nil); err == nil {
t.Error("ожидалась ошибка на nil-ответе")
}
}
+7 -18
View File
@@ -25,7 +25,6 @@ import (
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"strconv"
@@ -98,30 +97,20 @@ func (c *Client) SendPackage(ctx context.Context, channel, packageType string, z
if channel == "" {
return "", errors.New("igw: channel пустой")
}
_ = packageType // тип пакета (#M2MTR) кладётся внутрь ZIP/config.xml через pack.go
_ = packageType // не используется в новом API, кладётся внутрь ZIP/config.xml
if len(zipBody) == 0 {
return "", errors.New("igw: zipBody пустой")
}
// ИШ REST API ждёт multipart/form-data: поле File (binary ZIP) +
// Type (enum InFileType: FILE | ARCHIVE). Для ZIP-пакета — ARCHIVE.
// Ответ: {"id": <int64>}. См. Swagger /api/package/{channel}/file.
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
if err := mw.WriteField("Type", "ARCHIVE"); err != nil {
return "", fmt.Errorf("igw: multipart Type: %w", err)
payload := sendBody{
Type: "archive",
File: base64.StdEncoding.EncodeToString(zipBody),
}
fw, err := mw.CreateFormFile("File", "package.zip")
raw, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("igw: multipart File: %w", err)
}
if _, err := fw.Write(zipBody); err != nil {
return "", fmt.Errorf("igw: write zip в multipart: %w", err)
}
if err := mw.Close(); err != nil {
return "", fmt.Errorf("igw: multipart close: %w", err)
return "", fmt.Errorf("igw: marshal payload: %w", err)
}
path := fmt.Sprintf("/api/package/%s/file", url.PathEscape(channel))
resp, err := c.doRetry(ctx, http.MethodPost, path, &buf, mw.FormDataContentType())
resp, err := c.doRetry(ctx, http.MethodPost, path, bytes.NewReader(raw), "application/json")
if err != nil {
return "", err
}
+12 -12
View File
@@ -4,7 +4,6 @@ import (
"context"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
@@ -21,22 +20,23 @@ func TestSendPackageHappyPath(t *testing.T) {
if r.URL.Path != "/api/package/CH1/file" {
t.Errorf("неожиданный путь %q", r.URL.Path)
}
// ИШ ждёт multipart/form-data: поля Type (FILE|ARCHIVE) + File (binary).
if err := r.ParseMultipartForm(10 << 20); err != nil {
t.Fatalf("parse multipart: %v", err)
// Проверим что тело — это {Type: "archive", File: base64}.
var body struct {
Type string `json:"Type"`
File string `json:"File"`
}
if got := r.FormValue("Type"); got != "ARCHIVE" {
t.Errorf("Type = %q, ожидалось ARCHIVE", got)
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode body: %v", err)
}
f, _, err := r.FormFile("File")
if err != nil {
t.Fatalf("form file: %v", err)
if body.Type != "archive" {
t.Errorf("Type = %q, ожидалось archive", body.Type)
}
defer f.Close()
b, _ := io.ReadAll(f)
if len(b) == 0 {
if body.File == "" {
t.Errorf("File пустой")
}
if _, err := base64.StdEncoding.DecodeString(body.File); err != nil {
t.Errorf("File не base64: %v", err)
}
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]any{"id": 123})
}))
+7 -18
View File
@@ -40,21 +40,15 @@ func (s *Sender) Send(ctx context.Context, req *m2m.M2MTransferRequest) (*m2m.M2
if err := req.Validate(); err != nil {
return nil, fmt.Errorf("nsdadapter: req.Validate: %w", err)
}
body, err := nsdxml.Marshal(req)
if err != nil {
return nil, fmt.Errorf("nsdadapter: marshal Request: %w", err)
}
pkgType, err := RouteToPackageType(KindTransferRequest)
if err != nil {
return nil, err
}
// ИШ ждёт ZIP-архив (Type=ARCHIVE): doc.xml + config.xml с типом пакета.
// Сырой XML ИШ отвергает ("Bad signature" при распаковке). PackRequest
// собирает корректный ZIP с config.xml (#M2MTR).
body, err := igw.PackRequest(req, "M2MTransferRequest.xml")
if err != nil {
return nil, fmt.Errorf("nsdadapter: PackRequest: %w", err)
}
// ИШ резолвит канал по СОСТАВНОМУ коду: <код канала>+<код депонента>
// (так его формирует ИШ при создании канала: напр. TEST3+MC0413600000).
channel := s.profile.Channel + string(req.Header.SenderCode)
pkgID, err := s.client.SendPackage(ctx, channel, string(pkgType), body)
pkgID, err := s.client.SendPackage(ctx, s.profile.Channel, string(pkgType), body)
if err != nil {
return nil, fmt.Errorf("nsdadapter: SendPackage: %w", err)
}
@@ -73,7 +67,7 @@ func (s *Sender) SendDecision(ctx context.Context, decision *m2m.M2MTransferDeci
if err := decision.Validate(); err != nil {
return fmt.Errorf("nsdadapter: decision.Validate: %w", err)
}
xmlBytes, err := nsdxml.Marshal(decision)
body, err := nsdxml.Marshal(decision)
if err != nil {
return fmt.Errorf("nsdadapter: marshal Decision: %w", err)
}
@@ -81,12 +75,7 @@ func (s *Sender) SendDecision(ctx context.Context, decision *m2m.M2MTransferDeci
if err != nil {
return err
}
body, err := igw.PackXML(xmlBytes, "M2MTransferDecision.xml", string(pkgType))
if err != nil {
return fmt.Errorf("nsdadapter: PackXML: %w", err)
}
channel := s.profile.Channel + string(decision.Header.SenderCode)
if _, err := s.client.SendPackage(ctx, channel, string(pkgType), body); err != nil {
if _, err := s.client.SendPackage(ctx, s.profile.Channel, string(pkgType), body); err != nil {
return fmt.Errorf("nsdadapter: SendPackage: %w", err)
}
return nil
+2 -5
View File
@@ -88,11 +88,8 @@ func TestSenderSend(t *testing.T) {
if err != nil {
t.Fatalf("Send: %v", err)
}
// ИШ резолвит канал по составному коду: код канала + код депонента-
// отправителя (channels.code = channelCode+deponentCode). Для профиля
// test3-gost и отправителя MC0079200000 это TEST3MC0079200000.
if mock.gotChannel != "TEST3MC0079200000" {
t.Errorf("channel = %q, ожидалось TEST3MC0079200000", mock.gotChannel)
if mock.gotChannel != "TEST3" {
t.Errorf("channel = %q, ожидалось TEST3", mock.gotChannel)
}
if mock.gotType != string(nsdadapter.PackageM2MTransferRequest) {
t.Errorf("type = %q, ожидалось %s", mock.gotType, nsdadapter.PackageM2MTransferRequest)
-110
View File
@@ -1,110 +0,0 @@
package release
import (
"context"
"crypto/ed25519"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
// Client — клиент артефактории: скачивает подписанный манифест и артефакты,
// проверяет подпись зашитым публичным ключом и sha256 каждого файла.
type Client struct {
BaseURL string // напр. https://updates.example.com
Channel string // "stable" | "beta"
Pub ed25519.PublicKey // публичный ключ издателя (зашит в bj-server)
HTTP *http.Client
}
// NewClient собирает клиент. pubB64 — публичный ключ в base64 (зашитый).
func NewClient(baseURL, channel, pubB64 string) (*Client, error) {
pub, err := ParsePublicKey(pubB64)
if err != nil {
return nil, fmt.Errorf("release: публичный ключ: %w", err)
}
return &Client{
BaseURL: strings.TrimRight(baseURL, "/"),
Channel: channel,
Pub: pub,
HTTP: &http.Client{Timeout: 60 * time.Second},
}, nil
}
// FetchManifest скачивает /v1/<channel>/manifest.json и проверяет подпись.
// Возвращает доверенный Manifest (или ошибку, если подпись не сошлась).
func (c *Client) FetchManifest(ctx context.Context) (*Manifest, error) {
url := fmt.Sprintf("%s/v1/%s/manifest.json", c.BaseURL, c.Channel)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, fmt.Errorf("release: запрос манифеста: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("release: манифест HTTP %d", resp.StatusCode)
}
var sm SignedManifest
if err := json.NewDecoder(resp.Body).Decode(&sm); err != nil {
return nil, fmt.Errorf("release: разбор манифеста: %w", err)
}
return Verify(&sm, c.Pub) // здесь проверяется подпись
}
// DownloadArtifact скачивает артефакт в destDir, проверяет sha256/размер по
// манифесту, выставляет +x при необходимости. Возвращает путь к файлу.
// Скачивает во временный файл и переименовывает атомарно.
func (c *Client) DownloadArtifact(ctx context.Context, a Artifact, destDir string) (string, error) {
url := fmt.Sprintf("%s/v1/%s/files/%s", c.BaseURL, c.Channel, a.File)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", err
}
resp, err := c.HTTP.Do(req)
if err != nil {
return "", fmt.Errorf("release: скачивание %s: %w", a.Name, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("release: %s HTTP %d", a.Name, resp.StatusCode)
}
tmp, err := os.CreateTemp(destDir, "."+a.File+".dl-*")
if err != nil {
return "", err
}
tmpName := tmp.Name()
defer os.Remove(tmpName) // если что-то пойдёт не так — подчистим
if _, err := io.Copy(tmp, resp.Body); err != nil {
tmp.Close()
return "", err
}
if err := tmp.Close(); err != nil {
return "", err
}
// Проверка целостности по манифесту (подпись манифеста уже проверена).
if err := VerifyArtifact(tmpName, a); err != nil {
return "", err
}
mode := os.FileMode(0o644)
if a.Exec {
mode = 0o755
}
if err := os.Chmod(tmpName, mode); err != nil {
return "", err
}
final := filepath.Join(destDir, a.File)
if err := os.Rename(tmpName, final); err != nil { // атомарно в пределах destDir
return "", err
}
return final, nil
}
-198
View File
@@ -1,198 +0,0 @@
// Package release — формат манифеста релиза артефактории и его подпись
// Ed25519. Используется тремя сторонами:
//
// - cmd/bj-release — собирает манифест из артефактов и подписывает;
// - cmd/bj-artifactory — отдаёт манифест и файлы по HTTP;
// - bj-server (auto-update) — скачивает манифест, проверяет подпись,
// сравнивает версии и обновляет компоненты.
//
// Доверие строится на одном корневом Ed25519-ключе: приватный держит
// издатель (мы), публичный зашит в клиента (bj-server) и в установщик.
// Манифест подписывается целиком в каноничном виде — клиент проверяет
// подпись ДО того, как доверять версиям и хешам артефактов.
package release
import (
"crypto/ed25519"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"time"
)
// CurrentSchema — версия формата манифеста (на случай эволюции).
const CurrentSchema = 1
// Artifact — один распространяемый файл (бинарь/скрипт/архив).
type Artifact struct {
Name string `json:"name"` // логическое имя: "bj-server", "install-validata.sh"
File string `json:"file"` // имя файла в хранилище
Version string `json:"version,omitempty"` // версия компонента (если своя)
SHA256 string `json:"sha256"` // hex-хеш содержимого
Size int64 `json:"size"` // размер в байтах
Exec bool `json:"exec,omitempty"` // ставить ли +x при установке
}
// Manifest — описание релиза. Подписывается целиком (каноничный JSON).
type Manifest struct {
Schema int `json:"schema"`
Version string `json:"version"` // версия релиза, напр. "1.2.0"
Channel string `json:"channel"` // "stable" | "beta"
ReleasedAt time.Time `json:"released_at"` // время сборки релиза
Notes string `json:"notes,omitempty"` // что нового (для UI)
MinVersion string `json:"min_version,omitempty"` // мин. версия для прямого апгрейда
Artifacts []Artifact `json:"artifacts"`
}
// SignedManifest — манифест + отделённая подпись. Именно это лежит в
// /v1/manifest.json и скачивается клиентом.
//
// Payload хранится как base64 от каноничного JSON Manifest — НЕ как
// вложенный JSON. Иначе json.MarshalIndent переформатировал бы содержимое
// и подписанные байты разошлись бы с прочитанными.
type SignedManifest struct {
Payload string `json:"payload"` // base64(каноничный JSON Manifest)
Signature string `json:"signature"` // base64(ed25519 over каноничным JSON)
KeyID string `json:"key_id"` // идентификатор публичного ключа
}
// Canonical сериализует манифест детерминированно (для подписи/проверки).
// json.Marshal в Go стабилен по порядку полей структуры — этого достаточно,
// т.к. подписываем и проверяем одни и те же байты Payload.
func (m *Manifest) Canonical() ([]byte, error) {
if m.Schema == 0 {
m.Schema = CurrentSchema
}
return json.Marshal(m)
}
// Sign подписывает манифест приватным ключом и возвращает SignedManifest.
func Sign(m *Manifest, priv ed25519.PrivateKey, keyID string) (*SignedManifest, error) {
payload, err := m.Canonical()
if err != nil {
return nil, fmt.Errorf("release: canonical: %w", err)
}
sig := ed25519.Sign(priv, payload)
return &SignedManifest{
Payload: base64.StdEncoding.EncodeToString(payload),
Signature: base64.StdEncoding.EncodeToString(sig),
KeyID: keyID,
}, nil
}
// Verify проверяет подпись и возвращает разобранный Manifest. Это
// единственный способ получить доверенный Manifest на стороне клиента.
func Verify(sm *SignedManifest, pub ed25519.PublicKey) (*Manifest, error) {
sig, err := base64.StdEncoding.DecodeString(sm.Signature)
if err != nil {
return nil, fmt.Errorf("release: decode signature: %w", err)
}
payload, err := base64.StdEncoding.DecodeString(sm.Payload)
if err != nil {
return nil, fmt.Errorf("release: decode payload: %w", err)
}
if !ed25519.Verify(pub, payload, sig) {
return nil, errors.New("release: подпись манифеста недействительна")
}
var m Manifest
if err := json.Unmarshal(payload, &m); err != nil {
return nil, fmt.Errorf("release: unmarshal payload: %w", err)
}
if m.Schema != CurrentSchema {
return nil, fmt.Errorf("release: неподдерживаемая схема манифеста %d (ожидается %d)", m.Schema, CurrentSchema)
}
return &m, nil
}
// HashFile считает sha256 (hex) и размер файла — для заполнения Artifact.
func HashFile(path string) (sha string, size int64, err error) {
f, err := os.Open(path)
if err != nil {
return "", 0, err
}
defer f.Close()
h := sha256.New()
n, err := io.Copy(h, f)
if err != nil {
return "", 0, err
}
return hex.EncodeToString(h.Sum(nil)), n, nil
}
// VerifyArtifact проверяет, что файл по пути совпадает с Artifact (хеш+размер).
func VerifyArtifact(path string, a Artifact) error {
sha, size, err := HashFile(path)
if err != nil {
return err
}
if size != a.Size {
return fmt.Errorf("release: размер %s не совпал: %d != %d", a.Name, size, a.Size)
}
if sha != a.SHA256 {
return fmt.Errorf("release: sha256 %s не совпал", a.Name)
}
return nil
}
// --- Ключи ---
// LoadPrivateKey читает Ed25519 приватный ключ из файла (base64 seed, 32 байта).
func LoadPrivateKey(path string) (ed25519.PrivateKey, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
seed, err := base64.StdEncoding.DecodeString(stringsTrim(string(b)))
if err != nil {
return nil, fmt.Errorf("release: decode seed: %w", err)
}
if len(seed) != ed25519.SeedSize {
return nil, fmt.Errorf("release: неверный размер seed %d (ожидается %d)", len(seed), ed25519.SeedSize)
}
return ed25519.NewKeyFromSeed(seed), nil
}
// LoadPublicKey читает Ed25519 публичный ключ из файла (base64, 32 байта).
func LoadPublicKey(path string) (ed25519.PublicKey, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
pub, err := base64.StdEncoding.DecodeString(stringsTrim(string(b)))
if err != nil {
return nil, fmt.Errorf("release: decode pubkey: %w", err)
}
if len(pub) != ed25519.PublicKeySize {
return nil, fmt.Errorf("release: неверный размер pubkey %d", len(pub))
}
return ed25519.PublicKey(pub), nil
}
// ParsePublicKey разбирает публичный ключ из base64-строки (для зашитого в клиента).
func ParsePublicKey(b64 string) (ed25519.PublicKey, error) {
pub, err := base64.StdEncoding.DecodeString(stringsTrim(b64))
if err != nil {
return nil, err
}
if len(pub) != ed25519.PublicKeySize {
return nil, fmt.Errorf("release: неверный размер pubkey %d", len(pub))
}
return ed25519.PublicKey(pub), nil
}
func stringsTrim(s string) string {
// убираем пробелы/переводы строк вокруг base64
start, end := 0, len(s)
for start < end && (s[start] == ' ' || s[start] == '\n' || s[start] == '\r' || s[start] == '\t') {
start++
}
for end > start && (s[end-1] == ' ' || s[end-1] == '\n' || s[end-1] == '\r' || s[end-1] == '\t') {
end--
}
return s[start:end]
}
-61
View File
@@ -1,61 +0,0 @@
package release
import (
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"testing"
"time"
)
func TestSignVerifyRoundTrip(t *testing.T) {
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
m := &Manifest{
Version: "1.2.3",
Channel: "stable",
ReleasedAt: time.Now().UTC().Truncate(time.Second),
Artifacts: []Artifact{
{Name: "bj-server", File: "bj-server", SHA256: "abc", Size: 100, Exec: true},
},
}
sm, err := Sign(m, priv, "main")
if err != nil {
t.Fatalf("Sign: %v", err)
}
got, err := Verify(sm, pub)
if err != nil {
t.Fatalf("Verify: %v", err)
}
if got.Version != m.Version || got.Channel != m.Channel || len(got.Artifacts) != 1 {
t.Fatalf("manifest mismatch: %+v", got)
}
if got.Schema != CurrentSchema {
t.Fatalf("schema = %d, want %d", got.Schema, CurrentSchema)
}
}
func TestVerifyRejectsTamper(t *testing.T) {
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
m := &Manifest{Version: "1.0.0", Channel: "stable", Artifacts: []Artifact{{Name: "x"}}}
sm, _ := Sign(m, priv, "main")
// Подменяем payload на другой манифест — подпись не должна сойтись.
other := &Manifest{Version: "9.9.9", Channel: "stable", Artifacts: []Artifact{{Name: "evil"}}}
bad, _ := other.Canonical()
sm.Payload = base64.StdEncoding.EncodeToString(bad)
if _, err := Verify(sm, pub); err == nil {
t.Fatal("Verify принял подделанный payload")
}
}
func TestVerifyRejectsWrongKey(t *testing.T) {
_, priv, _ := ed25519.GenerateKey(rand.Reader)
other, _, _ := ed25519.GenerateKey(rand.Reader) // чужой публичный ключ
m := &Manifest{Version: "1.0.0", Channel: "stable", Artifacts: []Artifact{{Name: "x"}}}
sm, _ := Sign(m, priv, "main")
if _, err := Verify(sm, other); err == nil {
t.Fatal("Verify принял подпись чужим ключом")
}
}

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