feat: живой цикл M2M с НРД + мастер установки ключа на флешку

Инфраструктура M2M (живой обмен с НРД через ИШ):
- обработка M2MTransferResponse: ERROR(M2Mxx) → заявка Отклонена, сохранение
  ответа; INFO → ждём Decision; идемпотентность поллера
- fallback-корреляция ответов с нулевым GUID (M2M14/M2M17) по FIFO
- сырой XML ответа НРД в карточке заявки (для пересылки в ТП)
- тестовый пакет роботу приведён к эталону m2m_robot_samples (CostInfo=Yes,
  4 бумаги, IsolationStatus, DocumentSeries=сценарий); override паспорта
- редирект из теста сразу в карточку заявки

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zuevav
2026-06-19 00:03:21 +03:00
parent 6e503433d4
commit 9737c787f9
110 changed files with 10771 additions and 1690 deletions
@@ -51,16 +51,51 @@
{{if .Claim.M2MResponse}}
<div class="card">
<h2>Ответ НРД (M2MTransferResponse)</h2>
<p class="muted">GUID <code>{{.Claim.M2MResponse.GUID}}</code> · Status <code>{{.Claim.M2MResponse.StatusCode}}</code></p>
<h2>Ответ сервиса МОСТ (M2MTransferResponse)</h2>
<p>
{{if eq .Claim.M2MResponse.StatusCode "ERROR"}}
<span class="badge err">● ERROR — заявка отклонена сервисом НРД</span>
{{else}}
<span class="badge ok">● {{.Claim.M2MResponse.StatusCode}} — принято в обработку</span>
{{end}}
</p>
<p class="muted">GUID <code>{{.Claim.M2MResponse.GUID}}</code></p>
<table>
<thead><tr><th>ReferenceID</th><th>Код</th><th>Текст</th></tr></thead>
<thead><tr><th>ReferenceID</th><th>Код</th><th>Текст ответа НРД</th></tr></thead>
<tbody>
{{range .Claim.M2MResponse.Responses}}
<tr><td><code>{{.ReferenceID}}</code></td><td>{{.Code}}</td><td>{{.Text}}</td></tr>
<tr><td><code>{{.ReferenceID}}</code></td><td><code>{{.Code}}</code></td><td>{{.Text}}</td></tr>
{{end}}
</tbody>
</table>
{{if eq .Claim.M2MResponse.StatusCode "ERROR"}}
<p class="muted" style="margin-top:10px">
Это отказ на сервисном уровне — запрос не дошёл до контрагента. Решение
(M2MTransferDecision) по такой заявке не придёт. Устраните причину по коду
выше и отправьте новую заявку.
</p>
{{end}}
{{if .Claim.M2MResponse.RawXML}}
<details style="margin-top:12px">
<summary style="cursor:pointer;font-weight:600">
Сырой ответ НРД (для техподдержки M2MOST@nsd.ru)
</summary>
<p class="muted" style="margin:8px 0">
Точные байты ответа сервиса МОСТ. Можно дословно переслать в поддержку НРД.
</p>
<button type="button" class="btn" onclick="copyRaw(this)">Скопировать</button>
<pre id="raw-response" style="white-space:pre-wrap;word-break:break-all;background:var(--surface-2,#f5f5f7);padding:12px;border-radius:8px;font-size:12px;overflow:auto;max-height:340px">{{.Claim.M2MResponse.RawXML}}</pre>
</details>
<script>
function copyRaw(btn){
var t=document.getElementById('raw-response').innerText;
navigator.clipboard.writeText(t).then(function(){
var o=btn.textContent; btn.textContent='Скопировано ✓';
setTimeout(function(){btn.textContent=o;},1500);
});
}
</script>
{{end}}
</div>
{{end}}
@@ -17,10 +17,10 @@
<p class="muted">REST-контракт ESIA Finance V1: <code>POST /api/v1/back_office/claims/</code>, GET/PATCH-операции, формат callback'ов, аутентификация Basic, примеры запросов curl.</p>
</div>
</a>
<a href="/admin/help/cryptopro" style="text-decoration:none">
<a href="/admin/help/crypto" style="text-decoration:none">
<div class="card" style="height:100%">
<h2 style="color:var(--accent)">КриптоПро и Рутокен</h2>
<p class="muted">Установка КриптоПро CSP на РЕД ОС / Ubuntu, ввод серийного номера, PKCS#11 модуль, серверная подпись и подпись оператора через Рутокен ЭЦП 2.0, тестирование.</p>
<h2 style="color:var(--accent)">Криптография (Валидата)</h2>
<p class="muted">Установка АПК «Валидата Клиент L» на Astra Linux SE, подключение через PKCS#11, тестирование подписи и проверки квитанций НРД.</p>
</div>
</a>
<a href="/admin/help/systems" style="text-decoration:none">
@@ -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) │ │
│ └──────┬───────────┘ │
│ │ │
КриптоПро CSP — для нашей │ Валидата CSP
admin-стороны (PKCS#11) + АПК Валидата
Клиент L
АПК «Валидата Клиент L» АПК «Валидата │
(PKCS#11) — общий для всех Клиент L»
компонентов
└──────────────────────────────────────────────┼─────────────────┘
SOAP/REST/HTTPS │ Web-сервис ONYX
@@ -62,15 +62,15 @@
<tr>
<td><strong>bj-server</strong></td>
<td>наша</td>
<td>РЕД ОС 8 / Linux</td>
<td>КриптоПро CSP (PKCS#11) — для админ-части</td>
<td>Astra Linux SE 1.7 / Linux</td>
<td>АПК «Валидата Клиент L» (PKCS#11)</td>
<td>Стейт-машина, журнал в БД, веб-админка, lk-emulator</td>
</tr>
<tr>
<td><strong>ИШ (igate)</strong></td>
<td>наша <span class="muted">(но дистрибутив даёт НРД)</span></td>
<td>Astra Linux SE 1.6/1.7 <em>или</em> Windows 10/Server</td>
<td>Валидата CSP + АПК Валидата Клиент L</td>
<td>Astra Linux SE 1.6/1.7</td>
<td>АПК «Валидата Клиент L»</td>
<td>Подписывает наш XML сертификатом УЦ МБ, упаковывает в пакет ЭДО, отправляет в НРД</td>
</tr>
<tr>
@@ -98,16 +98,13 @@
<p>Нет. <strong>ИШ — это наша программа, поставленная у нас.</strong> НРД даёт дистрибутив (<code>igate_100.0-765_amd64.deb</code>, 117 МБ), но ставим у себя. ИШ — это «персональный почтовый клиент к НРД» с подписью.</p>
<h3>Q: ИШ можно поставить на ту же ВМ, что и bj-server?</h3>
<p>Технически да (если та ВМ — Astra Linux). Но у нас bj-server на РЕД ОС, а ИШ требует <strong>Astra Linux</strong> (RPM-версии нет). Поэтому нужно либо: (а) отдельная Astra Linux ВМ, (б) запуск ИШ в Docker-контейнере с Astra-образом, (в) перевод всей инфры на Astra Linux.</p>
<p>Да, если ВМ — Astra Linux. И bj-server, и ИШ работают на Astra Linux SE 1.6/1.7 и используют одно и то же СКЗИ — АПК «Валидата Клиент L». Можно собрать всё на одной ВМ или разнести по отдельным.</p>
<h3>Q: Мы перекладываем файлы между bj-server и ИШ?</h3>
<p>Нет. Мы используем <strong>REST API</strong> ИШ (раздел 2.5 инструкции). bj-server делает HTTP-запросы: <code>POST /api/package/{channel}/file</code> с ZIP в теле. Никаких разделяемых папок. (Альтернативный режим «обменные папки» в ИШ есть — мы его не используем.)</p>
<h3>Q: Почему ИШ требует Валидата CSP, а мы поставили КриптоПро?</h3>
<p>ИШ — отечественная разработка НРД, исторически работает с Валидатой (продукт ООО «Валидата», <code>x509.ru</code>). КриптоПро CSP на нашей ВМ останется — он используется для админ-части bj-server (подпись действий оператора через Рутокен). Валидату надо поставить <strong>на Astra Linux ВМ рядом с ИШ</strong>, не вместо КриптоПро.</p>
<h3>Q: Где брать Валидату?</h3>
<p>Не публично. По запросу: email <code>soed@nsd.ru</code> (НРД) или <code>pki@moex.com</code> (МБ). Временная лицензия выдаётся бесплатно для подключения к ЭДО НРД.</p>
<p>Дистрибутив для Astra Linux SE опубликован на сайте Московской Биржи: <a href="https://fs.moex.com/cdp/po/ClientL_ALSE.zip" target="_blank" rel="noopener">fs.moex.com/cdp/po/ClientL_ALSE.zip</a>. На Linux отдельной лицензии и регистрационных данных не требует — пакеты <code>zpki</code>/<code>zsdk</code> ставятся через <code>dpkg -i</code> и работают сразу.</p>
<h3>Q: Какой сертификат нужен?</h3>
<p>Только от <strong>УЦ Московской Биржи</strong> (<code>ca.moex.com</code>). Сертификаты других УЦ ИШ не примет. УЦ МБ выпускает сертификаты только для организаций, подключённых к ЭДО НРД (по договору).</p>
@@ -0,0 +1,136 @@
{{define "content"}}
<div class="card">
<h2>Криптография (АПК «Валидата Клиент L»)</h2>
<p class="muted">bj-server общается с СКЗИ «Валидата Клиент L» через сайдкар <code>bj-crypto</code> по UDS <code>/run/bj/crypto.sock</code>. Чтобы подпись и проверка квитанций НРД заработали, нужен <strong>ключевой профиль</strong> — папка с тремя сущностями: <code>local.pse</code> (зашифрованный контейнер), <code>local.gdbm</code> (база сертификатов) и <code>vdkeys/*.vdk</code> (сам ключ).</p>
<p class="muted"><strong>Архив от MOEX/НРД содержит «резервную копию», а не готовый профиль.</strong> На Linux рабочий <code>local.gdbm</code> нельзя восстановить headless — Валидата Linux требует GUI-операции «Восстановить справочники из резервной копии». Поэтому профиль готовится один раз на Windows и переносится на сервер через USB.</p>
</div>
<div class="card" style="border-left:4px solid var(--accent)">
<h2>Почему профиль готовится на Windows, а не на сервере</h2>
<p>Боевой Astra Linux SE-сервер с ГОСТ-криптографией <strong>обязан быть headless</strong>: чем меньше пакетов и поверхности атаки, тем проще сертификация ФСТЭК и тем меньше нарушений требований к контуру ЭДО НРД. Установка GUI (X-сервер, GTK, шрифты, VNC/RDP) тянет 50+ пакетов, расширяет surface attack и усложняет аудит — поэтому отказались.</p>
<p>Это <strong>стандартная практика</strong> в фин-секторе: на admin-станции (под Windows или отдельной защищённой ВМ) генерируются и обновляются профили; на боевые серверы они доставляются готовыми через выделенный USB или защищённый канал. Все инструкции MOEX/НРД написаны именно под Windows — этот путь поддерживается официально.</p>
<p class="muted">Альтернативный путь — Linux GUI через X11-forwarding или VNC на дев-стенды — допустим только в песочнице, не в проде. На боевых серверах <code>zcs</code>/<code>vdcsp_cfg</code> не должны запускаться.</p>
</div>
<div class="card" style="border-left:4px solid var(--ok)">
<h2>✅ Подготовка профиля (Windows → USB → bj-server)</h2>
<h3 style="margin-top:14px">Шаг A — на компьютере под Windows</h3>
<ol>
<li><strong>Установите СКЗИ Валидата CSP для Windows</strong>.<br>
Скачайте дистрибутив с <a href="https://www.moex.com/s1292" target="_blank" rel="noopener">moex.com/s1292</a> (раздел «СКЗИ для Windows», файл «Валидата CSP v.6.0.482.0 64bit»). Внутри архива есть <code>Readme.txt</code> с регистрационными данными — введите их во время установки.
</li>
<li><strong>Распакуйте архив-профиль от MOEX/НРД</strong>.<br>
Например <code>PrUser985.7z</code> с паролем <code>11</code> в папку <code>C:\moex-src\</code>. Получится структура:
<pre style="font-size:12px">C:\moex-src\
spr985\
local.pse
local.gdbm ← это «резервная копия», на Linux не работает напрямую
vdkeys\
XXXXXXXXXXXXXXXX.vdk
key.reg</pre>
</li>
<li><strong>Зарегистрируйте ключ в системе Windows</strong>.<br>
Двойной клик по <code>key.reg</code> → «Да» на запрос о записи в реестр. Это нужно, чтобы Валидата увидела ключ при восстановлении справочников.
</li>
<li><strong>Откройте «Справочник сертификатов x64»</strong> из меню «Пуск» → «АПК Валидата Клиент».</li>
<li><strong>Создайте профиль на флешке</strong>:
<ul>
<li>Вставьте чистую USB-флешку, запомните её букву (например <code>E:</code>).</li>
<li>В Справочнике: меню <em>Профили</em><em>Настройка профилей</em><em>Добавить</em>.</li>
<li>Имя профиля: например <code>moex</code>.</li>
<li><strong>Каталог профиля</strong>: создайте новую пустую папку <strong>на флешке</strong>, например <code>E:\moex\</code>. Это путь, куда Валидата положит рабочую копию.</li>
</ul>
</li>
<li><strong>Восстановите справочники из резервной копии</strong>:<br>
Меню <em>Сервис</em><em>Восстановить справочники из резервной копии</em>. В диалоге укажите папку <code>C:\moex-src\spr985\</code>. Дождитесь сообщения «Справочники восстановлены».<br>
После этого в <code>E:\moex\</code> появятся <code>local.pse</code> и <strong>рабочий</strong> <code>local.gdbm</code> (отличается от исходной резервной копии).
</li>
<li><strong>Скопируйте папку <code>vdkeys</code> на корень флешки</strong>.<br>
Скопируйте папку <code>C:\moex-src\vdkeys\</code> в корень флешки. Итоговая структура:
<pre style="font-size:12px">E:\
moex\ ← рабочий профиль, созданный Валидатой
local.pse
local.gdbm ← теперь правильный
vdkeys\
XXXXXXXXXXXXXXXX.vdk</pre>
</li>
<li><strong>Безопасно извлеките флешку</strong> через значок в системном трее Windows.</li>
</ol>
<h3 style="margin-top:18px">Шаг B — на сервере (этот веб-интерфейс)</h3>
<ol>
<li><strong>Вставьте флешку в сервер</strong> (физический USB-порт или прокинутая через гипервизор виртуальная флешка).</li>
<li>Откройте <a href="/admin/setup">/admin/setup</a>. Через 2-3 секунды (автодетект монтирования) в блоке <strong>«Носители ключей»</strong> появится строка <code>🔌 USB /run/media/...</code>. Внутри неё — сабблок <strong>«Профиль Валидаты»</strong> с тремя строками: <code>local.pse</code> / <code>local.gdbm</code> / <code>*.vdk</code>.</li>
<li>В поле <strong>«Имя профиля»</strong> введите осмысленное имя (например <code>moex</code>) и нажмите <strong>«Импортировать профиль в Валидату»</strong>.<br>
Сервер скопирует файлы в <code>/var/lib/bj/profiles/&lt;имя&gt;/</code>, допишет секцию в <code>/opt/Validata/VDCSP/etc/pki1.conf</code>. Toast подтвердит: «Секция дописана в pki1.conf».</li>
<li>В таблице <strong>«Импортированные профили Валидаты»</strong> ниже — нажмите <strong>«Активировать»</strong> в строке вашего профиля.<br>
Toast: «Валидата: контекст с профилем &lt;имя&gt; инициализирован» → готово.</li>
<li>Можно извлекать флешку — все нужные файлы уже скопированы в <code>/var/lib/bj/profiles/</code>.</li>
</ol>
<h3 style="margin-top:18px">Проверка</h3>
<ol>
<li>В блоке «СКЗИ» нажмите зелёную кнопку <strong>«✓ Проверить подключение СКЗИ»</strong>.</li>
<li>Toast должен показать что-то вроде: <code>СКЗИ validata: 0.1.0 (Валидата: контекст с профилем «moex» инициализирован)</code>.</li>
</ol>
</div>
<div class="card">
<h2>Что делать если профиль на флешке не виден</h2>
<ul>
<li><strong>USB не монтируется автоматически в Astra Linux SE.</strong> Подключите вручную: посмотрите <code>lsblk</code>, потом <code>sudo mount /dev/sdb1 /mnt</code>. Через секунду «Носители ключей» подхватит точку монтирования.</li>
<li><strong>Файлы лежат не в корне флешки.</strong> Сканер ищет в глубину 4 уровня — если поместили в <code>E:\very\deep\folder\moex\</code>, должно тоже найтись.</li>
<li><strong>На флешке нет <code>vdkeys\</code>.</strong> Без неё профиль не работает — ключ <code>.vdk</code> обязателен.</li>
<li><strong>«Ни контейнеров, ни сертификатов, ни профиля Валидаты не найдено».</strong> Это значит на носителе нет <em>одновременно</em> <code>.pse</code> и <code>.vdk</code> файлов. Перепроверьте Шаг 6-7 на Windows.</li>
</ul>
</div>
<div class="card">
<h2>Альтернатива: загрузка как ZIP-архив</h2>
<p>Если USB-доступ к серверу неудобен — можно собрать содержимое флешки в обычный <code>.zip</code> на Windows и загрузить через web-форму.</p>
<ol>
<li>После шага A.7 (когда на флешке готовая структура <code>moex\</code> + <code>vdkeys\</code>) — выделите обе папки, правый клик → <em>Отправить</em><em>Сжатая ZIP-папка</em>.</li>
<li>На сервере: <a href="/admin/setup">/admin/setup</a> → «Носители ключей» → форма «Загрузить образ или архив» → выберите ZIP, поле «Пароль» оставьте пустым.</li>
<li>Дальше как в Шаге B со 2-го пункта.</li>
</ol>
<p class="muted">Под капотом сервер распаковывает архив через <code>7z</code> в <code>/var/lib/bj/media/iso/</code>, сканирует на профиль Валидаты — далее всё то же самое, что с USB.</p>
</div>
<div class="card">
<h2>Справочные команды (диагностика)</h2>
<table>
<tbody>
<tr><td><code>systemctl status bj-crypto</code></td><td>Состояние Java-сайдкара (UDS-сокет, провайдер).</td></tr>
<tr><td><code>sudo journalctl -u bj-crypto -n 50</code></td><td>Последние строки лога сайдкара.</td></tr>
<tr><td><code>cat /opt/Validata/VDCSP/etc/pki1.conf</code></td><td>Список профилей, которые видит Валидата (наши секции помечены <code># --- bj-server: профиль ...</code>).</td></tr>
<tr><td><code>sudo ls -la /var/lib/bj/profiles/</code></td><td>Импортированные профили на сервере.</td></tr>
<tr><td><code>/opt/Validata/VDCSP/bin/amd64/testcsp -silent</code></td><td>Базовая проверка провайдера CSP.</td></tr>
</tbody>
</table>
</div>
<div class="card">
<h2>Установка Валидаты на сервер (если её ещё нет)</h2>
<p class="muted">Если этот раздел вам не показывает «✓ ready» — повторите установку:</p>
<pre>curl -fsSL https://fs.moex.com/cdp/po/ClientL_ALSE.zip -o ClientL_ALSE.zip
unzip ClientL_ALSE.zip
sudo apt-get install -y libccid pcscd execstack
sudo dpkg -i ClientL_ALSE/zpki-*.deb ClientL_ALSE/zsdk-*.deb
sudo apt-get -f install -y
sudo execstack -c /opt/Validata/VDCSP/lib/amd64/libvdcsp.so
sudo systemctl enable --now pcscd</pre>
<p class="muted">Дистрибутив для Astra Linux SE — <a href="https://fs.moex.com/cdp/po/ClientL_ALSE.zip" target="_blank" rel="noopener">fs.moex.com/cdp/po/ClientL_ALSE.zip</a>. Linux-версия отдельной лицензии не требует.</p>
</div>
{{end}}
@@ -1,130 +0,0 @@
{{define "content"}}
<p style="margin-bottom:16px"><a href="/admin/help">← все инструкции</a></p>
<div class="card">
<h2>КриптоПро и Рутокен</h2>
<p class="muted">Bridge-and-Join-s использует ГОСТ Р 34.10-2012 для подписи и проверки XMLDSig. Серверная криптография — КриптоПро CSP. Подпись оператора в admin-ui — Рутокен ЭЦП 2.0 (опционально). Оба продукта говорят со стандартным интерфейсом PKCS#11, поэтому Go-клиент общается с ними одинаково.</p>
</div>
<div class="card">
<h2>1. Что и зачем нужно</h2>
<table>
<thead><tr><th>Сценарий</th><th>СКЗИ</th><th>Цена (ориентир)</th></tr></thead>
<tbody>
<tr><td>Проверка XMLDSig входящих от НРД и брокеров</td><td>КриптоПро CSP «Сервер»</td><td>~30-50к ₽ (один раз)</td></tr>
<tr><td>Подпись пакетов в НРД (резервный канал WS ONYX)</td><td>КриптоПро CSP «Сервер»</td><td>включено</td></tr>
<tr><td>Подпись действий оператора в admin-ui</td><td>Рутокен ЭЦП 2.0 + лицензия CSP «Рабочее место»</td><td>~3-5к ₽ железо + ~2-3к ₽ лицензия</td></tr>
<tr><td>Проверка XMLDSig заявлений от ЛК</td><td>КриптоПро CSP «Сервер»</td><td>включено</td></tr>
</tbody>
</table>
<p class="muted">Если используется Интеграционный шлюз НРД (ИШ), он сам подписывает пакеты — наша серверная подпись нужна только для резервного канала ONYX и подписи действий оператора. Можно начать с минимума: только Рутокен оператора и отложить серверную лицензию.</p>
</div>
<div class="card">
<h2>2. Установка КриптоПро CSP на РЕД ОС (проверено)</h2>
<p><strong>Способ 1 — через веб-интерфейс (рекомендуется):</strong> <a href="/admin/setup">/admin/setup</a> → «СКЗИ» → «Установка КриптоПро CSP» → выбрать <code>linux-amd64.tar</code> с cryptopro.ru → «Загрузить и установить».</p>
<p><strong>Способ 2 — вручную из терминала.</strong> Скачать <code>linux-amd64.tgz</code> с <code>www.cryptopro.ru/products/csp/downloads</code> (доступ через личный кабинет), распаковать на ВМ и установить минимальный набор:</p>
<pre>tar -xzf linux-amd64.tgz
cd linux-amd64
sudo rpm -Uvh --replacepkgs --nodeps \
lsb-cprocsp-base-5.0.*.noarch.rpm \
lsb-cprocsp-ca-certs-5.0.*.noarch.rpm \
lsb-cprocsp-rdr-64-5.0.*.x86_64.rpm \
lsb-cprocsp-capilite-64-5.0.*.x86_64.rpm \
lsb-cprocsp-kc1-64-5.0.*.x86_64.rpm \
lsb-cprocsp-pkcs11-64-5.0.*.x86_64.rpm \
cprocsp-curl-64-5.0.*.x86_64.rpm \
cprocsp-rdr-gui-gtk-64-5.0.*.x86_64.rpm</pre>
<p>Ключевые пакеты:</p>
<ul>
<li><code>lsb-cprocsp-base</code> + <code>lsb-cprocsp-rdr-64</code> — базовая инфраструктура</li>
<li><code>lsb-cprocsp-capilite-64</code> — CAPILite (<code>libcapi20.so.4</code>, <code>libcpext.so.4</code>) — иначе libcppkcs11.so не загрузится</li>
<li><code>lsb-cprocsp-kc1-64</code> — CSP класса КС1 (без него Initialize упадёт с CKR_FUNCTION_FAILED)</li>
<li><code>lsb-cprocsp-pkcs11-64</code> — собственно <code>libcppkcs11.so</code></li>
</ul>
<p>Демо-лицензия на 3 месяца встроена в дистрибутив, отдельная активация не требуется. Проверка:</p>
<pre>/opt/cprocsp/sbin/amd64/cpconfig -license -view
/opt/cprocsp/bin/amd64/csptest -keyset -enum -unique</pre>
<p><strong>Важно — LD_LIBRARY_PATH.</strong> КриптоПро CSP кладёт .so в <code>/opt/cprocsp/lib/amd64</code> без записи в <code>/etc/ld.so.conf.d</code>. Bj-server при запуске должен иметь:</p>
<pre>Environment=LD_LIBRARY_PATH=/opt/cprocsp/lib/amd64</pre>
<p>В systemd-юните это уже прописано (<code>deploy/systemd/bj-server.service</code>). При ручном запуске из shell — <code>LD_LIBRARY_PATH=/opt/cprocsp/lib/amd64 ./bin/bj-server</code>.</p>
<p><strong>Активация коммерческой лицензии.</strong> После того как демо истечёт, серийник вводится через UI на <a href="/admin/setup">/admin/setup</a> → «Активация лицензии», или вручную:</p>
<pre>sudo /opt/cprocsp/sbin/amd64/cpconfig -license -set XXXX-XXXXX-XXXXX-XXXXX-XXXXX</pre>
</div>
<div class="card">
<h2>3. Установка на Ubuntu / Debian</h2>
<pre>sudo dpkg -i cprocsp-rdr-gui-gtk-64_5.0.*_amd64.deb \
cprocsp-rdr-64_5.0.*_amd64.deb \
lsb-cprocsp-base_5.0.*_all.deb \
lsb-cprocsp-rdr-64_5.0.*_amd64.deb
sudo apt-get install -f
sudo /opt/cprocsp/sbin/amd64/cpconfig -license -set XXXX-XXXXX-XXXXX-XXXXX-XXXXX</pre>
</div>
<div class="card">
<h2>4. PKCS#11 модуль</h2>
<p>Путь к библиотеке после установки:</p>
<pre>/opt/cprocsp/lib/amd64/libcppkcs11.so</pre>
<p>Эта же библиотека работает и с CSP-ключами (контейнеры на диске или в реестре), и с Рутокен ЭЦП 2.0 (подключённым по USB или в виде smart-card reader).</p>
<p>На <a href="/admin/setup">странице «Настройка»</a> в карточке «Криптография» укажите:</p>
<ul>
<li><strong>Провайдер</strong>: <code>cryptopro</code></li>
<li><strong>UDS-сокет</strong>: <code>/run/bj/crypto.sock</code> (для legacy crypto-service на Java — на M2+ переходим на Go-клиент напрямую через PKCS#11)</li>
<li><strong>Путь к jcp.jar / PKCS#11</strong>: <code>/opt/cprocsp/lib/amd64/libcppkcs11.so</code></li>
<li><strong>Лицензионный ключ</strong>: серийный номер CSP</li>
</ul>
</div>
<div class="card">
<h2>5. Подключение Рутокен ЭЦП 2.0</h2>
<p>Подключите Рутокен в USB. Драйверы КриптоПро CSP уже включают поддержку Рутокен:</p>
<pre># увидеть подключённые токены
/opt/cprocsp/bin/amd64/csptest -card -enum
# увидеть ключевые контейнеры на токене
/opt/cprocsp/bin/amd64/csptest -keyset -enum -unique</pre>
<p>Для подписи действий оператора в admin-ui:</p>
<ol>
<li>Запросить сертификат на физлицо у УЦ (через личный кабинет КриптоПро или через АРМ оператора УЦ).</li>
<li>Записать сертификат и контейнер на Рутокен.</li>
<li>На <a href="/admin/setup">странице «Настройка»</a> в карточке «Криптография» выбрать провайдер <code>cryptopro</code> и указать слот Рутокен.</li>
</ol>
</div>
<div class="card">
<h2>6. Импорт сертификата</h2>
<pre># сертификат корневого УЦ (если ещё нет в системе)
/opt/cprocsp/bin/amd64/certmgr -inst -store mroot -file /path/to/root-ca.cer
# сертификат подписанта (контейнер на токене)
/opt/cprocsp/bin/amd64/certmgr -inst -store uMy -cont '\\.\HDIMAGE\my-keys' \
-file /path/to/operator.cer
# проверить установленные сертификаты
/opt/cprocsp/bin/amd64/certmgr -list -store uMy</pre>
</div>
<div class="card">
<h2>7. Тестирование подписи</h2>
<p>Через CLI КриптоПро (быстрая проверка что криптография работает):</p>
<pre># подписать произвольный файл
/opt/cprocsp/bin/amd64/cryptcp -signf -dn 'CN=Иванов И.И.' \
-det -strict /tmp/test.txt
# проверить подпись
/opt/cprocsp/bin/amd64/cryptcp -vsignf -det /tmp/test.txt /tmp/test.txt.sgn</pre>
<p>Через нашу систему — раздел <a href="/admin/setup">Настройка</a> → кнопка «Запустить тестовую заявку». На странице «Заявка» появится результат и расшифровка проверки подписи.</p>
</div>
<div class="card">
<h2>8. Поддержка</h2>
<ul>
<li>Документация КриптоПро: <code>www.cryptopro.ru/products/csp</code></li>
<li>Установка на РЕД ОС: <code>www.cryptopro.ru/forum2/default.aspx?g=topics&f=43</code></li>
<li>Технические вопросы: <code>support@cryptopro.ru</code></li>
<li>Рутокен: <code>dev.rutoken.ru/display/PUB/Rutoken+EDS</code></li>
</ul>
<p class="muted">При проблемах с лицензией сначала проверьте <code>cpconfig -license -view</code> — лицензия должна быть валидна и не просрочена. Срок действия КриптоПро лицензии — обычно 1 год.</p>
</div>
{{end}}
@@ -87,7 +87,7 @@
<div class="card">
<h2>8. Подписание заявления</h2>
<p>ЛК должен подписать заявление XMLDSig (ГОСТ или RSA) и положить в поле <code>signed_document</code> (base64). Мы проверяем подпись через crypto-service — см. <a href="/admin/help/cryptopro">инструкцию по КриптоПро</a>.</p>
<p>ЛК должен подписать заявление XMLDSig (ГОСТ или RSA) и положить в поле <code>signed_document</code> (base64). Мы проверяем подпись через crypto-service — см. <a href="/admin/help/crypto">инструкцию по криптографии</a>.</p>
<p class="muted">На M2 проверка подписи отключена (stub). На M3-M4 включится после подключения СКЗИ.</p>
</div>
{{end}}
@@ -43,26 +43,20 @@
<ul>
<li>Профиль (например, <code>test3-gost</code>) — при выборе URL и контейнер заполняются автоматически</li>
<li>URL ONYX — например <code>https://gost-t3.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo</code></li>
<li>Ключевой контейнер — имя контейнера КриптоПро с ключами ЭДО НРД (выдаются УЦ НРД, см. ниже)</li>
<li>Ключевой контейнер — имя контейнера Валидаты с ключами ЭДО НРД (выдаются УЦ НРД, см. ниже)</li>
</ul>
<p class="muted">Без настроенного ИШ система работает в <strong>mock-режиме</strong>: bj-server эмитирует синтетический Decision через 3 секунды для каждой заявки. Это удобно для дев-демо и не требует подключения к НРД.</p>
</div>
<div class="card">
<h2>1а. Сертификаты УЦ НРД (для проверки квитанций)</h2>
<p>НРД подписывает все исходящие пакеты ЭДО ГОСТ-подписью своего УЦ. Для проверки этих подписей на нашей стороне нужно импортировать корневые сертификаты УЦ НРД в хранилище <code>mroot</code> (доверенные корневые).</p>
<p>НРД подписывает все исходящие пакеты ЭДО ГОСТ-подписью своего УЦ. Для проверки этих подписей на нашей стороне нужно загрузить корневые и промежуточные сертификаты УЦ НРД.</p>
<ol>
<li>Скачать сертификаты с сайта УЦ НРД: <code>www.nsd.ru/workflow/system/cryptography/</code> (или из дистрибутива ИШ).</li>
<li>В <a href="/admin/setup">/admin/setup</a> → раздел «Импорт сертификата» → выбрать файл <code>.cer</code>, тип хранилища <code>mroot — корневой УЦ</code>, нажать «Импортировать». Под капотом выполняется <code>certmgr -inst -file root.cer -store mroot</code>.</li>
<li>Промежуточные сертификаты УЦ — в хранилище <code>uRoot</code>.</li>
<li>Для проверки подписей самой системы НРД (квитанции ЭДО) — импортировать сертификат подписи НРД в <code>uMy</code> (как корреспондента), либо оставить в <code>mroot</code>, если он самоподписной.</li>
</ol>
<p><strong>Наши сертификаты для отправки в НРД</strong> (получаются из другого УЦ — нашей организации):</p>
<ol>
<li>Сертификат подписи нашей организации (с приватным ключом в виде <code>.pfx</code>/<code>.p12</code> или на Рутокен) — импортировать в <code>uMy</code> с PIN.</li>
<li>Цепочка сертификатов вашего УЦ — в <code>mroot</code> (корневой) и <code>uRoot</code> (промежуточные).</li>
<li>После импорта проверить: <code>certmgr -list -store uMy</code> и <code>cpverify</code>.</li>
<li>В <a href="/admin/setup">/admin/setup</a> → раздел «Сертификаты УЦ» добавить прямые URL <code>.cer</code>-файлов и нажать «Скачать и импортировать сейчас». Файлы сохраняются в <code>/var/lib/bj/ca-certs/</code> (по SHA-256). Включите «Авто-обновление раз в сутки» — система перепроверит и обновит.</li>
<li>Загруженные через Валидату ключи и сертификаты управляются её собственным справочником (<code>zcs</code>/<code>vdcsp_cfg</code>).</li>
</ol>
<p><strong>Наши сертификаты для отправки в НРД</strong> загружаются в профиль Валидаты её утилитой <code>zcs</code> (импорт ключевого контейнера и сертификата подписи).</p>
<p class="muted">Полный цикл обмена сертификатами с НРД описан в <code>DOC/Инструкция M2M.pdf</code> и <code>DOC/Презентация MOEX MOST.pdf</code>.</p>
</div>
<p><strong>Документация по подключению</strong>: <code>DOC/instr_podkl_stend_v3.pdf</code>, <code>DOC/Ссылки для доступа в тестовые контуры.pdf</code>.</p>
@@ -105,7 +99,6 @@
<tr><td>НРД (Национальный расчётный депозитарий)</td><td>Тестовые сертификаты GUEST/TEST3, дистрибутив ИШ, доступ к личному кабинету УЦ НРД</td></tr>
<tr><td>Команда ЛК (ESIA Finance)</td><td>Базовый URL ЛК, Basic-auth учётные данные, очерёдность подключения (сначала эмулятор, потом реальный ЛК)</td></tr>
<tr><td>Команда Fansy</td><td>Контракт <code>docs/fansy-contract/v1/</code>, SLA, окна обслуживания, IP-allowlist</td></tr>
<tr><td>КриптоПро</td><td>Серийный номер лицензии CSP, актуальный дистрибутив, поддержка <code>support@cryptopro.ru</code></td></tr>
<tr><td>Брокеры-контрагенты MOST</td><td>БКС (ИНН 5406121446), Ренессанс (7709258228), Альфа-Банк (7728168971) — уже в seed</td></tr>
</tbody>
</table>
@@ -1,63 +1,75 @@
{{define "content"}}
{{/* Активные новости — сразу под навигацией. Показываем top-3: те у которых ValidFrom..ValidTo сейчас активны, иначе свежие. */}}
{{if .News}}
<div class="card" style="border-left:3px solid var(--accent);margin-bottom:16px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<h2 style="margin:0">📢 Новости</h2>
<a href="/admin/news" style="font-size:13px">все новости →</a>
</div>
{{range .News}}
<div style="padding:8px 0;border-bottom:1px solid var(--border)">
<div style="font-weight:600;font-size:14px">
{{if eq .Kind "maintenance"}}🔧 {{end}}{{if eq .Kind "feature"}}✨ {{end}}{{if eq .Kind "system"}}⚠ {{end}}{{if eq .Kind "doc-update"}}📄 {{end}}{{.Title}}
{{/* ===== Оператор-дашборд (Apple-стиль): приветствие → статус → плитки задач → сводка ===== */}}
<div class="hero">
<h1 class="hero-greeting">Добрый день</h1>
{{if .AllReady}}
<span class="hero-status ok">● Система готова к работе</span>
{{else}}
<div style="display:flex;align-items:center;gap:14px;flex-wrap:wrap">
<span class="hero-status warn">● Требуется настройка — {{.NotReadyCount}} из {{.TotalCount}} компонентов</span>
<a href="/admin/wizard" class="btn">Открыть мастер настройки →</a>
</div>
{{if .Body}}<div class="muted" style="font-size:12px;margin-top:4px">{{.Body}}</div>{{end}}
{{if and (not .ValidFrom.IsZero) (not .ValidTo.IsZero)}}
<div class="muted" style="font-size:11px;margin-top:4px">с {{.ValidFrom.Format "02.01.2006"}} по {{.ValidTo.Format "02.01.2006"}}</div>
{{end}}
</div>
{{end}}
</div>
{{end}}
{{/* ===== Крупные плитки задач ===== */}}
<div class="tiles">
<a href="/admin/claims?new=1" class="tile brand">
<span class="ico"></span>
<span class="t-title">Новый перевод</span>
<span class="t-sub">Заявка на перевод ценных бумаг M2M</span>
<span class="t-arrow"></span>
</a>
<a href="/admin/claims" class="tile">
<span class="ico">📋</span>
<span class="t-title">Переводы</span>
<span class="t-sub">{{.Counts.Total}} всего · {{.Counts.InProgress}} в работе</span>
<span class="t-arrow"></span>
</a>
<a href="/admin/status" class="tile">
<span class="ico">🔍</span>
<span class="t-title">Диагностика</span>
<span class="t-sub">Состояние СКЗИ, ИШ и базы</span>
<span class="t-arrow"></span>
</a>
<a href="/admin/setup" class="tile">
<span class="ico">⚙️</span>
<span class="t-title">Настройка</span>
<span class="t-sub">Криптография, НРД, подключения</span>
<span class="t-arrow"></span>
</a>
</div>
{{/* ===== Сводка по переводам ===== */}}
<div class="grid">
<div class="stat">
<div class="stat-label">Всего сделок</div>
<div class="stat-label">Всего переводов</div>
<div class="stat-value">{{.Counts.Total}}</div>
</div>
<div class="stat">
<div class="stat-label">Подтверждено</div>
<div class="stat-value" style="color: var(--ok)">{{.Counts.Confirmed}}</div>
<div class="stat-value" style="color:var(--ok)">{{.Counts.Confirmed}}</div>
</div>
<div class="stat">
<div class="stat-label">В ожидании</div>
<div class="stat-value" style="color: var(--warn)">{{.Counts.InProgress}}</div>
<div class="stat-value" style="color:var(--warn)">{{.Counts.InProgress}}</div>
</div>
<div class="stat">
<div class="stat-label">Отказы / таймауты</div>
<div class="stat-value" style="color: var(--err)">{{.Counts.Failed}}</div>
<div class="stat-value" style="color:var(--err)">{{.Counts.Failed}}</div>
</div>
</div>
<div class="card">
<h2>Состояние системы</h2>
{{range .Status.Checks}}
<div style="padding: 6px 0">
<span class="dot {{if .OK}}ok{{else}}err{{end}}"></span>
<strong>{{.Name}}</strong> — {{.Message}}
{{if .Detail}}<span class="muted"> · <code>{{.Detail}}</code></span>{{end}}
</div>
{{end}}
<div class="muted" style="margin-top: 12px">
Профиль: <code>{{.Status.Profile}}</code> · Crypto-провайдер: <code>{{.Status.Provider}}</code>
</div>
{{/* ===== Последние переводы ===== */}}
<div class="section-head">
<h2>Последние переводы</h2>
<a href="/admin/claims">все →</a>
</div>
<div class="card">
<h2>Последние заявки</h2>
{{if .Recent}}
<table>
<thead><tr><th>Создана</th><th>ID</th><th>Инвестор</th><th>ЦБ</th><th>Статус</th><th></th></tr></thead>
<thead><tr><th>Время</th><th>ID</th><th>Инвестор</th><th>ЦБ</th><th>Статус</th><th></th></tr></thead>
<tbody>
{{range .Recent}}
<tr>
@@ -72,7 +84,25 @@
</tbody>
</table>
{{else}}
<p class="muted">Заявок ещё нет. Подайте первую через lk-emulator или POST /api/v1/back_office/claims/.</p>
<p class="muted" style="margin:0">Переводов ещё нет. Нажмите «Новый перевод», чтобы создать первый.</p>
{{end}}
</div>
{{/* ===== События (компактно, если есть) ===== */}}
{{if .News}}
<div class="section-head">
<h2>События</h2>
<a href="/admin/news">все →</a>
</div>
<div class="card">
{{range .News}}
<div style="padding:9px 0;border-bottom:1px solid var(--border)">
<div style="font-weight:600;font-size:13.5px">
{{if eq .Kind "maintenance"}}🔧 {{end}}{{if eq .Kind "feature"}}✨ {{end}}{{if eq .Kind "system"}}⚠️ {{end}}{{if eq .Kind "doc-update"}}📄 {{end}}{{.Title}}
</div>
{{if .Body}}<div class="muted" style="font-size:12px;margin-top:3px">{{.Body}}</div>{{end}}
</div>
{{end}}
</div>
{{end}}
{{end}}
@@ -0,0 +1,106 @@
{{define "content"}}
{{/* Пошаговый мастер установки ключа Валидаты на флешку. */}}
<div class="hero">
<h1 class="hero-greeting">Установка ключа на флешку</h1>
<span class="hero-status">Загрузите архив НРД → запись на носитель → справочник сертификатов → проверка → готово</span>
</div>
{{$s := .State}}
{{/* ===== Лента шагов ===== */}}
<div class="card">
<ol style="list-style:none;padding:0;margin:0;display:grid;gap:12px">
{{range $i, $step := $s.Steps}}
<li style="display:flex;gap:12px;align-items:flex-start">
<span style="flex:0 0 28px;height:28px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:13px;
{{if eq $step.Status "ok"}}background:var(--ok-weak);color:var(--ok)
{{else if eq $step.Status "error"}}background:var(--err-weak);color:var(--err)
{{else if eq $step.Status "active"}}background:var(--accent-weak);color:var(--accent)
{{else}}background:var(--surface-2,#eee);color:var(--muted,#999){{end}}">
{{if eq $step.Status "ok"}}✓{{else if eq $step.Status "error"}}✕{{else}}{{add $i 1}}{{end}}
</span>
<div style="flex:1">
<div style="font-weight:600">{{$step.Title}}</div>
{{if $step.Detail}}<div class="muted" style="font-size:13px;margin-top:2px">{{$step.Detail}}</div>{{end}}
</div>
</li>
{{end}}
</ol>
</div>
{{/* ===== Действие в зависимости от состояния ===== */}}
{{if $s.Done}}
<div class="card" style="border-left:3px solid var(--ok)">
<h2>✓ Готово</h2>
<p>Ключ установлен на флешку, справочник сертификатов сформирован, Валидата проверена.</p>
{{if $s.Backup}}<p class="muted">Бэкап прежнего носителя: <code>{{$s.Backup}}</code></p>{{end}}
<div style="display:flex;gap:8px;margin-top:12px;flex-wrap:wrap">
<form method="post" action="/admin/setup/test-nsd" style="margin:0">
<input type="hidden" name="scenario" value="2001">
<button type="submit" class="btn btn-ok">→ Отправить тестовый документ роботу</button>
</form>
<form method="post" action="/admin/setup/keywizard/reset" style="margin:0">
<button type="submit" class="btn btn-secondary">Установить ещё один ключ</button>
</form>
</div>
</div>
{{else if $s.StagingID}}
{{/* Архив загружен — выбор флешки + запись */}}
<div class="card">
<h2>Шаг 2 — выбор флешки и запись</h2>
<p class="muted">Архив распакован. Ключ: <code>{{fallbackTpl $s.VDK "—"}}</code>.
Выберите носитель — запись сделает бэкап, запишет ключ и справочник
сертификатов, дотянет CRL и перезапустит ИШ.</p>
<form method="post" action="/admin/setup/keywizard/install" style="margin-top:12px;display:grid;gap:12px;max-width:640px"
onsubmit="this.querySelector('button[type=submit]').disabled=true;this.querySelector('button[type=submit]').textContent='Устанавливаю…';">
<div>
<label style="font-weight:600;display:block;margin-bottom:6px">Целевая флешка</label>
{{if .Drives}}
{{range $i, $d := .Drives}}
<label style="display:flex;gap:10px;align-items:flex-start;padding:10px;border:1px solid var(--border,#ddd);border-radius:8px;margin-bottom:8px;cursor:pointer">
<input type="radio" name="target_device" value="{{$d.Device}}" {{if $d.IsKeymedia}}checked{{else if and (eq $i 0) (not (anyKeymedia $.Drives))}}checked{{end}} style="margin-top:3px">
<span>
<b>{{fallbackTpl $d.Model "USB-носитель"}}</b> · {{$d.Size}} · {{$d.FSType}}
{{if $d.Label}}· метка «{{$d.Label}}»{{end}}<br>
<span class="muted" style="font-size:12px">{{$d.Device}}{{if $d.Mountpoint}} · {{$d.Mountpoint}}{{end}}
{{if $d.IsKeymedia}}<b style="color:var(--accent)">← текущий ключевой носитель ИШ (рекомендуется)</b>{{end}}</span>
</span>
</label>
{{end}}
{{else}}
<p class="muted">Съёмные носители не обнаружены — будет использован текущий ключевой носитель ИШ по умолчанию.</p>
{{end}}
</div>
<div>
<label style="font-weight:600;display:block;margin-bottom:6px">Имя профиля в справочнике (необязательно)</label>
<input type="text" name="profile_name" placeholder="Авто из архива (напр. PrUser1046)" autocomplete="off"
pattern="[A-Za-z0-9_-]*" style="width:100%">
<span class="muted" style="font-size:12px">Пусто = имя берётся из архива автоматически.</span>
</div>
<button type="submit" class="btn btn-ok">Записать на флешку, сформировать справочник и проверить ИШ</button>
</form>
<form method="post" action="/admin/setup/keywizard/reset" style="margin-top:8px">
<button type="submit" class="btn btn-secondary">Отмена / загрузить другой архив</button>
</form>
</div>
{{else}}
{{/* Начало — форма загрузки */}}
<div class="card">
<h2>Шаг 1 — загрузка архива</h2>
<p class="muted">Выберите .7z-архив с ключом от НРД и введите пароль архива.</p>
<form method="post" action="/admin/setup/keywizard/upload" enctype="multipart/form-data"
style="margin-top:12px;display:grid;gap:10px;max-width:560px">
<input type="file" name="archive" accept=".7z,.zip" required>
<input type="password" name="password" placeholder="Пароль архива (например 11)" autocomplete="off">
<button type="submit" class="btn btn-ok">Загрузить и распаковать</button>
</form>
</div>
{{end}}
<p style="margin-top:16px"><a href="/admin/setup" class="muted">← Назад к настройкам</a></p>
{{end}}
@@ -11,7 +11,7 @@
.news-title { font-size:15px; font-weight:600; margin:0 0 6px 0; }
.news-body { font-size:13px; white-space:pre-wrap; }
.news-validity { margin-top:6px; padding:4px 8px; background:var(--bg); border-radius:4px; display:inline-block; font-size:12px; }
.news-validity.active { background:rgba(232,177,58,0.15); color:var(--warn); }
.news-validity.active { background:var(--warn-weak); color:var(--warn); }
</style>
{{if .Flash}}<div class="card" style="border-left:3px solid var(--accent)"><p style="margin:0">{{.Flash}}</p></div>{{end}}
+358 -298
View File
@@ -1,324 +1,384 @@
{{define "content"}}
{{if .Flash}}<div style="padding:12px 16px;background:rgba(63,191,108,0.1);border-left:3px solid var(--ok);border-radius:4px;margin-bottom:16px">{{.Flash}}</div>{{end}}
{{if .Flash}}<div style="padding:12px 16px;background:var(--ok-weak);border-left:3px solid var(--ok);border-radius:8px;margin-bottom:16px">{{.Flash}}</div>{{end}}
<div class="card">
<h2>Готовность системы: {{.ReadyCount}} из {{.TotalCount}}</h2>
<div style="display:flex;gap:8px;margin-top:8px">
{{range .Readiness}}
<div style="flex:1;text-align:center;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px">
<span class="dot {{if .Configured}}ok{{else}}err{{end}}"></span>
<strong>{{.Name}}</strong><br>
<span class="muted" style="font-size:11px">{{if .Configured}}настроено{{else}}не настроено{{end}}</span>
</div>
{{end}}
</div>
</div>
<div class="settings">
{{/* ===== Боковая навигация разделов с индикаторами ===== */}}
<nav class="settings-nav">
<button data-sec="overview" class="active"><span class="nico"></span>Обзор</button>
<button data-sec="db"><span class="nico">🗄️</span>База данных<span class="ind {{if .Settings.Postgres.DSN}}ok{{else}}err{{end}}"></span></button>
<button data-sec="crypto"><span class="nico">🔐</span>Криптография<span class="ind {{if .Settings.Crypto.Profile}}ok{{else}}warn{{end}}"></span></button>
<button data-sec="nsd"><span class="nico">🏛️</span>НРД<span class="ind {{if .Settings.NSD.IGWBaseURL}}ok{{else}}warn{{end}}"></span></button>
<button data-sec="tests"><span class="nico">🧪</span>Тесты</button>
<button data-sec="update"><span class="nico">⬆️</span>Обновления{{if .Settings.Update.Available}}<span class="ind warn"></span>{{end}}</button>
<button data-sec="license"><span class="nico">🔑</span>Лицензия{{if .License.Present}}<span class="ind {{if .License.Valid}}ok{{else}}err{{end}}"></span>{{end}}</button>
</nav>
<!-- PostgreSQL -->
<div class="card">
<h2><span class="dot {{if .Settings.Postgres.DSN}}ok{{else}}err{{end}}"></span>PostgreSQL</h2>
<p class="muted">Принимающая БД (fansy-store) и журнал сделок m2m-core. Сейчас:
{{if .Settings.Postgres.DSN}}<code>настроено</code>{{else}}<code>in-memory</code> (M2-демо){{end}}.</p>
<div class="settings-body">
{{if not .Settings.Postgres.DSN}}
<div style="background:var(--bg);border:1px solid var(--accent);border-radius:6px;padding:14px;margin-top:12px">
<h3 style="margin:0 0 8px 0;font-size:15px">Самый простой вариант — подключить автоматически</h3>
<p class="muted" style="margin:0 0 10px 0">Если у вас ещё нет своего PostgreSQL, мы поднимем его сами в контейнере (podman-compose), применим все миграции и запишем DSN. Подходит для дев-стенда и тестирования. Для прода — лучше указать свой DSN ниже.</p>
<form method="post" action="/admin/setup/postgres/quick-start" style="margin:0">
<button type="submit" class="btn" style="background:var(--accent);color:white;border:none;padding:10px 18px;border-radius:4px;font-weight:600;cursor:pointer">⚡ Поднять локальный PostgreSQL автоматически</button>
<span class="muted" style="margin-left:10px;font-size:12px">Займёт ~10-30 секунд. Требуется установленный <code>podman-compose</code>.</span>
</form>
</div>
{{end}}
{{/* ============ ОБЗОР ============ */}}
<section class="settings-section active" id="sec-overview">
<h1>Обзор</h1>
<div class="card">
<h2>Готовность системы: {{.ReadyCount}} из {{.TotalCount}}</h2>
<div class="grid" style="margin-top:12px">
{{range .Readiness}}
<div class="stat">
<div><span class="dot {{if .Configured}}ok{{else}}err{{end}}"></span><strong>{{.Name}}</strong></div>
<div class="muted" style="font-size:12px;margin-top:4px">{{if .Configured}}настроено{{else}}не настроено{{end}}</div>
</div>
{{end}}
</div>
</div>
</section>
<details {{if not .Settings.Postgres.DSN}}style="margin-top:12px"{{end}}>
<summary style="cursor:pointer;color:var(--accent);font-size:13px">{{if .Settings.Postgres.DSN}}Изменить параметры подключения{{else}}…или ввести параметры подключения вручную (для существующего PostgreSQL){{end}}</summary>
<form method="post" action="/admin/setup/postgres" style="margin-top:12px">
<div class="form-row" style="display:grid;grid-template-columns:200px 1fr;gap:12px;align-items:center">
<label>DSN <span class="muted" title="DSN = Data Source Name. Строка вида postgres://пользователь:пароль@хост:порт/база?опции" style="cursor:help">(?)</span></label>
<input type="text" name="dsn" value="{{.Settings.Postgres.DSN}}" placeholder="postgres://bj:secret@127.0.0.1:5432/bj?sslmode=disable" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
{{/* ============ БАЗА ДАННЫХ ============ */}}
<section class="settings-section" id="sec-db">
<h1>База данных</h1>
<div class="card">
<h2><span class="dot {{if .Settings.Postgres.DSN}}ok{{else}}err{{end}}"></span>PostgreSQL</h2>
<p class="muted">Журнал сделок m2m-core и принимающая БД. Сейчас: {{if .Settings.Postgres.DSN}}<code>подключено</code>{{else}}<code>in-memory</code> (данные не сохраняются){{end}}.</p>
{{if not .Settings.Postgres.DSN}}
<div style="background:var(--card-2);border:1px solid var(--accent);border-radius:10px;padding:16px;margin-top:12px">
<h3 style="margin:0 0 8px">Подключить автоматически</h3>
<p class="muted" style="margin:0 0 12px">Поднимем локальный PostgreSQL в контейнере, применим миграции и запишем DSN. Для дев-стенда. Для прода — укажите свой DSN ниже.</p>
<form method="post" action="/admin/setup/postgres/quick-start" style="margin:0">
<button type="submit" class="btn">⚡ Поднять локальный PostgreSQL</button>
</form>
</div>
<p class="muted" style="margin-top:8px">При сохранении выполняется Ping. Если БД недоступна — будет ошибка.</p>
<button type="submit" class="btn" style="background:var(--accent);color:white;border:none;padding:8px 16px;border-radius:4px;margin-top:8px">Сохранить и проверить</button>
</form>
</details>
</div>
<!-- СКЗИ через PKCS#11: КриптоПро CSP / Рутокен / Валидата / ViPNet -->
<div class="card">
<h2><span class="dot {{if and .Settings.Crypto.JCPPath .Settings.Crypto.LicenseKey}}ok{{else}}err{{end}}"></span>СКЗИ (КриптоПро CSP, Рутокен и др. через PKCS#11)</h2>
<p class="muted">Go-клиент подключается к СКЗИ напрямую через стандартный PKCS#11 интерфейс. Поддерживаются КриптоПро CSP, Рутокен ЭЦП 2.0, Валидата, ViPNet — один клиент, разные .so модули. Подробно — раздел <a href="/admin/help/cryptopro">«КриптоПро»</a> в инструкциях.</p>
<table style="margin-bottom:12px">
<tr><td style="width:220px" class="muted">Текущий провайдер</td><td><code>{{.Settings.Crypto.Provider}}</code></td></tr>
<tr><td class="muted">Путь к модулю PKCS#11</td><td><code>{{if .Settings.Crypto.JCPPath}}{{.Settings.Crypto.JCPPath}}{{else}}—{{end}}</code></td></tr>
<tr><td class="muted">UDS-сокет (legacy)</td><td><code>{{.Settings.Crypto.SocketPath}}</code></td></tr>
<tr><td class="muted">Лицензия введена</td><td>{{if .Settings.Crypto.LicenseKey}}<span style="color:var(--ok)">да</span>{{else}}<span style="color:var(--err)">нет</span>{{end}}</td></tr>
</table>
<details {{if or (eq .Settings.Crypto.Provider "stub") (not .Settings.Crypto.JCPPath)}}open{{end}}>
<summary style="cursor:pointer;color:var(--accent);font-size:13px">Изменить параметры СКЗИ</summary>
<form method="post" action="/admin/setup/crypto" style="margin-top:12px;display:grid;gap:10px">
<div class="form-row" style="display:grid;grid-template-columns:220px 1fr;gap:12px;align-items:center">
<label>Провайдер СКЗИ</label>
<select name="provider" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
<option value="stub" {{if eq .Settings.Crypto.Provider "stub"}}selected{{end}}>stub — без криптографии (демо)</option>
<option value="cryptopro" {{if eq .Settings.Crypto.Provider "cryptopro"}}selected{{end}}>КриптоПро CSP (через PKCS#11)</option>
<option value="rutoken" {{if eq .Settings.Crypto.Provider "rutoken"}}selected{{end}}>Рутокен ЭЦП 2.0 (для подписи оператора)</option>
<option value="validata" {{if eq .Settings.Crypto.Provider "validata"}}selected{{end}}>Валидата</option>
<option value="vipnet" {{if eq .Settings.Crypto.Provider "vipnet"}}selected{{end}}>ViPNet</option>
</select>
</div>
<div class="form-row" style="display:grid;grid-template-columns:220px 1fr;gap:12px;align-items:center">
<label>Путь к модулю PKCS#11</label>
<input type="text" name="jcp_path" value="{{.Settings.Crypto.JCPPath}}" placeholder="/opt/cprocsp/lib/amd64/libcppkcs11.so" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
</div>
<div class="form-row" style="display:grid;grid-template-columns:220px 1fr;gap:12px;align-items:center">
<label>UDS-сокет (legacy)</label>
<input type="text" name="socket_path" value="{{.Settings.Crypto.SocketPath}}" placeholder="/run/bj/crypto.sock (только для совместимости со старым Java crypto-service)" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
</div>
<div class="form-row" style="display:grid;grid-template-columns:220px 1fr;gap:12px;align-items:flex-start">
<label>Серийный номер лицензии</label>
<textarea name="license_key" rows="3" placeholder="XXXX-XXXXX-XXXXX-XXXXX-XXXXX (серийный номер КриптоПро CSP)" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;font-family:monospace;font-size:12px">{{.Settings.Crypto.LicenseKey}}</textarea>
</div>
<p class="muted">
<strong>КриптоПро CSP</strong>: установить пакеты <code>rpm -i cprocsp-*.rpm</code>, активировать лицензию командой <code>cpconfig -license -set XXXX-...</code>, указать <code>/opt/cprocsp/lib/amd64/libcppkcs11.so</code>.<br>
<strong>Рутокен</strong>: подключить токен USB, указать <code>/usr/lib64/librtpkcs11ecp.so</code>.<br>
Полная инструкция: <a href="/admin/help/cryptopro">/admin/help/cryptopro</a>. При сохранении проверим, что файл модуля существует.
</p>
<div style="display:flex;gap:8px">
<button type="submit" class="btn" style="background:var(--accent);color:white;border:none;padding:8px 16px;border-radius:4px">Сохранить</button>
</div>
</form>
<form method="post" action="/admin/setup/crypto/check" style="margin-top:12px">
<button type="submit" class="btn" style="background:var(--border);color:var(--text);border:none;padding:8px 16px;border-radius:4px">Проверить подключение СКЗИ</button>
<span class="muted" style="margin-left:8px">Загрузит PKCS#11 модуль, опросит список токенов, покажет результат сверху страницы.</span>
</form>
<hr style="margin:18px 0;border:none;border-top:1px solid var(--border)">
<h3 style="font-size:14px;margin:0 0 8px">Установка КриптоПро CSP</h3>
<p class="muted">Дистрибутив с <a href="https://www.cryptopro.ru/products/csp/downloads" target="_blank" rel="noopener">cryptopro.ru</a> (например, <code>linux-amd64.tgz</code> или <code>linux-amd64.tar</code> для РЕД ОС/ALT/ROSA). Загрузите файл здесь — он будет распакован и установлен через <code>sudo rpm -Uvh</code>. Установка длится ~30 секунд.</p>
<form method="post" action="/admin/setup/crypto/install" enctype="multipart/form-data" style="margin-top:12px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<input type="file" name="dist" accept=".tar,.tgz,.gz,.rpm" required style="padding:6px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;flex:1;min-width:300px">
<button type="submit" class="btn" style="background:var(--accent);color:white;border:none;padding:8px 16px;border-radius:4px">Загрузить и установить</button>
</form>
<hr style="margin:18px 0;border:none;border-top:1px solid var(--border)">
<h3 style="font-size:14px;margin:0 0 8px">Сертификаты на токенах</h3>
{{if .Certificates}}
<table>
<thead><tr><th>Кому</th><th>Кем выдан</th><th>ИНН</th><th>Действителен</th><th>Токен</th><th>Приватный ключ</th></tr></thead>
<tbody>
{{range .Certificates}}
<tr>
<td>{{.SubjectCN}}</td>
<td class="muted">{{.IssuerCN}}</td>
<td><code>{{.INN}}</code></td>
<td class="muted">до {{.NotAfter.Format "02.01.2006"}}</td>
<td class="muted">«{{.TokenLabel}}» (slot {{.SlotID}})</td>
<td>{{if .HasPrivateKey}}<span style="color:var(--ok)">есть</span>{{else}}<span class="muted">нет</span>{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="muted">На подключенных токенах сертификатов не найдено. Загрузите .pfx ниже или подключите Рутокен с сертификатом.</p>
{{end}}
<details {{if not .Settings.Postgres.DSN}}open{{end}} style="margin-top:14px">
<summary style="cursor:pointer;color:var(--accent);font-size:13px">{{if .Settings.Postgres.DSN}}Изменить подключение{{else}}…или ввести DSN вручную{{end}}</summary>
<form method="post" action="/admin/setup/postgres" style="margin-top:12px;display:grid;gap:10px;max-width:640px">
<label>DSN</label>
<input type="text" name="dsn" value="{{.Settings.Postgres.DSN}}" placeholder="postgres://bj:secret@127.0.0.1:5432/bj?sslmode=disable">
<p class="muted" style="margin:0">При сохранении выполняется Ping.</p>
<div><button type="submit" class="btn">Сохранить и проверить</button></div>
</form>
</details>
</div>
</section>
<hr style="margin:18px 0;border:none;border-top:1px solid var(--border)">
<h3 style="font-size:14px;margin:0 0 8px">Импорт сертификата (.pfx / .cer / .crt)</h3>
<p class="muted">PFX с приватным ключом (с PIN) — для серверной подписи и подписи оператора. CER/CRT без приватного ключа — для проверки чужих подписей (например, сертификаты УЦ НРД для проверки квитанций). Подробно — <a href="/admin/help/cryptopro">/admin/help/cryptopro</a>.</p>
<form method="post" action="/admin/setup/crypto/import-cert" enctype="multipart/form-data" style="margin-top:8px;display:grid;gap:8px;grid-template-columns:auto auto auto auto;align-items:center">
<input type="file" name="cert" accept=".pfx,.p12,.cer,.crt" required style="padding:6px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
<input type="text" name="pin" placeholder="PIN (только для .pfx/.p12)" style="padding:6px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;font-family:monospace">
<select name="store" style="padding:6px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
<option value="uMy">uMy — личный (для подписи)</option>
<option value="mroot">mroot — корневой УЦ (для проверки)</option>
<option value="uRoot">uRoot — промежуточные УЦ</option>
</select>
<button type="submit" class="btn" style="background:var(--accent);color:white;border:none;padding:8px 16px;border-radius:4px">Импортировать</button>
</form>
{{/* ============ КРИПТОГРАФИЯ ============ */}}
<section class="settings-section" id="sec-crypto">
<h1>Криптография</h1>
<hr style="margin:18px 0;border:none;border-top:1px solid var(--border)">
<h3 style="font-size:14px;margin:0 0 8px">Активация лицензии</h3>
<form method="post" action="/admin/setup/crypto/activate" style="margin-top:6px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<input type="text" name="license_key" value="{{.Settings.Crypto.LicenseKey}}" placeholder="XXXX-XXXXX-XXXXX-XXXXX-XXXXX (серийный номер КриптоПро CSP)" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;font-family:monospace;font-size:12px;min-width:340px">
<button type="submit" class="btn" style="background:var(--ok);color:#0a0f1a;border:none;padding:8px 16px;border-radius:4px;font-weight:600">Активировать лицензию</button>
</form>
<p class="muted" style="margin-top:8px">Вызовет <code>cpconfig -license -set</code> и сохранит серийник. Если КриптоПро CSP ещё не установлен — покажет инструкцию.</p>
</details>
</div>
<div class="card" style="border-left:3px solid var(--accent)">
<h2>🔑 Установка ключа на флешку</h2>
<p class="muted">Пошаговый мастер: загрузить архив НРД с паролем → запись на флешку → справочник сертификатов → проверка Валидаты → готово.</p>
<a href="/admin/setup/keywizard" class="btn btn-ok" style="margin-top:10px;display:inline-block">Открыть мастер установки ключа →</a>
</div>
<!-- Контейнеры КриптоПро на флешке -->
<div class="card">
<h2><span class="dot {{if .FlashContainers}}ok{{else}}warn{{end}}"></span>Контейнеры на USB-носителях (флешка/Рутокен)</h2>
{{if .FlashContainers}}
<p class="muted">Найдено {{len .FlashContainers}} контейнер(а) формата <code>name.000</code> на смонтированных USB-носителях. Кнопка ниже копирует папку в <code>/var/opt/cprocsp/keys/$USER/</code> — после этого контейнер виден как <code>\\.\HDIMAGE\name</code> и работает без вставленной флешки.</p>
<table style="margin-top:8px">
<thead><tr><th>Носитель</th><th>Имя контейнера</th><th>Файлы</th><th>Статус</th><th></th></tr></thead>
<tbody>
{{range .FlashContainers}}
<tr>
<td><code style="font-size:12px">{{.Mountpoint}}</code></td>
<td><strong>{{.Name}}</strong></td>
<td><span class="muted" style="font-size:11px">{{len .Files}} файлов</span></td>
<td>{{if .AlreadyImported}}<span style="color:var(--ok)">уже в HDIMAGE</span>{{else}}<span class="muted">только на флешке</span>{{end}}</td>
<td>
{{if not .AlreadyImported}}
<form method="post" action="/admin/setup/crypto/copy-container" style="margin:0">
<input type="hidden" name="src" value="{{.Path}}">
<button type="submit" class="btn" style="background:var(--ok);color:#0a0f1a;padding:6px 12px;font-size:12px;font-weight:600">Скопировать в локальное хранилище</button>
<div class="card">
<h2><span class="dot {{if .Settings.Crypto.Profile}}ok{{else}}warn{{end}}"></span>СКЗИ «Валидата Клиент L»</h2>
<p class="muted">Активный профиль: <code>{{if .Settings.Crypto.Profile}}{{.Settings.Crypto.Profile}}{{else}}{{end}}</code> · провайдер <code>{{.Settings.Crypto.Provider}}</code>. Подробно — <a href="/admin/help/crypto">справка</a>.</p>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:12px">
<form method="post" action="/admin/setup/crypto/check" style="margin:0"><button type="submit" class="btn btn-ok">✓ Проверить СКЗИ</button></form>
<form method="post" action="/admin/setup/crypto/test-sign" style="margin:0"><button type="submit" class="btn">✎ Тестовая подпись</button></form>
<form method="post" action="/admin/setup/restart-crypto" style="margin:0" onsubmit="return confirm('Перезапустить crypto-service? Поднимется через ~5 сек.');"><button type="submit" class="btn btn-warn">↻ crypto-service</button></form>
<form method="post" action="/admin/setup/restart-server" style="margin:0" onsubmit="return confirm('Перезапустить bj-server? Через 5-10 сек страница вернётся.');"><button type="submit" class="btn btn-secondary">↻ bj-server</button></form>
</div>
<details style="margin-top:14px">
<summary style="cursor:pointer;color:var(--accent);font-size:13px">Параметры провайдера (для совместимости)</summary>
<form method="post" action="/admin/setup/crypto" style="margin-top:12px;display:grid;gap:10px;max-width:640px">
<label>Провайдер</label>
<select name="provider">
<option value="stub" {{if eq .Settings.Crypto.Provider "stub"}}selected{{end}}>stub — без криптографии (демо)</option>
<option value="validata" {{if eq .Settings.Crypto.Provider "validata"}}selected{{end}}>Валидата Клиент L</option>
</select>
<label>Путь к модулю PKCS#11</label>
<input type="text" name="module_path" value="{{.Settings.Crypto.ModulePath}}" placeholder="/opt/Validata/VDCSP/lib/amd64/libvdpkcs11.so">
<label>UDS-сокет</label>
<input type="text" name="socket_path" value="{{.Settings.Crypto.SocketPath}}" placeholder="/run/bj/crypto.sock">
<div><button type="submit" class="btn">Сохранить</button></div>
</form>
</details>
</div>
{{/* Носители ключей */}}
<div class="card">
<h2><span class="dot {{if .Media}}ok{{else}}warn{{end}}"></span>Носители ключей</h2>
<p class="muted">USB-флешки сканируются автоматически. Образы (.iso/.img/.zip/.7z) загружаются ниже — bj-server распакует и найдёт профиль Валидаты, контейнеры, сертификаты.</p>
<form method="post" action="/admin/setup/media/iso/upload" enctype="multipart/form-data" style="margin-top:12px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<input type="file" name="iso" accept=".iso,.img,.zip,.7z" required style="flex:1;min-width:240px">
<input type="password" name="password" placeholder="Пароль архива (для MOEX — 11)" autocomplete="off" style="min-width:200px">
<button type="submit" class="btn">Загрузить</button>
</form>
<p class="muted" style="margin-top:6px;font-size:12px">Лимит 500 МБ. Распаковка через 7z.</p>
{{if .Media}}
{{range .Media}}
<div style="margin-top:14px;padding:14px;background:var(--card-2);border:1px solid var(--border);border-radius:10px">
<div style="display:flex;justify-content:space-between;align-items:center">
<div><strong>{{if eq .Kind "iso"}}📀 ISO{{else}}🔌 USB{{end}}</strong> <code style="font-size:12px;margin-left:8px">{{.Mountpoint}}</code>{{if .Source}}<span class="muted" style="font-size:11px;margin-left:8px">{{.Source}}</span>{{end}}</div>
{{if eq .Kind "iso"}}
<form method="post" action="/admin/setup/media/iso/unmount" style="margin:0" onsubmit="return confirm('Удалить распаковку {{.Mountpoint}}?');">
<input type="hidden" name="id" value="{{.ID}}"><button type="submit" class="btn btn-secondary" style="padding:5px 11px;font-size:12px">Удалить распаковку</button>
</form>
{{end}}
</td>
</tr>
</div>
{{if .Profile}}
<h3>Профиль Валидаты</h3>
<table>
<tbody>
<tr><td>ПСП (.pse)</td><td class="muted" style="font-size:11px">{{range .Profile.PSEFiles}}{{.}}<br>{{end}}</td></tr>
<tr><td>ЛСП (.gdbm)</td><td class="muted" style="font-size:11px">{{range .Profile.GDBMFiles}}{{.}}<br>{{end}}</td></tr>
<tr><td>Ключи (.vdk)</td><td class="muted" style="font-size:11px">{{range .Profile.KeyFiles}}{{.}}<br>{{end}}</td></tr>
</tbody>
</table>
<form method="post" action="/admin/setup/media/import-profile" style="margin-top:8px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<input type="hidden" name="root" value="{{.Profile.Root}}">
<input type="text" name="name" placeholder="Имя профиля, напр. nrd-edo">
{{if .Profile.Imported}}<span style="color:var(--ok)">✓ импортирован</span>{{else}}<button type="submit" class="btn btn-ok" style="padding:6px 12px;font-size:12px">Импортировать профиль</button>{{end}}
</form>
{{end}}
{{if .Containers}}
<h3>Контейнеры ({{len .Containers}})</h3>
<table>
<thead><tr><th>Имя</th><th>Статус</th><th></th></tr></thead>
<tbody>{{range .Containers}}
<tr><td><strong>{{.Name}}</strong> <span class="muted" style="font-size:11px">{{.Path}}</span></td><td>{{if .Imported}}<span style="color:var(--ok)">импортирован</span>{{else}}<span class="muted">нет</span>{{end}}</td>
<td>{{if not .Imported}}<form method="post" action="/admin/setup/media/import-container" style="margin:0"><input type="hidden" name="path" value="{{.Path}}"><button type="submit" class="btn btn-ok" style="padding:5px 11px;font-size:12px">Импортировать</button></form>{{end}}</td></tr>
{{end}}</tbody>
</table>
{{end}}
{{if and (not .Containers) (not .Certificates) (not .Profile)}}<p class="muted" style="margin-top:8px;font-size:12px">Профиль Валидаты не найден на носителе.</p>{{end}}
</div>
{{end}}
</tbody>
</table>
{{else}}
<p class="muted">Подключённые USB-носители с контейнерами КриптоПро (папки <code>name.000</code> с *.key) не обнаружены. Поиск идёт в <code>/run/media/$USER/</code>, <code>/media/$USER/</code>, <code>/media/</code>, <code>/mnt/</code>. Вставьте флешку и обновите страницу.</p>
{{end}}
</div>
{{else}}<p class="muted" style="margin-top:10px">Носители не обнаружены. Подключите USB или загрузите образ.</p>{{end}}
<!-- Авто-загрузка сертификатов УЦ НРД -->
<div class="card">
<h2><span class="dot {{if .Settings.CACerts.URLs}}ok{{else}}warn{{end}}"></span>Сертификаты УЦ (НРД и др.) — авто-загрузка</h2>
<p class="muted">Прямые URL .cer-файлов УЦ НРД (см. <a href="https://www.nsd.ru/workflow/system/cryptography/" target="_blank" rel="noopener">nsd.ru/workflow/system/cryptography/</a>) и других УЦ. Каждый URL скачивается, парсится X.509, и автоматически импортируется в КриптоПро (<code>mroot</code> для корневых, <code>uRoot</code> для промежуточных). Включите авто-обновление — раз в сутки система перепроверит и переустановит, если сертификат изменился.</p>
<form method="post" action="/admin/setup/cacerts" style="margin-top:10px;display:grid;gap:10px">
<label>URL'ы .cer-файлов (один на строку)</label>
<textarea name="urls" rows="4" placeholder="https://www.nsd.ru/path/to/root-ca.cer&#10;https://www.nsd.ru/path/to/sub-ca.cer" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;font-family:monospace;font-size:12px">{{range .Settings.CACerts.URLs}}{{.}}
{{end}}</textarea>
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" name="auto_update" {{if .Settings.CACerts.AutoUpdate}}checked{{end}}>
<span>Авто-обновление раз в сутки</span>
</label>
<div style="display:flex;gap:8px;align-items:center">
<button type="submit" class="btn">Сохранить</button>
{{if .ImportedProfiles}}
<h3 style="margin-top:18px">Импортированные профили</h3>
<table>
<thead><tr><th>Имя</th><th>Состояние</th><th>Действия</th></tr></thead>
<tbody>{{range .ImportedProfiles}}
<tr><td><strong>{{.}}</strong></td><td>{{if eq . $.Settings.Crypto.Profile}}<span style="color:var(--ok)">✓ активен</span>{{else}}<span class="muted">не активен</span>{{end}}</td>
<td style="display:flex;gap:6px;flex-wrap:wrap">
{{if ne . $.Settings.Crypto.Profile}}<form method="post" action="/admin/setup/media/activate-profile" style="margin:0"><input type="hidden" name="name" value="{{.}}"><button type="submit" class="btn" style="padding:5px 11px;font-size:12px">Активировать</button></form>{{end}}
<form method="post" action="/admin/setup/media/delete-profile" style="margin:0" onsubmit="return confirm('Удалить профиль «{{.}}»?');"><input type="hidden" name="name" value="{{.}}"><button type="submit" class="btn btn-danger" style="padding:5px 11px;font-size:12px">Удалить</button></form>
</td></tr>
{{end}}</tbody>
</table>
{{end}}
</div>
</form>
<form method="post" action="/admin/setup/cacerts/fetch" style="margin-top:8px">
<button type="submit" class="btn" style="background:var(--ok);color:#0a0f1a;font-weight:600">⬇ Скачать и импортировать сейчас</button>
{{if not .Settings.CACerts.LastFetch.IsZero}}
<span class="muted" style="margin-left:10px">Последнее обновление: {{.Settings.CACerts.LastFetch.Format "02.01.2006 15:04:05"}}</span>
{{end}}
</form>
{{if .Settings.CACerts.FetchedCerts}}
<table style="margin-top:14px">
<thead><tr><th>URL</th><th>Владелец</th><th>Хранилище</th><th>Действителен до</th><th>SHA-256</th><th>Статус</th></tr></thead>
<tbody>
{{range .Settings.CACerts.FetchedCerts}}
<tr>
<td style="max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="{{.URL}}"><code style="font-size:11px">{{.URL}}</code></td>
<td>{{.SubjectCN}}</td>
<td><code>{{.Store}}</code></td>
<td>{{if not .NotAfter.IsZero}}{{.NotAfter.Format "02.01.2006"}}{{end}}</td>
<td><code style="font-size:11px">{{if .SHA256}}{{slice .SHA256 0 12}}…{{end}}</code></td>
<td>{{if .Error}}<span style="color:var(--err)" title="{{.Error}}">ошибка</span>{{else}}<span style="color:var(--ok)">ок</span>{{end}}</td>
</tr>
{{/* Сертификаты УЦ */}}
<div class="card">
<h2><span class="dot {{if .Settings.CACerts.URLs}}ok{{else}}warn{{end}}"></span>Сертификаты УЦ (авто-загрузка)</h2>
<p class="muted">URL .cer-файлов УЦ НРД (<a href="https://www.nsd.ru/workflow/system/cryptography/" target="_blank" rel="noopener">nsd.ru</a>). Скачиваются, парсятся и сохраняются в <code>/var/lib/bj/ca-certs/</code>.</p>
<form method="post" action="/admin/setup/cacerts" style="margin-top:10px;display:grid;gap:10px;max-width:720px">
<label>URL'ы (один на строку)</label>
<textarea name="urls" rows="3" style="font-family:ui-monospace,monospace;font-size:12px">{{range .Settings.CACerts.URLs}}{{.}}
{{end}}</textarea>
<label style="display:flex;align-items:center;gap:8px;cursor:pointer"><input type="checkbox" name="auto_update" {{if .Settings.CACerts.AutoUpdate}}checked{{end}} style="width:auto"> Авто-обновление раз в сутки</label>
<div style="display:flex;gap:8px"><button type="submit" class="btn">Сохранить</button></div>
</form>
<form method="post" action="/admin/setup/cacerts/fetch" style="margin-top:8px"><button type="submit" class="btn btn-ok">⬇ Скачать сейчас</button>{{if not .Settings.CACerts.LastFetch.IsZero}}<span class="muted" style="margin-left:10px">обновлено {{.Settings.CACerts.LastFetch.Format "02.01.2006 15:04"}}</span>{{end}}</form>
{{if .Settings.CACerts.FetchedCerts}}
<table style="margin-top:14px">
<thead><tr><th>Владелец</th><th>Тип</th><th>До</th><th>Статус</th></tr></thead>
<tbody>{{range .Settings.CACerts.FetchedCerts}}<tr><td>{{.SubjectCN}}</td><td><code>{{.Store}}</code></td><td>{{if not .NotAfter.IsZero}}{{.NotAfter.Format "02.01.2006"}}{{end}}</td><td>{{if .Error}}<span style="color:var(--err)" title="{{.Error}}">ошибка</span>{{else}}<span style="color:var(--ok)">ок</span>{{end}}</td></tr>{{end}}</tbody>
</table>
{{end}}
</tbody>
</table>
{{end}}
{{if .Settings.CACerts.LastFetchLog}}
<details style="margin-top:10px">
<summary style="cursor:pointer;color:var(--accent);font-size:13px">Лог последнего обновления</summary>
<pre style="margin-top:8px">{{.Settings.CACerts.LastFetchLog}}</pre>
</details>
{{end}}
</div>
</div>
</section>
<!-- nsd-adapter / ИШ НРД -->
<div class="card">
<h2><span class="dot {{if .Settings.NSD.IGWBaseURL}}ok{{else}}err{{end}}"></span>Интеграционный шлюз НРД (ИШ)</h2>
<p class="muted">{{if not .Settings.NSD.IGWBaseURL}}Сейчас <code>mock-режим</code> — Decision эмитируется через 3 секунды после Send.{{else}}Профиль <code>{{.Settings.NSD.Profile}}</code>, ИШ <code>{{.Settings.NSD.IGWBaseURL}}</code>.{{end}}</p>
<p class="muted">Подключение к стендам: <a href="/admin/help/systems">/admin/help/systems</a> — там полная таблица URL контуров GUEST/TEST3/PROD и инструкция по установке ИШ. Дистрибутив ИШ скачивается с <code>www.nsd.ru/workflow/system/programs/#0-widget-faq-0-4</code>.</p>
<details {{if not .Settings.NSD.IGWBaseURL}}open{{end}}>
<summary style="cursor:pointer;color:var(--accent);font-size:13px">Изменить параметры ИШ</summary>
<form method="post" action="/admin/setup/nsd" style="margin-top:12px;display:grid;gap:10px">
<div class="form-row" style="display:grid;grid-template-columns:200px 1fr;gap:12px;align-items:center">
<label>Профиль</label>
<select name="profile" id="nsd-profile" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
<option value="" {{if eq .Settings.NSD.Profile ""}}selected{{end}}>— mock (демо без реального ИШ) —</option>
<option value="guest-gost" {{if eq .Settings.NSD.Profile "guest-gost"}}selected{{end}}>guest-gost — контур GUEST, ГОСТ ключи</option>
<option value="guest-rsa" {{if eq .Settings.NSD.Profile "guest-rsa"}}selected{{end}}>guest-rsa — контур GUEST, RSA ключи</option>
<option value="test3-gost" {{if eq .Settings.NSD.Profile "test3-gost"}}selected{{end}}>test3-gost — контур TEST3, ГОСТ ключи</option>
<option value="test3-rsa" {{if eq .Settings.NSD.Profile "test3-rsa"}}selected{{end}}>test3-rsa — контур TEST3, RSA ключи</option>
<option value="prod-gost" {{if eq .Settings.NSD.Profile "prod-gost"}}selected{{end}}>prod-gost — продуктивный, ГОСТ</option>
<option value="prod-rsa" {{if eq .Settings.NSD.Profile "prod-rsa"}}selected{{end}}>prod-rsa — продуктивный, RSA</option>
{{/* ============ НРД ============ */}}
<section class="settings-section" id="sec-nsd">
<h1>НРД</h1>
<div class="card">
<h2><span class="dot {{if .Settings.NSD.IGWBaseURL}}ok{{else}}warn{{end}}"></span>Интеграционный шлюз (ИШ)</h2>
<p class="muted">{{if not .Settings.NSD.IGWBaseURL}}<code>mock-режим</code> — без реального ИШ.{{else}}Профиль <code>{{.Settings.NSD.Profile}}</code>, ИШ <code>{{.Settings.NSD.IGWBaseURL}}</code>.{{end}} Стенды и установка — <a href="/admin/help/systems">справка</a>.</p>
<form method="post" action="/admin/setup/nsd" style="margin-top:12px;display:grid;gap:10px;max-width:680px">
<label>Профиль / контур</label>
<select name="profile" id="nsd-profile">
<option value="" {{if eq .Settings.NSD.Profile ""}}selected{{end}}>— mock (демо) —</option>
<option value="guest-gost" {{if eq .Settings.NSD.Profile "guest-gost"}}selected{{end}}>guest-gost — GUEST, ГОСТ</option>
<option value="guest-rsa" {{if eq .Settings.NSD.Profile "guest-rsa"}}selected{{end}}>guest-rsa — GUEST, RSA</option>
<option value="test3-gost" {{if eq .Settings.NSD.Profile "test3-gost"}}selected{{end}}>test3-gost — TEST3, ГОСТ</option>
<option value="test3-rsa" {{if eq .Settings.NSD.Profile "test3-rsa"}}selected{{end}}>test3-rsa — TEST3, RSA</option>
<option value="prod-gost" {{if eq .Settings.NSD.Profile "prod-gost"}}selected{{end}}>prod-gost — ПРОМ, ГОСТ</option>
<option value="prod-rsa" {{if eq .Settings.NSD.Profile "prod-rsa"}}selected{{end}}>prod-rsa — ПРОМ, RSA</option>
</select>
</div>
<div class="form-row" style="display:grid;grid-template-columns:200px 1fr;gap:12px;align-items:center">
<label>URL ONYX (WSL) НРД</label>
<input type="text" name="igw_base_url" id="nsd-url" value="{{.Settings.NSD.IGWBaseURL}}" placeholder="будет заполнено по профилю" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
</div>
<div class="form-row" style="display:grid;grid-template-columns:200px 1fr;gap:12px;align-items:center">
<input type="text" name="igw_base_url" id="nsd-url" value="{{.Settings.NSD.IGWBaseURL}}" placeholder="автозаполнится по профилю">
<label>Ключевой контейнер</label>
<input type="text" name="key_container" id="nsd-container" value="{{.Settings.NSD.KeyContainer}}" placeholder="GUEST_GOST_CONTAINER (или ваш контейнер УЦ НРД)" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
</div>
<p class="muted">При смене профиля URL ONYX автозаполнится по таблице НРД (из <code>DOC/Ссылки для доступа в тестовые контуры.pdf</code>). При сохранении проверяется доступность URL.</p>
<button type="submit" class="btn" style="background:var(--accent);color:white;border:none;padding:8px 16px;border-radius:4px">Сохранить и проверить</button>
</form>
<script>
// Автозаполнение URL ONYX и дефолтного контейнера по выбранному профилю.
(function() {
var urls = {
"guest-gost": ["https://gost-gt.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "GUEST_GOST_CONTAINER"],
"guest-rsa": ["https://rsa-gt.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "GUEST_RSA_CONTAINER"],
"test3-gost": ["https://gost-t3.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "TEST3_GOST_CONTAINER"],
"test3-rsa": ["https://rsa-t3.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "TEST3_RSA_CONTAINER"],
"prod-gost": ["https://gost.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "PROD_GOST_CONTAINER"],
"prod-rsa": ["https://rsa.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "PROD_RSA_CONTAINER"]
};
var profile = document.getElementById("nsd-profile");
var urlInput = document.getElementById("nsd-url");
var contInput = document.getElementById("nsd-container");
profile.addEventListener("change", function() {
var p = profile.value;
if (urls[p]) {
if (!urlInput.value || confirm("Заменить URL и контейнер на дефолт для профиля " + p + "?")) {
urlInput.value = urls[p][0];
contInput.value = urls[p][1];
}
}
});
})();
</script>
</details>
</div>
<input type="text" name="key_container" id="nsd-container" value="{{.Settings.NSD.KeyContainer}}" placeholder="напр. TEST3_GOST_CONTAINER">
<hr style="border:none;border-top:1px solid var(--border);margin:6px 0">
<p class="muted" style="margin:0">Депозитарные реквизиты (откуда списываются бумаги) — из договора/письма НРД. Нужны для формирования заявки на перевод.</p>
<label>Депозитарный код</label>
<input type="text" name="deponent_code" value="{{.Settings.NSD.DeponentCode}}" placeholder="напр. MC0413600000">
<label>Депозитарный счёт</label>
<input type="text" name="account_id" value="{{.Settings.NSD.AccountID}}" placeholder="депозитарный счёт">
<label>Раздел счёта</label>
<input type="text" name="section_id" value="{{.Settings.NSD.SectionID}}" placeholder="раздел депозитарного счёта">
<div><button type="submit" class="btn">Сохранить и проверить</button></div>
</form>
<script>
(function() {
var urls = {
"guest-gost": ["https://gost-gt.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "GUEST_GOST_CONTAINER"],
"guest-rsa": ["https://rsa-gt.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "GUEST_RSA_CONTAINER"],
"test3-gost": ["https://gost-t3.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "TEST3_GOST_CONTAINER"],
"test3-rsa": ["https://rsa-t3.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "TEST3_RSA_CONTAINER"],
"prod-gost": ["https://gost.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "PROD_GOST_CONTAINER"],
"prod-rsa": ["https://rsa.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo", "PROD_RSA_CONTAINER"]
};
var p = document.getElementById("nsd-profile"), u = document.getElementById("nsd-url"), c = document.getElementById("nsd-container");
if (p) p.addEventListener("change", function() {
var v = p.value;
if (urls[v] && (!u.value || confirm("Заменить URL и контейнер на дефолт для " + v + "?"))) { u.value = urls[v][0]; c.value = urls[v][1]; }
});
})();
</script>
</div>
<!-- LK callback -->
<div class="card">
<h2><span class="dot {{if .Settings.LK.CallbackURL}}ok{{else}}err{{end}}"></span>Callback в ЛК</h2>
<p class="muted">{{if .Settings.LK.CallbackURL}}Callback URL: <code>{{.Settings.LK.CallbackURL}}</code>{{else}}Сейчас используется встроенный lk-emulator (он сам зарегистрировал свой адрес при старте).{{end}}</p>
<details {{if not .Settings.LK.CallbackURL}}open{{end}}>
<summary style="cursor:pointer;color:var(--accent);font-size:13px">Указать URL реального ЛК</summary>
<form method="post" action="/admin/setup/lk" style="margin-top:12px">
<div class="form-row" style="display:grid;grid-template-columns:200px 1fr;gap:12px;align-items:center">
<div class="card">
<h2><span class="dot {{if .Settings.LK.CallbackURL}}ok{{else}}warn{{end}}"></span>Callback в личный кабинет <span class="muted" style="font-size:12px;font-weight:400">(необязательно)</span></h2>
<p class="muted">{{if .Settings.LK.CallbackURL}}<code>{{.Settings.LK.CallbackURL}}</code>{{else}}Не настроен — уведомления в ЛК отключены. Для работы с НРД не требуется.{{end}}</p>
<form method="post" action="/admin/setup/lk" style="margin-top:12px;display:grid;gap:10px;max-width:640px">
<label>Callback URL</label>
<input type="text" name="callback_url" value="{{.Settings.LK.CallbackURL}}" placeholder="http://lk.example.com" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
<input type="text" name="callback_url" value="{{.Settings.LK.CallbackURL}}" placeholder="http://lk.example.com">
<div><button type="submit" class="btn">Сохранить и проверить</button></div>
</form>
</div>
</section>
{{/* ============ ТЕСТЫ ============ */}}
<section class="settings-section" id="sec-tests">
<h1>Тесты</h1>
<div class="card">
<h2>Тестовый пакет роботу НРД</h2>
<p class="muted">Робот <code>MC0012500000</code> эмулирует вторую сторону перевода. Выберите сценарий — bj-server отправит эталонный запрос через ИШ, ответ придёт во входящие. Требуется настроенный ИШ + профиль Валидаты.</p>
<form method="post" action="/admin/setup/test-nsd" style="margin-top:12px;display:flex;gap:8px;flex-wrap:wrap;align-items:center">
<select name="scenario">
<option value="2001">2001 — Принять все бумаги</option>
<option value="2002">2002 — Принять частично</option>
<option value="1111">1111 — Ответ с отказом</option>
<option value="3333">3333 — Робот принимающая сторона</option>
</select>
<button type="submit" class="btn">Отправить роботу</button>
</form>
<p class="muted" style="margin-top:6px;font-size:12px">Ответ робота — асинхронно (~30-60 сек) во входящие ИШ.</p>
</div>
<div class="card">
<h2>Сквозной тестовый прогон (mock/реальный)</h2>
<p class="muted">Заявка с предзаполненными данными через всю цепочку до финального статуса.</p>
<form method="post" action="/admin/setup/test-run" style="margin-top:12px"><button type="submit" class="btn">Запустить тестовую заявку</button></form>
{{if .Settings.LastTest}}
<div style="margin-top:16px;padding:14px;background:var(--card-2);border:1px solid var(--border);border-radius:10px">
<strong>Последний прогон</strong>
<table style="margin-top:8px">
<tbody>
<tr><td style="width:160px" class="muted">Статус</td><td>{{if .Settings.LastTest.OK}}<span style="color:var(--ok)">✓ успешно</span>{{else}}<span style="color:var(--err)">✗ не прошёл</span>{{end}}</td></tr>
<tr><td class="muted">FSM-статус</td><td><code>{{.Settings.LastTest.FinalStatus}}</code></td></tr>
<tr><td class="muted">ClaimID</td><td><code>{{.Settings.LastTest.ClaimID}}</code> {{if .Settings.LastTest.ClaimID}}<a href="/admin/claims/{{.Settings.LastTest.ClaimID}}">→ карточка</a>{{end}}</td></tr>
<tr><td class="muted">Сообщение</td><td>{{.Settings.LastTest.Message}}</td></tr>
</tbody>
</table>
</div>
<p class="muted" style="margin-top:8px">URL до базового хоста ЛК (без /api). При сохранении выполняется GET <code>{URL}/healthz</code>.</p>
<button type="submit" class="btn" style="background:var(--accent);color:white;border:none;padding:8px 16px;border-radius:4px;margin-top:8px">Сохранить и проверить</button>
</form>
</details>
{{end}}
</div>
</section>
{{/* ============ ОБНОВЛЕНИЯ ============ */}}
<section class="settings-section" id="sec-update">
<h1>Обновления</h1>
<div class="card">
<h2>Версия bj-server</h2>
<div style="display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin-top:6px">
<span class="stat-value" style="font-size:20px">{{.CurrentVersion}}</span>
{{if .Settings.Update.Available}}
{{if ne .Settings.Update.Available .CurrentVersion}}
<span class="hero-status warn">● Доступна {{.Settings.Update.Available}}</span>
<form method="post" action="/admin/setup/update/apply" style="margin:0" onsubmit="return confirm('Скачать и установить {{.Settings.Update.Available}}? bj-server перезапустится.');">
<button type="submit" class="btn btn-ok">⬆ Установить {{.Settings.Update.Available}}</button>
</form>
{{else}}<span class="hero-status ok">● Актуальная версия</span>{{end}}
{{end}}
<form method="post" action="/admin/setup/update/check" style="margin:0"><button type="submit" class="btn btn-secondary">Проверить обновления</button></form>
</div>
{{if .Settings.Update.Notes}}<p class="muted" style="margin-top:10px">Что нового: {{.Settings.Update.Notes}}</p>{{end}}
{{if not .Settings.Update.LastCheck.IsZero}}<p class="muted" style="margin-top:6px;font-size:12px">Последняя проверка: {{.Settings.Update.LastCheck.Format "02.01.2006 15:04"}} — {{.Settings.Update.LastResult}}</p>{{end}}
{{if and .License.Present (not .License.AllowsUpdates)}}<p class="muted" style="margin-top:6px;font-size:12px;color:var(--warn)">⚠ Текущий план «{{.License.Plan}}» не включает обновления.</p>{{end}}
</div>
<div class="card">
<h2><span class="dot {{if .Settings.Update.BaseURL}}ok{{else}}warn{{end}}"></span>Источник обновлений</h2>
<p class="muted">Артефактория раздаёт подписанные релизы. Обновления проверяются по подписи Ed25519 и sha256 — без валидной подписи установка не выполняется.</p>
<form method="post" action="/admin/setup/update" style="margin-top:12px;display:grid;gap:10px;max-width:680px">
<label>URL артефактории</label>
<input type="text" name="base_url" value="{{.Settings.Update.BaseURL}}" placeholder="https://updates.example.com">
<label>Канal</label>
<select name="channel">
<option value="stable" {{if eq .Settings.Update.Channel "stable"}}selected{{end}}>stable — стабильный</option>
<option value="beta" {{if eq .Settings.Update.Channel "beta"}}selected{{end}}>beta — предварительный</option>
</select>
<label>Публичный ключ издателя (base64 Ed25519)</label>
<input type="text" name="public_key" value="{{.Settings.Update.PublicKey}}" placeholder="зашит в релиз; переопределить здесь">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer"><input type="checkbox" name="auto_check" {{if .Settings.Update.AutoCheck}}checked{{end}} style="width:auto"> Проверять автоматически (раз в 6 часов)</label>
<div><button type="submit" class="btn">Сохранить</button></div>
</form>
</div>
</section>
{{/* ============ ЛИЦЕНЗИЯ ============ */}}
<section class="settings-section" id="sec-license">
<h1>Лицензия</h1>
<div class="card">
<h2>
{{if .License.Valid}}<span class="dot ok"></span>Активна
{{else if .License.Present}}<span class="dot err"></span>Недействительна
{{else}}<span class="dot warn"></span>Не активирована{{end}}
</h2>
{{if .License.Present}}
<table style="margin-top:8px">
<tbody>
{{if .License.Tenant}}<tr><td style="width:180px" class="muted">Организация</td><td><strong>{{.License.Tenant}}</strong></td></tr>{{end}}
{{if .License.Plan}}<tr><td class="muted">План</td><td><span class="badge confirmed">{{.License.Plan}}</span></td></tr>{{end}}
{{if not .License.ExpiresAt.IsZero}}<tr><td class="muted">Действует до</td><td>{{.License.ExpiresAt.Format "02.01.2006"}} {{if .License.Valid}}<span class="muted">(осталось {{.License.DaysLeft}} дн.)</span>{{end}}</td></tr>{{end}}
<tr><td class="muted">Обновления</td><td>{{if .License.AllowsUpdates}}<span style="color:var(--ok)">включены</span>{{else}}<span class="muted">не входят в план</span>{{end}}</td></tr>
<tr><td class="muted">Статус</td><td>{{.License.Message}}</td></tr>
</tbody>
</table>
{{else}}
<p class="muted">Лицензионный ключ не введён. Без лицензии сервис работает, но автообновления заблокированы.</p>
{{end}}
</div>
<div class="card">
<h2>Активация</h2>
<p class="muted">Вставьте лицензионный ключ, полученный от поставщика. Проверка офлайн по подписи — связь с сервером лицензий не требуется.</p>
<form method="post" action="/admin/setup/license" style="margin-top:12px;display:grid;gap:10px;max-width:720px">
<label>Лицензионный ключ</label>
<textarea name="key" rows="3" style="font-family:ui-monospace,monospace;font-size:11px" placeholder="payload.signature.keyid">{{.Settings.License.Key}}</textarea>
<details>
<summary style="cursor:pointer;color:var(--accent);font-size:13px">Публичный ключ издателя (если не зашит)</summary>
<input type="text" name="public_key" value="{{.Settings.License.PublicKey}}" placeholder="base64 Ed25519" style="margin-top:8px;width:100%">
</details>
<div><button type="submit" class="btn">Активировать</button></div>
</form>
</div>
</section>
</div>
</div>
<!-- Тестовый прогон -->
<div class="card">
<h2>Тестовый прогон сквозной заявки</h2>
<p class="muted">Создаст заявку с предзаполненными данными (инвестор Иванов И.И., 1500 акций Газпрома, ИИС T03), отправит её через всю цепочку и дождётся финального статуса. Если ИШ НРД настроен — пойдёт в реальный ИШ; иначе через mock с задержкой 3 сек.</p>
<form method="post" action="/admin/setup/test-run" style="margin-top:12px">
<button type="submit" class="btn" style="background:var(--accent);color:white;border:none;padding:10px 20px;border-radius:4px;font-weight:600">Запустить тестовую заявку</button>
</form>
{{if .Settings.LastTest}}
<div style="margin-top:16px;padding:12px;background:var(--bg);border:1px solid var(--border);border-radius:4px">
<strong>Последний прогон:</strong>
<table style="margin-top:8px">
<tr><td style="width:160px" class="muted">Статус</td><td>{{if .Settings.LastTest.OK}}<span style="color:var(--ok)">✓ успешно</span>{{else}}<span style="color:var(--err)">✗ не прошёл</span>{{end}}</td></tr>
<tr><td class="muted">Финальный FSM-статус</td><td><code>{{.Settings.LastTest.FinalStatus}}</code></td></tr>
<tr><td class="muted">ClaimID</td><td><code>{{.Settings.LastTest.ClaimID}}</code> {{if .Settings.LastTest.ClaimID}}<a href="/admin/claims/{{.Settings.LastTest.ClaimID}}">→ открыть карточку</a>{{end}}</td></tr>
<tr><td class="muted">Когда</td><td>{{.Settings.LastTest.StartedAt.Format "02.01.2006 15:04:05"}} — длительность {{.Settings.LastTest.FinishedAt.Sub .Settings.LastTest.StartedAt}}</td></tr>
<tr><td class="muted">Сообщение</td><td>{{.Settings.LastTest.Message}}</td></tr>
</table>
</div>
{{end}}
</div>
<script>
(function() {
// Переключение разделов админ-центра + запоминание выбранного (hash).
var navs = document.querySelectorAll('.settings-nav button');
var secs = document.querySelectorAll('.settings-section');
function show(id) {
navs.forEach(function(b){ b.classList.toggle('active', b.dataset.sec === id); });
secs.forEach(function(s){ s.classList.toggle('active', s.id === 'sec-' + id); });
try { history.replaceState(null, '', '#' + id); } catch(e) {}
}
navs.forEach(function(b){ b.addEventListener('click', function(){ show(b.dataset.sec); }); });
var h = (location.hash || '').replace('#','');
if (h && document.getElementById('sec-' + h)) show(h);
})();
</script>
{{end}}
@@ -22,7 +22,7 @@
<h2>Что подключается на следующих этапах</h2>
<table>
<tr><td class="muted" style="width:240px">PostgreSQL (схема m2m_core)</td><td>M2-шаг-3: pgx-репозиторий вместо MemoryRepository. Миграция готова — <code>migrations/m2m-core/001__deals.sql</code>.</td></tr>
<tr><td class="muted">crypto-service · КриптоПро JCP</td><td>M4: положить <code>jcp.jar</code> в <code>services/crypto-service/libs/</code>, выставить <code>BJ_CRYPTO_PROVIDER=cryptopro</code>, заполнить keystore профиля. Проверка — gRPC Health должна вернуть <code>provider=cryptopro, ok=true</code>.</td></tr>
<tr><td class="muted">crypto-service · Валидата PKCS#11</td><td>M4: установить АПК «Валидата Клиент L» (<code>zpki</code>, <code>zsdk</code>), выставить <code>BJ_CRYPTO_PROVIDER=validata</code> и путь к <code>libvdpkcs11.so</code>. Проверка — Health PKCS#11 должна вернуть <code>provider=validata, ok=true</code>.</td></tr>
<tr><td class="muted">nsd-adapter · ИШ НРД</td><td>M3: установить ИШ, выставить <code>BJ_NSD_PROFILE=guest-gost</code> или иной, <code>BJ_NSD_IGW_URL=http://localhost:8080</code>. Без этого сейчас используется <code>nsdadapter/mock</code> с эмуляцией ответов через 3 сек.</td></tr>
<tr><td class="muted">Реальный ЛК (ESIA Finance)</td><td>M4: согласовать <code>docs/lk-contract/v1/openapi.yaml</code> с командой ЛК, выставить <code>BJ_LK_CALLBACK_URL</code> на реальный адрес. Сейчас callback идёт в встроенный lk-emulator.</td></tr>
</table>
@@ -1,48 +1,42 @@
{{define "content"}}
<style>
.wizard-progress { display:flex; gap:6px; margin-bottom:24px; }
.wizard-step { flex:1; padding:12px 8px; border-radius:6px; background:var(--card); border:1px solid var(--border); text-align:center; position:relative; }
.wizard-step.done { background:rgba(63,191,108,0.12); border-color:var(--ok); }
.wizard-step.current { background:rgba(91,157,255,0.15); border-color:var(--accent); }
.wizard-step-num { display:block; font-size:11px; color:var(--muted); margin-bottom:4px; }
.wizard-step-name { font-size:13px; font-weight:600; }
.wizard-step.done .wizard-step-num::after { content:" ✓"; color:var(--ok); }
.wiz-head { text-align:center; padding:8px 0 4px; }
.wiz-head h1 { font-size:26px; font-weight:720; letter-spacing:-0.02em; margin:0 0 6px; }
.wiz-head p { color:var(--muted); margin:0; }
.wizard-progress { display:flex; align-items:center; justify-content:center; gap:0; margin:24px auto 28px; max-width:680px; }
.wstep { display:flex; flex-direction:column; align-items:center; gap:7px; flex:1; position:relative; }
.wstep .bub { width:34px; height:34px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:14px; font-weight:650; background:var(--card-2); border:1.5px solid var(--border-strong); color:var(--muted); z-index:1; transition:all .2s; }
.wstep .lbl { font-size:12px; color:var(--muted); font-weight:550; }
.wstep::before { content:""; position:absolute; top:17px; left:-50%; width:100%; height:2px; background:var(--border); z-index:0; }
.wstep:first-child::before { display:none; }
.wstep.done .bub { background:var(--ok); border-color:var(--ok); color:#fff; }
.wstep.done .lbl { color:var(--text-2); }
.wstep.done::before { background:var(--ok); }
.wstep.current .bub { background:var(--accent); border-color:var(--accent); color:#fff; box-shadow:0 0 0 4px var(--accent-weak); }
.wstep.current .lbl { color:var(--accent); font-weight:650; }
.tooltip { display:inline-block; background:var(--border); color:var(--muted); border-radius:50%; width:16px; height:16px; line-height:16px; text-align:center; font-size:11px; cursor:help; margin-left:4px; }
.where { font-size:12px; color:var(--accent); margin-left:8px; }
.help-block { background:rgba(91,157,255,0.07); border-left:3px solid var(--accent); padding:10px 14px; margin:10px 0; font-size:13px; }
.help-block { background:var(--accent-weak); border-left:3px solid var(--accent); padding:10px 14px; margin:10px 0; font-size:13px; border-radius:0 8px 8px 0; }
.help-block strong { color:var(--accent); }
.wiz-card { max-width:680px; margin:0 auto; }
</style>
<div class="card">
<h2>Мастер настройки</h2>
<p class="muted">Пошаговая настройка системы. Подходит для первого запуска. После каждого шага состояние сохраняется и можно вернуться позже.</p>
<div class="wiz-head">
<h1>Настройка Bridge&amp;Join</h1>
<p>Пошаговый мастер первого запуска — состояние сохраняется после каждого шага</p>
</div>
<div class="wizard-progress">
<div class="wizard-step {{if .Done.Postgres}}done{{end}} {{if eq .Step 1}}current{{end}}">
<span class="wizard-step-num">Шаг 1</span>
<span class="wizard-step-name">PostgreSQL</span>
</div>
<div class="wizard-step {{if .Done.Crypto}}done{{end}} {{if eq .Step 2}}current{{end}}">
<span class="wizard-step-num">Шаг 2</span>
<span class="wizard-step-name">КриптоПро / Рутокен</span>
</div>
<div class="wizard-step {{if .Done.Certs}}done{{end}} {{if eq .Step 3}}current{{end}}">
<span class="wizard-step-num">Шаг 3</span>
<span class="wizard-step-name">Сертификаты</span>
</div>
<div class="wizard-step {{if .Done.NSD}}done{{end}} {{if eq .Step 4}}current{{end}}">
<span class="wizard-step-num">Шаг 4</span>
<span class="wizard-step-name">Шлюз НРД</span>
</div>
<div class="wizard-step {{if .Done.TestRun}}done{{end}} {{if eq .Step 5}}current{{end}}">
<span class="wizard-step-num">Шаг 5</span>
<span class="wizard-step-name">Тестовая заявка</span>
</div>
<div class="wstep {{if .Done.Postgres}}done{{end}} {{if eq .Step 1}}current{{end}}"><span class="bub">{{if .Done.Postgres}}✓{{else}}1{{end}}</span><span class="lbl">База</span></div>
<div class="wstep {{if .Done.Crypto}}done{{end}} {{if eq .Step 2}}current{{end}}"><span class="bub">{{if .Done.Crypto}}✓{{else}}2{{end}}</span><span class="lbl">Валидата</span></div>
<div class="wstep {{if .Done.Certs}}done{{end}} {{if eq .Step 3}}current{{end}}"><span class="bub">{{if .Done.Certs}}✓{{else}}3{{end}}</span><span class="lbl">Сертификаты</span></div>
<div class="wstep {{if .Done.NSD}}done{{end}} {{if eq .Step 4}}current{{end}}"><span class="bub">{{if .Done.NSD}}✓{{else}}4{{end}}</span><span class="lbl">Шлюз НРД</span></div>
<div class="wstep {{if .Done.TestRun}}done{{end}} {{if eq .Step 5}}current{{end}}"><span class="bub">{{if .Done.TestRun}}✓{{else}}5{{end}}</span><span class="lbl">Проверка</span></div>
</div>
{{if .Flash}}<div class="card" style="border-left:3px solid var(--accent)"><p style="margin:0">{{.Flash}}</p></div>{{end}}
{{if .Flash}}<div class="card wiz-card" style="border-left:3px solid var(--accent)"><p style="margin:0">{{.Flash}}</p></div>{{end}}
<div class="wiz-card">
{{/* ============= ШАГ 1: PostgreSQL ============= */}}
{{if eq .Step 1}}
<div class="card">
@@ -82,60 +76,35 @@
</div>
{{end}}
{{/* ============= ШАГ 2: Крипто ============= */}}
{{/* ============= ШАГ 2: Крипто (Валидата) ============= */}}
{{if eq .Step 2}}
<div class="card">
<h2><span class="dot {{if .Done.Crypto}}ok{{else}}err{{end}}"></span>Шаг 2. Крипто-провайдер (КриптоПро CSP или Рутокен)</h2>
<h2><span class="dot {{if .Done.Crypto}}ok{{else}}err{{end}}"></span>Шаг 2. СКЗИ «Валидата Клиент L»</h2>
<p>СКЗИ нужен для подписи XMLDSig и проверки квитанций НРД.</p>
<div class="help-block">
<strong>Что это?</strong> КриптоПро CSP — российский криптопровайдер с поддержкой ГОСТ Р 34.10-2012. Рутокен ЭЦП 2.0 — USB-токен для безопасного хранения ключей. Можно использовать оба: CSP — для серверной части, Рутокен — для подписи действий оператора.<br>
<strong>Где взять?</strong> Дистрибутив КриптоПро CSP 5.0 R4<a href="https://www.cryptopro.ru/products/csp/downloads" target="_blank">cryptopro.ru/products/csp/downloads</a> (нужна регистрация в личном кабинете). Лицензия — там же или у дилера. Демо-лицензия на 3 месяца встроена в дистрибутив.
<strong>Что это?</strong> АПК «Валидата Клиент L» — российское СКЗИ с поддержкой ГОСТ Р 34.10-2012, поставляемое НРД для подключения к ЭДО. На Linux работает напрямую через PKCS#11 — отдельной лицензии и регистрационных данных <em>не требует</em>.<br>
<strong>Где взять?</strong> Дистрибутив для Astra Linux SE<a href="https://fs.moex.com/cdp/po/ClientL_ALSE.zip" target="_blank">fs.moex.com/cdp/po/ClientL_ALSE.zip</a>. Установка — <code>sudo dpkg -i zpki-*.deb zsdk-*.deb</code> (см. <a href="/admin/help/crypto">/admin/help/crypto</a>).
</div>
{{if not .CryptoProInstalled}}
<div style="background:var(--bg);border:1px solid var(--accent);border-radius:6px;padding:14px;margin-top:12px">
<h3 style="margin:0 0 8px 0;font-size:15px">Шаг 2a — загрузить и установить КриптоПро CSP</h3>
<p class="muted" style="margin:0 0 10px 0">Скачайте с <code>cryptopro.ru</code> архив <code>linux-amd64.tgz</code> или <code>linux-amd64.tar</code> (КриптоПро CSP 5.0 R4 для Linux) и загрузите его сюда. Bj-server сам распакует и установит нужные пакеты.</p>
<form method="post" action="/admin/setup/crypto/install" enctype="multipart/form-data" style="margin:0">
<input type="file" name="dist" accept=".tar,.tgz,.tar.gz,.rpm" required style="margin-right:8px">
<button type="submit" class="btn">Загрузить и установить</button>
</form>
</div>
{{else}}
<p style="color:var(--ok);margin-top:12px">✓ КриптоПро CSP установлен. Версия: <code>{{.CryptoProVersion}}</code></p>
{{end}}
<details style="margin-top:14px" {{if not .Done.Crypto}}open{{end}}>
<summary style="cursor:pointer;color:var(--accent)">Шаг 2b — указать провайдер и путь к PKCS#11 модулю</summary>
<summary style="cursor:pointer;color:var(--accent)">Параметры провайдера</summary>
<form method="post" action="/admin/setup/crypto" style="margin-top:12px;display:grid;gap:10px">
<div>
<label>Провайдер <span class="tooltip" title="cryptopro — КриптоПро CSP, rutoken — Рутокен ЭЦП 2.0 через драйверы CSP, stub — без криптографии (демо-режим без подписи)">?</span></label>
<label>Провайдер <span class="tooltip" title="validata — АПК «Валидата Клиент L»; stub — без криптографии (демо-режим без подписи)">?</span></label>
<select name="provider" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;width:100%">
<option value="stub" {{if eq .Settings.Crypto.Provider "stub"}}selected{{end}}>stub — без криптографии (демо)</option>
<option value="cryptopro" {{if eq .Settings.Crypto.Provider "cryptopro"}}selected{{end}}>КриптоПро CSP (серверная подпись, ключи на диске)</option>
<option value="rutoken" {{if eq .Settings.Crypto.Provider "rutoken"}}selected{{end}}>Рутокен ЭЦП 2.0 (подпись оператора)</option>
<option value="validata" {{if eq .Settings.Crypto.Provider "validata"}}selected{{end}}>Валидата Клиент L</option>
</select>
</div>
<div>
<label>Путь к модулю PKCS#11 <span class="tooltip" title="Файл libcppkcs11.so входит в пакет lsb-cprocsp-pkcs11-64. После установки КриптоПро CSP он находится в /opt/cprocsp/lib/amd64/">?</span></label>
<input type="text" name="jcp_path" value="{{if .Settings.Crypto.JCPPath}}{{.Settings.Crypto.JCPPath}}{{else}}/opt/cprocsp/lib/amd64/libcppkcs11.so{{end}}" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;width:100%">
<label>Путь к модулю PKCS#11 <span class="tooltip" title="После установки пакета zpki модуль находится в /opt/Validata/VDCSP/lib/amd64/libvdpkcs11.so">?</span></label>
<input type="text" name="module_path" value="{{if .Settings.Crypto.ModulePath}}{{.Settings.Crypto.ModulePath}}{{else}}/opt/Validata/VDCSP/lib/amd64/libvdpkcs11.so{{end}}" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;width:100%">
</div>
<button type="submit" class="btn">Сохранить</button>
</form>
</details>
{{if and .Done.Crypto (not .Settings.Crypto.LicenseKey)}}
<details open style="margin-top:14px">
<summary style="cursor:pointer;color:var(--accent)">Шаг 2c — активировать лицензию (если демо не подходит)</summary>
<form method="post" action="/admin/setup/crypto/activate" style="margin-top:12px">
<label>Серийный номер лицензии КриптоПро <span class="tooltip" title="Формат XXXXX-XXXXX-XXXXX-XXXXX-XXXXX. Выдаётся при покупке лицензии. Демо-лицензия на 3 месяца встроена в дистрибутив — её активировать не нужно.">?</span></label>
<input type="text" name="license" placeholder="XXXXX-XXXXX-XXXXX-XXXXX-XXXXX" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;width:100%;margin-top:6px">
<button type="submit" class="btn" style="margin-top:8px">Активировать</button>
</form>
</details>
{{end}}
<div style="margin-top:20px;display:flex;justify-content:space-between">
<a href="/admin/wizard?step=1" class="btn" style="background:var(--card);text-decoration:none">К шагу 1</a>
{{if .Done.Crypto}}<a href="/admin/wizard?step=3" class="btn" style="text-decoration:none">К шагу 3 →</a>{{else}}<a href="/admin/wizard?step=3&skip=crypto" class="btn" style="background:var(--card);text-decoration:none">Пропустить →</a>{{end}}
@@ -156,72 +125,17 @@
<li>В режиме <strong>ИШ НРД</strong>: подписывает <em>сам ИШ</em> — наш ключ настраивается <em>в ИШ</em>, не здесь. Bj-server нужен только для проверки квитанций НРД и (опц.) расшифровки 4BROKER01.</li>
<li>В режиме <strong>прямого ONYX без ИШ</strong>: bj-server подписывает сам — нужен наш ключ с приватной частью.</li>
</ul>
<strong>Что куда загружать (по режиму):</strong>
<table style="margin-top:6px;font-size:13px">
<thead><tr><th>Что</th><th>Зачем</th><th>Куда</th></tr></thead>
<tbody>
<tr><td>Корневой сертификат <strong>УЦ МБ</strong> (<a href="https://ca.moex.com/" target="_blank">ca.moex.com</a>)</td><td>проверка цепочки нашей подписи и подписей контрагентов</td><td><code>mroot</code></td></tr>
<tr><td>Корневой и подписной <strong>УЦ НРД</strong> (<a href="https://www.nsd.ru/workflow/system/cryptography/" target="_blank">nsd.ru/workflow/system/cryptography/</a>)</td><td>проверка квитанций от НРД</td><td><code>mroot</code> + <code>uRoot</code></td></tr>
<tr><td>Наш сертификат + ключ <em>(только если без ИШ)</em></td><td>подпись отправляемых пакетов + расшифровка 4BROKER01</td><td><code>uMy</code> — с приватным ключом</td></tr>
<tr><td>Сертификаты с Рутокена</td><td>сами появятся в таблице ниже после подключения USB</td><td>не грузить</td></tr>
</tbody>
</table>
<strong>Что куда загружать:</strong>
<ul style="margin:6px 0 6px 16px">
<li>Корневой сертификат <strong>УЦ МБ</strong> (<a href="https://ca.moex.com/" target="_blank">ca.moex.com</a>) — для проверки цепочки нашей подписи и подписей контрагентов.</li>
<li>Корневой и подписной <strong>УЦ НРД</strong> (<a href="https://www.nsd.ru/workflow/system/cryptography/" target="_blank">nsd.ru/workflow/system/cryptography/</a>) — для проверки квитанций от НРД.</li>
<li>Наш сертификат с приватным ключом <em>(только если без ИШ)</em> — для подписи пакетов и расшифровки 4BROKER01.</li>
</ul>
<p class="muted" style="margin-top:6px">Полный регламент PKI — в «Правилах ЭДО НРД» и «Руководстве по установке ИШ» (<a href="https://www.nsd.ru/ru/documents/workflow/" target="_blank">nsd.ru/ru/documents/workflow/</a>) — в наших PDF этого не описано.</p>
</div>
<h3 style="margin-top:18px">Импорт сертификата</h3>
<form method="post" action="/admin/setup/crypto/import-cert" enctype="multipart/form-data" style="margin-top:8px;display:grid;gap:8px;grid-template-columns:1fr 1fr 1fr auto;align-items:end">
<div>
<label class="muted" style="font-size:12px">Файл</label>
<input type="file" name="cert" accept=".cer,.crt,.pfx,.p12" required style="width:100%">
</div>
<div>
<label class="muted" style="font-size:12px">Хранилище</label>
<select name="store" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;width:100%">
<option value="uMy">uMy — мой (с приватным ключом)</option>
<option value="mroot">mroot — корневой УЦ</option>
<option value="uRoot">uRoot — промежуточный УЦ</option>
<option value="uCA">uCA — сертификаты УЦ НРД</option>
</select>
</div>
<div>
<label class="muted" style="font-size:12px">PIN (для .pfx)</label>
<input type="password" name="pin" placeholder="опц." style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;width:100%">
</div>
<button type="submit" class="btn">Импортировать</button>
</form>
<h3 style="margin-top:18px">Контейнеры на подключённых носителях (флешка/Рутокен)</h3>
{{if .FlashContainers}}
<p class="muted">Найдено {{len .FlashContainers}} контейнер(а) формата <code>name.000</code> на смонтированных USB-носителях. Нажмите «Скопировать в локальное хранилище» — папка будет перенесена в <code>/var/opt/cprocsp/keys/$USER/</code>, после чего контейнер виден как <code>\\.\HDIMAGE\name</code> и работает даже без вставленной флешки.</p>
<table style="margin-top:8px">
<thead><tr><th>Носитель</th><th>Имя контейнера</th><th>Файлы</th><th>Статус</th><th></th></tr></thead>
<tbody>
{{range .FlashContainers}}
<tr>
<td><code style="font-size:12px">{{.Mountpoint}}</code></td>
<td><strong>{{.Name}}</strong></td>
<td><span class="muted" style="font-size:11px">{{len .Files}} файлов</span></td>
<td>{{if .AlreadyImported}}<span style="color:var(--ok)">уже в HDIMAGE</span>{{else}}<span class="muted">только на флешке</span>{{end}}</td>
<td>
{{if not .AlreadyImported}}
<form method="post" action="/admin/setup/crypto/copy-container" style="margin:0">
<input type="hidden" name="src" value="{{.Path}}">
<button type="submit" class="btn" style="background:var(--ok);padding:6px 12px;font-size:12px">Скопировать в локальное хранилище</button>
</form>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
<p class="muted" style="margin-top:8px">После копирования: импортировать сертификат из контейнера командой <code>certmgr -inst -cont '\\.\HDIMAGE\{имя}' -store uMy</code> — это пропишет сертификат в видимое хранилище. (UI-кнопку для этого добавим следующим шагом.)</p>
{{else}}
<p class="muted">Подключённые USB-носители с контейнерами КриптоПро формата <code>name.000</code> не обнаружены. Поиск идёт в <code>/run/media/$USER/</code>, <code>/media/$USER/</code>, <code>/media/</code>, <code>/mnt/</code>. Вставьте флешку с контейнером и обновите страницу — контейнер появится в этой таблице автоматически.</p>
{{end}}
<h3 style="margin-top:18px">Авто-загрузка сертификатов УЦ НРД</h3>
<p class="muted">Самый простой способ — добавить прямые URL <code>.cer</code>-файлов УЦ НРД (с <a href="https://www.nsd.ru/workflow/system/cryptography/" target="_blank" rel="noopener">nsd.ru/workflow/system/cryptography/</a>) и включить авто-обновление. Раз в сутки система перепроверит и переустановит изменённые сертификаты.</p>
<p class="muted">Самый простой способ — добавить прямые URL <code>.cer</code>-файлов УЦ НРД (с <a href="https://www.nsd.ru/workflow/system/cryptography/" target="_blank" rel="noopener">nsd.ru/workflow/system/cryptography/</a>) и включить авто-обновление. Раз в сутки система перепроверит и сохранит изменённые сертификаты в <code>/var/lib/bj/ca-certs/</code>.</p>
<form method="post" action="/admin/setup/cacerts" style="margin-top:8px;display:grid;gap:10px">
<textarea name="urls" rows="3" placeholder="https://www.nsd.ru/path/to/root-ca.cer&#10;https://www.nsd.ru/path/to/sub-ca.cer" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;font-family:monospace;font-size:12px">{{range .Settings.CACerts.URLs}}{{.}}
{{end}}</textarea>
@@ -292,8 +206,8 @@
<input type="text" name="igw_url" id="nsd-url" value="{{.Settings.NSD.IGWBaseURL}}" placeholder="https://gost-t3.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;width:100%">
</div>
<div>
<label>Ключевой контейнер НРД <span class="tooltip" title="Имя контейнера КриптоПро с ключами ЭДО НРД (выдаются УЦ НРД). Формат: \\.\HDIMAGE\нрд-имя или нрд-имя.000">?</span></label>
<input type="text" name="key_container" value="{{.Settings.NSD.KeyContainer}}" placeholder="\\.\HDIMAGE\nrd-edo" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;width:100%">
<label>Ключевой контейнер НРД <span class="tooltip" title="Имя контейнера Валидаты с ключами ЭДО НРД (выдаются УЦ НРД)">?</span></label>
<input type="text" name="key_container" value="{{.Settings.NSD.KeyContainer}}" placeholder="nrd-edo" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;width:100%">
</div>
<button type="submit" class="btn" style="justify-self:start">Сохранить</button>
</form>
@@ -359,4 +273,5 @@
</div>
{{end}}
</div>
{{end}}
+267 -52
View File
@@ -1,72 +1,287 @@
{{define "layout"}}<!DOCTYPE html>
<html lang="ru">
<html lang="ru" data-theme="light">
<head>
<meta charset="utf-8">
<title>{{.Title}} · lk-gateway</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} · Bridge&Join</title>
<style>
:root { --bg:#0f1115; --card:#1a1d24; --border:#2a2f3a; --text:#e8eaed; --muted:#8b94a3; --accent:#5b9dff; --ok:#3fbf6c; --warn:#e8b13a; --err:#e85a5a; }
/* ===================== Дизайн-система ===================== */
/* Светлая тема (по умолчанию) */
: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);
}
* { box-sizing: border-box; }
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; }
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); }
</style>
</head>
<body>
<header>
<h1>lk-gateway</h1>
<nav>
<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>
<a href="/admin/" class="{{if eq .Active "home"}}active{{end}}">Дашборд</a>
<a href="/admin/wizard" class="{{if eq .Active "wizard"}}active{{end}}">Мастер настройки</a>
<a href="/admin/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/setup" class="{{if eq .Active "setup"}}active{{end}}">Настройка</a>
<a href="/admin/news" class="{{if eq .Active "news"}}active{{end}}">Новости</a>
<a href="/admin/claims" class="{{if eq .Active "claims"}}active{{end}}">Заявки</a>
<a href="/admin/status" class="{{if eq .Active "status"}}active{{end}}">Статус системы</a>
<a href="/admin/help" class="{{if eq .Active "help"}}active{{end}}">Инструкции</a>
<a href="/admin/status" class="{{if eq .Active "status"}}active{{end}}">Статус</a>
<a href="/admin/help" class="{{if eq .Active "help"}}active{{end}}">Справка</a>
</nav>
<span class="muted" style="margin-left:auto">{{.Now}}</span>
</header>
{{if .IsMockMode}}
<div style="background:rgba(232,177,58,0.15);border-bottom:2px solid var(--warn);padding:10px 24px;display:flex;align-items:center;gap:12px;font-size:13px">
<span style="font-size:18px">🟡</span>
<div>
<strong style="color:var(--warn)">РЕЖИМ ЭМУЛЯЦИИ</strong> — реального обмена с НРД нет.
<span class="muted" style="margin-left:6px">{{.MockReason}}</span>
<div class="topbar-right">
<span class="clock">{{.Now}}</span>
<button class="theme-toggle" id="theme-toggle" title="Светлая/тёмная тема" aria-label="Переключить тему">🌙</button>
</div>
<a href="/admin/wizard" style="margin-left:auto;font-size:13px">Настроить →</a>
</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>
{{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}}