feat(admin): копирование контейнеров КриптоПро с флешки в HDIMAGE + уточнение PKI по докам НРД

Сканирование смонтированных USB-носителей (/run/media/$USER, /media,
/mnt) на папки вида name.000 с *.key — это «контейнер КриптоПро на
флешке». В шаге 3 wizard'а и в /admin/setup появилась таблица
найденных контейнеров с кнопкой «Скопировать в локальное хранилище»
(копирует папку в /var/opt/cprocsp/keys/$USER/, после чего контейнер
виден как \\.\HDIMAGE\name и работает без вставленной флешки).

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

Кроме того — переписан help-блок в шаге 3 wizard'a на основании
«Инструккия M2M.pdf» (стр. 11, 16-19): явно расписано, что в режиме
ИШ подписывает сам ИШ (наш ключ в ИШ), а в режиме прямого ONYX —
bj-server. Таблица «что куда грузить»: УЦ МБ в mroot, УЦ НРД в
mroot+uRoot, наш сертификат в uMy только если без ИШ. Сертификаты
с Рутокена явно отмечены как «не грузить — сами появятся».
This commit is contained in:
fontvielle
2026-05-14 16:12:37 +03:00
parent 2142c4f586
commit f1e05c0ca3
4 changed files with 292 additions and 25 deletions
+190
View File
@@ -0,0 +1,190 @@
package lkgateway
import (
"context"
"fmt"
"io"
"net/http"
"os"
"os/user"
"path/filepath"
"strings"
"time"
)
// FlashContainer — найденный на смонтированной флешке контейнер КриптоПро.
// КриптоПро CSP под Linux ожидает контейнер в виде папки <name>.000 с
// файлами header.key/masks.key/name.key/primary.key/primary2.key.
type FlashContainer struct {
// Mountpoint — путь смонтированной флешки, например /run/media/user/USB.
Mountpoint string
// Path — полный путь до папки <name>.000.
Path string
// Name — имя контейнера (без суффикса .000).
Name string
// Files — список файлов в контейнере (для дисплея).
Files []string
// AlreadyImported — true, если папка <name>.000 уже есть в локальном
// хранилище /var/opt/cprocsp/keys/<user>/.
AlreadyImported bool
}
// scanFlashContainers ищет контейнеры формата <name>.000 на типичных
// точках монтирования USB-носителей в Linux: /run/media/<user>/* и
// /media/<user>/* и /media/*. Возвращает список найденных контейнеров.
func scanFlashContainers() []FlashContainer {
u, err := user.Current()
if err != nil {
return nil
}
roots := []string{
filepath.Join("/run/media", u.Username),
filepath.Join("/media", u.Username),
"/media",
"/mnt",
}
localKeysDir := filepath.Join("/var/opt/cprocsp/keys", u.Username)
var out []FlashContainer
for _, root := range roots {
entries, err := os.ReadDir(root)
if err != nil {
continue
}
for _, e := range entries {
if !e.IsDir() {
continue
}
mountpoint := filepath.Join(root, e.Name())
out = append(out, findContainersAt(mountpoint, localKeysDir)...)
}
}
return out
}
func findContainersAt(mountpoint, localKeysDir string) []FlashContainer {
var out []FlashContainer
// Ищем папки <name>.000 на верхнем уровне и на 1 уровне вглубь.
_ = filepath.Walk(mountpoint, func(p string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
// Глубже 2 уровней не лезем (на флешке могут быть личные папки).
rel, _ := filepath.Rel(mountpoint, p)
if strings.Count(rel, string(filepath.Separator)) > 2 {
return filepath.SkipDir
}
if !info.IsDir() || !strings.HasSuffix(strings.ToLower(p), ".000") {
return nil
}
// Проверяем, что внутри лежат файлы вида *.key.
entries, _ := os.ReadDir(p)
var files []string
hasKey := false
for _, ent := range entries {
files = append(files, ent.Name())
if strings.HasSuffix(strings.ToLower(ent.Name()), ".key") {
hasKey = true
}
}
if !hasKey {
return nil
}
name := strings.TrimSuffix(filepath.Base(p), ".000")
fc := FlashContainer{
Mountpoint: mountpoint,
Path: p,
Name: name,
Files: files,
}
// Проверка: уже скопирован в локальное хранилище?
if _, err := os.Stat(filepath.Join(localKeysDir, name+".000")); err == nil {
fc.AlreadyImported = true
}
out = append(out, fc)
return filepath.SkipDir
})
return out
}
// copyContainerToLocal копирует папку <name>.000 с флешки в локальное
// хранилище КриптоПро /var/opt/cprocsp/keys/<user>/<name>.000. После
// этого контейнер виден как \\.\HDIMAGE\<name> и работает даже без
// вставленной флешки.
func copyContainerToLocal(srcDir string) (string, error) {
u, err := user.Current()
if err != nil {
return "", err
}
localKeysDir := filepath.Join("/var/opt/cprocsp/keys", u.Username)
if err := os.MkdirAll(localKeysDir, 0o700); err != nil {
return "", fmt.Errorf("создать %s: %w", localKeysDir, err)
}
base := filepath.Base(srcDir)
dstDir := filepath.Join(localKeysDir, base)
if _, err := os.Stat(dstDir); err == nil {
return "", fmt.Errorf("контейнер %s уже существует в локальном хранилище", dstDir)
}
if err := os.MkdirAll(dstDir, 0o700); err != nil {
return "", fmt.Errorf("создать %s: %w", dstDir, err)
}
entries, err := os.ReadDir(srcDir)
if err != nil {
return "", err
}
for _, e := range entries {
if e.IsDir() {
continue
}
src, err := os.Open(filepath.Join(srcDir, e.Name()))
if err != nil {
return "", err
}
dst, err := os.OpenFile(filepath.Join(dstDir, e.Name()),
os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
if err != nil {
src.Close()
return "", err
}
if _, err := io.Copy(dst, src); err != nil {
src.Close()
dst.Close()
return "", err
}
src.Close()
dst.Close()
}
return dstDir, nil
}
// copyContainer — POST /admin/setup/crypto/copy-container.
// Параметр src — путь до папки <name>.000 на флешке.
func (h *setupHandlers) copyContainer(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
src := strings.TrimSpace(r.FormValue("src"))
if src == "" {
setupFlash(w, r, "Копирование контейнера: не указан путь")
return
}
// Минимальная защита: ожидаем .000 в конце пути.
if !strings.HasSuffix(strings.ToLower(src), ".000") {
setupFlash(w, r, "Копирование контейнера: путь должен заканчиваться на .000")
return
}
if _, err := os.Stat(src); err != nil {
setupFlash(w, r, "Копирование контейнера: исходная папка недоступна: "+err.Error())
return
}
dst, err := copyContainerToLocal(src)
if err != nil {
setupFlash(w, r, "Копирование контейнера: "+err.Error())
return
}
// Дадим CSP несколько мс «заметить» новый контейнер (не критично).
_, cancel := context.WithTimeout(r.Context(), 100*time.Millisecond)
cancel()
setupFlash(w, r, "Контейнер скопирован в "+dst+". Теперь он виден как \\\\.\\HDIMAGE\\"+strings.TrimSuffix(filepath.Base(dst), ".000")+" и работает без вставленной флешки. Импортируйте сертификат: certmgr -inst -cont '\\\\.\\HDIMAGE\\"+strings.TrimSuffix(filepath.Base(dst), ".000")+"' -store uMy.")
}
+25 -18
View File
@@ -74,6 +74,9 @@ func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service
mux.HandleFunc("/admin/setup/cacerts", h.saveCACerts) mux.HandleFunc("/admin/setup/cacerts", h.saveCACerts)
mux.HandleFunc("/admin/setup/cacerts/fetch", h.fetchCACertsNow) mux.HandleFunc("/admin/setup/cacerts/fetch", h.fetchCACertsNow)
// Копирование контейнера КриптоПро с флешки в локальное хранилище.
mux.HandleFunc("/admin/setup/crypto/copy-container", h.copyContainer)
// Пошаговый мастер настройки для нетехнических пользователей. // Пошаговый мастер настройки для нетехнических пользователей.
mux.HandleFunc("/admin/wizard", h.renderWizard) mux.HandleFunc("/admin/wizard", h.renderWizard)
} }
@@ -84,6 +87,7 @@ type WizardData struct {
Step int Step int
Settings Settings Settings Settings
Certs []cryptocli.Certificate Certs []cryptocli.Certificate
FlashContainers []FlashContainer
Flash string Flash string
CryptoProInstalled bool CryptoProInstalled bool
CryptoProVersion string CryptoProVersion string
@@ -106,10 +110,11 @@ func (h *setupHandlers) renderWizard(w http.ResponseWriter, r *http.Request) {
} }
s := h.rc.Snapshot() s := h.rc.Snapshot()
d := WizardData{ d := WizardData{
page: nowPage("Мастер настройки", "wizard"), page: nowPage("Мастер настройки", "wizard"),
Settings: s, Settings: s,
Certs: h.listCertsForUI(), Certs: h.listCertsForUI(),
Flash: r.URL.Query().Get("flash"), FlashContainers: scanFlashContainers(),
Flash: r.URL.Query().Get("flash"),
} }
d.Done.Postgres = s.Postgres.DSN != "" d.Done.Postgres = s.Postgres.DSN != ""
d.Done.Crypto = s.Crypto.Provider != "" && s.Crypto.Provider != "stub" && s.Crypto.JCPPath != "" d.Done.Crypto = s.Crypto.Provider != "" && s.Crypto.Provider != "stub" && s.Crypto.JCPPath != ""
@@ -401,13 +406,14 @@ func (h *setupHandlers) checkCrypto(w http.ResponseWriter, r *http.Request) {
// SetupData — данные для шаблона admin_setup.html. // SetupData — данные для шаблона admin_setup.html.
type SetupData struct { type SetupData struct {
page page
Settings Settings Settings Settings
Readiness []Readiness Readiness []Readiness
ReadyCount int ReadyCount int
TotalCount int TotalCount int
Certificates []cryptocli.Certificate Certificates []cryptocli.Certificate
Flash string FlashContainers []FlashContainer
Error string Flash string
Error string
} }
func (h *setupHandlers) renderSetup(w http.ResponseWriter, _ *http.Request, flash string) { func (h *setupHandlers) renderSetup(w http.ResponseWriter, _ *http.Request, flash string) {
@@ -420,13 +426,14 @@ func (h *setupHandlers) renderSetup(w http.ResponseWriter, _ *http.Request, flas
} }
} }
data := SetupData{ data := SetupData{
page: nowPage("Настройка", "setup"), page: nowPage("Настройка", "setup"),
Settings: s, Settings: s,
Readiness: r, Readiness: r,
ReadyCount: ready, ReadyCount: ready,
TotalCount: len(r), TotalCount: len(r),
Certificates: h.listCertsForUI(), Certificates: h.listCertsForUI(),
Flash: flash, FlashContainers: scanFlashContainers(),
Flash: flash,
} }
if errVal := errMsgFromQuery(_q(w)); errVal != "" { if errVal := errMsgFromQuery(_q(w)); errVal != "" {
data.Error = errVal data.Error = errVal
@@ -147,6 +147,37 @@
</details> </details>
</div> </div>
<!-- Контейнеры КриптоПро на флешке -->
<div class="card">
<h2><span class="dot {{if .FlashContainers}}ok{{else}}warn{{end}}"></span>Контейнеры на USB-носителях (флешка/Рутокен)</h2>
{{if .FlashContainers}}
<p class="muted">Найдено {{len .FlashContainers}} контейнер(а) формата <code>name.000</code> на смонтированных USB-носителях. Кнопка ниже копирует папку в <code>/var/opt/cprocsp/keys/$USER/</code> — после этого контейнер виден как <code>\\.\HDIMAGE\name</code> и работает без вставленной флешки.</p>
<table style="margin-top:8px">
<thead><tr><th>Носитель</th><th>Имя контейнера</th><th>Файлы</th><th>Статус</th><th></th></tr></thead>
<tbody>
{{range .FlashContainers}}
<tr>
<td><code style="font-size:12px">{{.Mountpoint}}</code></td>
<td><strong>{{.Name}}</strong></td>
<td><span class="muted" style="font-size:11px">{{len .Files}} файлов</span></td>
<td>{{if .AlreadyImported}}<span style="color:var(--ok)">уже в HDIMAGE</span>{{else}}<span class="muted">только на флешке</span>{{end}}</td>
<td>
{{if not .AlreadyImported}}
<form method="post" action="/admin/setup/crypto/copy-container" style="margin:0">
<input type="hidden" name="src" value="{{.Path}}">
<button type="submit" class="btn" style="background:var(--ok);color:#0a0f1a;padding:6px 12px;font-size:12px;font-weight:600">Скопировать в локальное хранилище</button>
</form>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="muted">Подключённые USB-носители с контейнерами КриптоПро (папки <code>name.000</code> с *.key) не обнаружены. Поиск идёт в <code>/run/media/$USER/</code>, <code>/media/$USER/</code>, <code>/media/</code>, <code>/mnt/</code>. Вставьте флешку и обновите страницу.</p>
{{end}}
</div>
<!-- Авто-загрузка сертификатов УЦ НРД --> <!-- Авто-загрузка сертификатов УЦ НРД -->
<div class="card"> <div class="card">
<h2><span class="dot {{if .Settings.CACerts.URLs}}ok{{else}}warn{{end}}"></span>Сертификаты УЦ (НРД и др.) — авто-загрузка</h2> <h2><span class="dot {{if .Settings.CACerts.URLs}}ok{{else}}warn{{end}}"></span>Сертификаты УЦ (НРД и др.) — авто-загрузка</h2>
@@ -150,13 +150,23 @@
<p>Импортируйте сертификаты вашей организации и сертификаты УЦ НРД (для проверки квитанций).</p> <p>Импортируйте сертификаты вашей организации и сертификаты УЦ НРД (для проверки квитанций).</p>
<div class="help-block"> <div class="help-block">
<strong>Какие сертификаты нужны?</strong> <strong>Что говорят документы НРД (<code>DOC/Инструккия M2M.pdf</code>, стр. 11, 16-19):</strong>
<ol style="margin:6px 0 0 16px"> <ul style="margin:6px 0 6px 16px">
<li>Ваш сертификат организации с приватным ключом (<code>.pfx</code> / <code>.p12</code> на диске или контейнер на Рутокене) — для подписи отправляемых пакетов.</li> <li>Наши пакеты должны быть подписаны сертификатом <strong>УЦ МБ</strong> (Удостоверяющий центр Московской Биржи).</li>
<li>Корневой сертификат вашего УЦ (<code>.cer</code>)в хранилище <code>mroot</code>.</li> <li>В режиме <strong>ИШ НРД</strong>: подписывает <em>сам ИШ</em>наш ключ настраивается <em>в ИШ</em>, не здесь. Bj-server нужен только для проверки квитанций НРД и (опц.) расшифровки 4BROKER01.</li>
<li>Корневой и подписной сертификаты УЦ НРД — для проверки квитанций. Скачиваются с <a href="https://www.nsd.ru/workflow/system/cryptography/" target="_blank">nsd.ru/workflow/system/cryptography/</a></li> <li>В режиме <strong>прямого ONYX без ИШ</strong>: bj-server подписывает сам — нужен наш ключ с приватной частью.</li>
</ol> </ul>
<strong>Где взять?</strong> Сертификат вашей организации — у вашего УЦ (Контур, СКБ Контур, ИнфоТеКС, КриптоПро УЦ, …). Сертификаты УЦ НРД — на сайте НРД (см. выше). <strong>Что куда загружать (по режиму):</strong>
<table style="margin-top:6px;font-size:13px">
<thead><tr><th>Что</th><th>Зачем</th><th>Куда</th></tr></thead>
<tbody>
<tr><td>Корневой сертификат <strong>УЦ МБ</strong> (<a href="https://ca.moex.com/" target="_blank">ca.moex.com</a>)</td><td>проверка цепочки нашей подписи и подписей контрагентов</td><td><code>mroot</code></td></tr>
<tr><td>Корневой и подписной <strong>УЦ НРД</strong> (<a href="https://www.nsd.ru/workflow/system/cryptography/" target="_blank">nsd.ru/workflow/system/cryptography/</a>)</td><td>проверка квитанций от НРД</td><td><code>mroot</code> + <code>uRoot</code></td></tr>
<tr><td>Наш сертификат + ключ <em>(только если без ИШ)</em></td><td>подпись отправляемых пакетов + расшифровка 4BROKER01</td><td><code>uMy</code> — с приватным ключом</td></tr>
<tr><td>Сертификаты с Рутокена</td><td>сами появятся в таблице ниже после подключения USB</td><td>не грузить</td></tr>
</tbody>
</table>
<p class="muted" style="margin-top:6px">Полный регламент PKI — в «Правилах ЭДО НРД» и «Руководстве по установке ИШ» (<a href="https://www.nsd.ru/ru/documents/workflow/" target="_blank">nsd.ru/ru/documents/workflow/</a>) — в наших PDF этого не описано.</p>
</div> </div>
<h3 style="margin-top:18px">Импорт сертификата</h3> <h3 style="margin-top:18px">Импорт сертификата</h3>
@@ -181,6 +191,35 @@
<button type="submit" class="btn">Импортировать</button> <button type="submit" class="btn">Импортировать</button>
</form> </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> <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>) и включить авто-обновление. Раз в сутки система перепроверит и переустановит изменённые сертификаты.</p>
<form method="post" action="/admin/setup/cacerts" style="margin-top:8px;display:grid;gap:10px"> <form method="post" action="/admin/setup/cacerts" style="margin-top:8px;display:grid;gap:10px">