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/ 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 (в песочнице) пишет .req, root-сервис bj-keymedia // (host namespace, триггерится bj-keymedia.path) выполняет операцию с флешкой // и пишет .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() }