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
+525
View File
@@ -0,0 +1,525 @@
package lkgateway
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/cryptocli"
)
// urlQ — экранирование строки для query-параметра flash.
func urlQ(s string) string { return url.QueryEscape(s) }
// Пошаговый мастер установки ключа Валидаты на съёмный носитель (USB keymedia
// ИШ). Реализует то, что просил пользователь: загрузил архив + пароль →
// распаковка → запись на флешку → формирование справочника сертификатов →
// проверка Валидаты → «Готово» → можно слать тестовый документ.
//
// Привилегированные операции с флешкой (бэкап, remount rw, запись, перезапуск
// VDCrySvc) делает помощник /usr/local/sbin/bj-keymedia-install через узкий
// sudoers (bj-server работает под непривилегированным bj).
// keyWizardStep — один шаг мастера.
type keyWizardStep struct {
Title string
Status string // pending | active | ok | error
Detail string
}
// keyWizardState — состояние одного прогона мастера (в памяти, один активный).
type keyWizardState struct {
mu sync.Mutex
StagingID string // id распаковки в /var/lib/bj/media/iso/<id>
VDK string // имя файла ключа
Profile string // имя установленного профиля на носителе
Backup string // путь бэкапа
Steps []keyWizardStep // 1..5
Done bool // все шаги пройдены
Flash string
}
func newKeyWizardState() *keyWizardState {
return &keyWizardState{Steps: defaultKeySteps()}
}
// reset обнуляет поля прогона, НЕ трогая мьютекс (вызывать под Lock).
func (s *keyWizardState) reset() {
s.StagingID = ""
s.VDK = ""
s.Profile = ""
s.Backup = ""
s.Steps = defaultKeySteps()
s.Done = false
s.Flash = ""
}
func defaultKeySteps() []keyWizardStep {
return []keyWizardStep{
{Title: "Загрузка архива и распаковка", Status: "pending"},
{Title: "Запись ключа на выбранную флешку (с бэкапом)", Status: "pending"},
{Title: "Формирование справочника сертификатов (CRL)", Status: "pending"},
{Title: "Перезапуск и проверка ИШ", Status: "pending"},
{Title: "Готово — можно отправлять тестовый документ", Status: "pending"},
}
}
func (s *keyWizardState) set(i int, status, detail string) {
if i >= 0 && i < len(s.Steps) {
s.Steps[i].Status = status
if detail != "" {
s.Steps[i].Detail = detail
}
}
}
// flashDrive — съёмный носитель (USB), обнаруженный в системе.
type flashDrive struct {
Device string // /dev/sdb1
Size string // 1,9G
Label string
FSType string
Mountpoint string // пусто если не смонтирован
Model string // USB2FlashStorage
IsKeymedia bool // смонтирован как текущий ключевой носитель ИШ
}
// keyWizardData — данные шаблона admin_keywizard.html.
type keyWizardData struct {
page
State *keyWizardState
Drives []flashDrive
}
const keymediaMount = "/var/lib/igate/keymedia"
// listFlashDrives перечисляет съёмные (removable/hotplug) USB-носители с ФС —
// чтобы пользователь выбрал, на какую флешку писать ключ.
func listFlashDrives() []flashDrive {
out, err := exec.Command("lsblk", "-J", "-b", "-o",
"NAME,SIZE,LABEL,MOUNTPOINT,RM,HOTPLUG,TYPE,MODEL,FSTYPE,PATH").Output()
if err != nil {
return nil
}
var parsed struct {
Blockdevices []json.RawMessage `json:"blockdevices"`
}
if json.Unmarshal(out, &parsed) != nil {
return nil
}
var drives []flashDrive
var walk func(raw []byte, parentRemovable bool, parentModel string)
walk = func(raw []byte, parentRemovable bool, parentModel string) {
var d struct {
Name string `json:"name"`
Size int64 `json:"size"`
Label string `json:"label"`
Mountpoint string `json:"mountpoint"`
RM bool `json:"rm"`
Hotplug bool `json:"hotplug"`
Type string `json:"type"`
Model string `json:"model"`
FSType string `json:"fstype"`
Path string `json:"path"`
Children []json.RawMessage `json:"children"`
}
if json.Unmarshal(raw, &d) != nil {
return
}
removable := d.RM || d.Hotplug || parentRemovable
model := strings.TrimSpace(d.Model)
if model == "" {
model = parentModel
}
// Носитель с ФС — кандидат на запись.
if removable && d.Type == "part" && d.FSType != "" {
drives = append(drives, flashDrive{
Device: d.Path,
Size: humanSize(d.Size),
Label: d.Label,
FSType: d.FSType,
Mountpoint: d.Mountpoint,
Model: model,
IsKeymedia: d.Mountpoint == keymediaMount,
})
}
for _, c := range d.Children {
walk(c, removable, model)
}
}
for _, b := range parsed.Blockdevices {
walk(b, false, "")
}
return drives
}
func humanSize(b int64) string {
switch {
case b >= 1<<30:
return fmt.Sprintf("%.1f ГБ", float64(b)/(1<<30))
case b >= 1<<20:
return fmt.Sprintf("%.0f МБ", float64(b)/(1<<20))
default:
return fmt.Sprintf("%d Б", b)
}
}
// registerKeyWizard вешает маршруты мастера установки ключа.
func (h *setupHandlers) registerKeyWizard(mux *http.ServeMux) {
if h.keyWiz == nil {
h.keyWiz = newKeyWizardState()
}
mux.HandleFunc("/admin/setup/keywizard", h.renderKeyWizard)
mux.HandleFunc("/admin/setup/keywizard/upload", h.keyWizardUpload)
mux.HandleFunc("/admin/setup/keywizard/install", h.keyWizardInstall)
mux.HandleFunc("/admin/setup/keywizard/reset", h.keyWizardReset)
}
func (h *setupHandlers) renderKeyWizard(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
h.keyWiz.mu.Lock()
h.keyWiz.Flash = r.URL.Query().Get("flash")
st := h.keyWiz
h.keyWiz.mu.Unlock()
// Список флешек нужен на шаге выбора носителя (когда архив уже загружен).
var drives []flashDrive
if st.StagingID != "" && !st.Done {
drives = listFlashDrives()
}
render(w, h.tpl.a.keyWizard, keyWizardData{page: nowPage("Установка ключа", "setup"), State: st, Drives: drives})
}
func (h *setupHandlers) keyWizardReset(w http.ResponseWriter, r *http.Request) {
h.keyWiz.mu.Lock()
h.keyWiz.reset()
h.keyWiz.mu.Unlock()
http.Redirect(w, r, "/admin/setup/keywizard", http.StatusSeeOther)
}
// keyWizardUpload — шаг 1: приём .7z + пароль, распаковка, инспекция.
func (h *setupHandlers) keyWizardUpload(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
if err := r.ParseMultipartForm(500 << 20); err != nil {
h.keyWizFlash(w, r, "Ошибка чтения формы: "+err.Error())
return
}
file, header, err := r.FormFile("archive")
if err != nil {
h.keyWizFlash(w, r, "Выберите файл архива (.7z/.zip)")
return
}
defer file.Close()
lower := strings.ToLower(header.Filename)
if !(strings.HasSuffix(lower, ".7z") || strings.HasSuffix(lower, ".zip")) {
h.keyWizFlash(w, r, "Архив должен быть .7z или .zip")
return
}
password := r.FormValue("password")
isoDir := "/var/lib/bj/iso"
if err := os.MkdirAll(isoDir, 0o755); err != nil {
h.keyWizFlash(w, r, "Не удалось создать "+isoDir+": "+err.Error())
return
}
dst := filepath.Join(isoDir, time.Now().UTC().Format("20060102-150405-")+filepath.Base(header.Filename))
out, err := os.Create(dst)
if err != nil {
h.keyWizFlash(w, r, "Запись архива: "+err.Error())
return
}
if _, err := io.Copy(out, file); err != nil {
out.Close()
_ = os.Remove(dst)
h.keyWizFlash(w, r, "Запись архива: "+err.Error())
return
}
out.Close()
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
defer cancel()
m, err := ExtractISO(ctx, dst, password)
if err != nil {
h.keyWizFlash(w, r, "Распаковка не удалась (проверьте пароль): "+err.Error())
return
}
h.keyWiz.mu.Lock()
h.keyWiz.reset()
h.keyWiz.StagingID = m.ID
// Инспекция через помощник (читает staging, находит .vdk/gdbm/pse).
staging := filepath.Join("/var/lib/bj/media/iso", m.ID)
insp, ierr := runKeymediaHelper(ctx, "inspect", staging, "", "", "")
if ierr == nil && insp["ok"] == true {
if v, ok := insp["vdk"].(string); ok {
h.keyWiz.VDK = v
}
}
detail := fmt.Sprintf("Ключ: %s · справочник сертификатов: %s",
fallback(h.keyWiz.VDK, "—"), yesNo(insp["has_gdbm"]))
h.keyWiz.set(0, "ok", detail)
h.keyWiz.set(1, "active", "")
h.keyWiz.mu.Unlock()
http.Redirect(w, r, "/admin/setup/keywizard", http.StatusSeeOther)
}
// keyWizardInstall — шаги 2-5: запись на флешку, справочник, проверка, готово.
func (h *setupHandlers) keyWizardInstall(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
h.keyWiz.mu.Lock()
id := h.keyWiz.StagingID
h.keyWiz.mu.Unlock()
if id == "" {
h.keyWizFlash(w, r, "Сначала загрузите архив (шаг 1)")
return
}
staging := filepath.Join("/var/lib/bj/media/iso", id)
// Выбор флешки и имя профиля из формы.
profileName := strings.TrimSpace(r.FormValue("profile_name"))
targetDev := strings.TrimSpace(r.FormValue("target_device"))
targetMnt := ""
if targetDev != "" {
for _, d := range listFlashDrives() {
if d.Device == targetDev {
targetMnt = d.Mountpoint
break
}
}
}
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
defer cancel()
// Шаг 2-3: запись ключа на флешку + справочник + CRL (привилегированный воркер).
res, err := runKeymediaHelper(ctx, "install", staging, profileName, targetDev, targetMnt)
h.keyWiz.mu.Lock()
if err != nil || res["ok"] != true {
msg := errStr(err)
if e, ok := res["error"].(string); ok {
msg = e
}
h.keyWiz.set(1, "error", "Запись на флешку не удалась: "+msg)
h.keyWiz.mu.Unlock()
h.keyWizFlash(w, r, "Установка прервана: "+msg)
return
}
if p, ok := res["profile"].(string); ok {
h.keyWiz.Profile = p
}
if b, ok := res["backup"].(string); ok {
h.keyWiz.Backup = b
}
tgt, _ := res["target"].(string)
spr, _ := res["spr"].(string)
h.keyWiz.set(1, "ok", fmt.Sprintf("Профиль «%s» записан на %s. Бэкап: %s", h.keyWiz.Profile, fallback(tgt, "носитель"), h.keyWiz.Backup))
crl, _ := res["crl"].(string)
h.keyWiz.set(2, "ok", fmt.Sprintf("Справочник «%s» сформирован. CRL: %s", fallback(spr, "—"), crlRu(crl)))
h.keyWiz.set(3, "active", "")
h.keyWiz.mu.Unlock()
// Шаг 4: перезапуск и проверка ИШ. Перезапуск VDCrySvc мог сбросить
// активацию серверного профиля bj-crypto — восстанавливаем. Затем
// перезапускаем ИШ и проверяем engine/state: ИШ поднялся с новым ключом.
h.reactivateCryptoProfile(ctx)
ishOK, ishMsg := h.restartAndVerifyISH(ctx)
h.keyWiz.mu.Lock()
if !ishOK {
h.keyWiz.set(3, "error", "ИШ не подтвердил готовность: "+ishMsg)
h.keyWiz.mu.Unlock()
h.keyWizFlash(w, r, "Ключ записан, но ИШ не готов: "+ishMsg)
return
}
h.keyWiz.set(3, "ok", "ИШ перезапущен и работает: "+ishMsg)
h.keyWiz.set(4, "ok", "Теперь подпишите новым ключом — отправьте тестовый документ роботу НРД ниже")
h.keyWiz.Done = true
h.keyWiz.mu.Unlock()
http.Redirect(w, r, "/admin/setup/keywizard?flash="+urlQ("Готово! Ключ на флешке, справочник сформирован, ИШ перезапущен и работает. Финальная проверка — тестовым документом роботу."), http.StatusSeeOther)
}
// restartAndVerifyISH перезапускает Интеграционный шлюз и проверяет, что он
// поднялся (engine/state). Возвращает (ok, сообщение).
func (h *setupHandlers) restartAndVerifyISH(ctx context.Context) (bool, string) {
// Перезапуск igate через привилегированный воркер (bj не sudoer).
res, err := runKeymediaHelper(ctx, "restart-ish", "/var/lib/bj/media/iso", "", "", "")
if err != nil || res["ok"] != true {
// restart-ish может быть не поддержан — не критично, проверим состояние.
_ = err
}
// Проверяем состояние ИШ через nsd-адаптер (engine/state).
deadline := time.Now().Add(40 * time.Second)
for time.Now().Before(deadline) {
if st := h.ishEngineState(ctx); st != "" {
return true, "engine "+st
}
select {
case <-ctx.Done():
return false, "таймаут"
case <-time.After(3 * time.Second):
}
}
return false, "ИШ не ответил на /api/admin/engine/state за 40 сек"
}
// ishEngineState запрашивает состояние движка ИШ; пусто если недоступен.
func (h *setupHandlers) ishEngineState(ctx context.Context) string {
s := h.rc.Snapshot()
base := s.NSD.IGWBaseURL
if base == "" {
return ""
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(base, "/")+"/api/admin/engine/state", nil)
if err != nil {
return ""
}
cl := &http.Client{Timeout: 5 * time.Second}
resp, err := cl.Do(req)
if err != nil {
return ""
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return ""
}
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return strings.TrimSpace(string(b))
}
func crlRu(s string) string {
switch s {
case "updated":
return "обновлены из точек распространения"
case "failed":
return "не удалось обновить (проверьте сеть/CDP)"
case "skip":
return "пропущено"
default:
return s
}
}
// reactivateCryptoProfile повторно активирует текущий серверный профиль
// bj-crypto (после перезапуска VDCrySvc активация в сайдкаре сбрасывается).
// Best-effort: возвращает true при успехе.
func (h *setupHandlers) reactivateCryptoProfile(ctx context.Context) bool {
s := h.rc.Snapshot()
if s.Crypto.Profile == "" {
return false
}
cli := cryptocli.New(cryptocli.Config{
Provider: cryptocli.Provider(s.Crypto.Provider),
SocketPath: s.Crypto.SocketPath,
})
defer cli.Close()
res, err := cli.Activate(ctx, s.Crypto.Profile)
return err == nil && res.OK
}
// vdcrysvcActive проверяет, что демон Валидаты (vdmkdev) запущен.
func vdcrysvcActive() bool {
out, _ := exec.Command("systemctl", "is-active", "vdmkdev.service").Output()
return strings.TrimSpace(string(out)) == "active"
}
func boolRu(b bool, yes, no string) string {
if b {
return yes
}
return no
}
const keymediaReqDir = "/var/lib/bj/keymedia-requests"
// runKeymediaHelper передаёт запрос привилегированному воркеру через файловый
// обмен: bj-server (в песочнице) пишет <id>.req, root-сервис bj-keymedia
// (host namespace, триггерится bj-keymedia.path) выполняет операцию с флешкой
// и пишет <id>.res. bj-server опрашивает результат. Так привилегированная
// работа идёт вне mount-namespace песочницы, где доступно перемонтирование USB.
func runKeymediaHelper(ctx context.Context, action, staging, profile, targetDev, targetMnt string) (map[string]any, error) {
id := fmt.Sprintf("%s-%d", action, time.Now().UnixNano())
reqPath := filepath.Join(keymediaReqDir, id+".req")
resPath := filepath.Join(keymediaReqDir, id+".res")
defer os.Remove(resPath)
reqBody, _ := json.Marshal(map[string]string{
"action": action, "staging": staging, "profile": profile,
"target_dev": targetDev, "target_mnt": targetMnt,
})
// Пишем атомарно (tmp → rename), чтобы .path не подхватил полупустой файл.
tmp := reqPath + ".tmp"
if err := os.WriteFile(tmp, reqBody, 0o660); err != nil {
return nil, fmt.Errorf("запись запроса: %w", err)
}
if err := os.Rename(tmp, reqPath); err != nil {
return nil, fmt.Errorf("публикация запроса: %w", err)
}
// Опрос результата.
ticker := time.NewTicker(200 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return nil, fmt.Errorf("таймаут ожидания воркера установки ключа")
case <-ticker.C:
b, err := os.ReadFile(resPath)
if err != nil {
continue // ещё не готово
}
res := map[string]any{}
if jerr := json.Unmarshal(b, &res); jerr != nil {
return nil, fmt.Errorf("разбор ответа воркера: %v (%s)", jerr, strings.TrimSpace(string(b)))
}
if res["ok"] != true {
msg, _ := res["error"].(string)
return res, fmt.Errorf("воркер: %s", fallback(msg, "ошибка"))
}
return res, nil
}
}
}
func (h *setupHandlers) keyWizFlash(w http.ResponseWriter, r *http.Request, msg string) {
http.Redirect(w, r, "/admin/setup/keywizard?flash="+urlQ(msg), http.StatusSeeOther)
}
func fallback(s, def string) string {
if s == "" {
return def
}
return s
}
func yesNo(v any) string {
if b, ok := v.(bool); ok && b {
return "да"
}
return "нет"
}
func errStr(err error) string {
if err == nil {
return "неизвестная ошибка"
}
return err.Error()
}