Files
Bridge-and-Join-s/internal/lkgateway/keywizard.go
T

840 lines
34 KiB
Go

package lkgateway
import (
"bytes"
"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
// ключ взят с флешки (а не из архива): на ту же флешку и под тем же именем профиля
MediaDevice, MediaProfile 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 = ""
s.MediaDevice, s.MediaProfile = "", ""
}
func defaultKeySteps() []keyWizardStep {
last := "Готово — можно проверить подключения"
if devMode(globalRC) {
last = "Готово — можно отправлять тестовый документ"
}
return []keyWizardStep{
{Title: "Загрузка архива и распаковка", Status: "pending"},
{Title: "Запись ключа на новую флешку (копия прежнего содержимого — на сервере)", Status: "pending"},
{Title: "Формирование справочника сертификатов (CRL)", Status: "pending"},
{Title: "Перезапуск и проверка ИШ", Status: "pending"},
{Title: last, 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
SuggestContour string // контур по сертификату в архиве: test3 (издатель «Тестовый УЦ») | prod | "" (не понятно)
SuggestIssuer string
WantContour string // контур, для которого открыли установку (кнопка «Установить ключ» у канала)
InWizard bool // блок — на шаге мастера: формы возвращают туда же
}
// keyWizView — данные блока установки ключа (want — контур, у которого нажали «Установить ключ»).
func (h *setupHandlers) keyWizView(want string, inWizard bool) *keyWizardData {
if h.keyWiz == nil { // обработчики без мастера ключа (тесты)
h.keyWiz = newKeyWizardState()
}
h.keyWiz.mu.Lock()
st := h.keyWiz
h.keyWiz.mu.Unlock()
d := &keyWizardData{State: st, InWizard: inWizard}
if want == "test3" || want == "prod" {
d.WantContour = want
}
if !st.Done { // флешки: в начале — откуда взять ключ, после загрузки — куда записать
d.Drives = listFlashDrives()
}
if st.StagingID != "" && !st.Done {
d.SuggestContour, d.SuggestIssuer = keyArchiveContour(filepath.Join("/var/lib/bj/media/iso", st.StagingID))
}
return d
}
// keyWizBack — куда вернуться после действия: на шаг мастера (ret=wizard) или на отдельную страницу.
func keyWizBack(r *http.Request) string {
u := "/admin/setup/keywizard?"
if r.FormValue("ret") == "wizard" {
u = "/admin/wizard?step=8&"
}
if c := r.FormValue("want"); c == "test3" || c == "prod" {
u += "contour=" + c + "&"
}
return u
}
// keyArchiveContour — контур ключа по сертификатам в распакованном архиве (не самоподписанным — это УЦ):
// издатель «Тестовый УЦ» — TEST3, иначе боевой. ГОСТ-сертификаты разбираются по TBS (parseNSDCert).
func keyArchiveContour(dir string) (contour, issuer string) {
_ = filepath.Walk(dir, func(p string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() || info.Size() > 64<<10 || contour != "" {
return nil
}
switch strings.ToLower(filepath.Ext(p)) {
case ".cer", ".crt", ".pem":
default:
return nil
}
raw, err := os.ReadFile(p)
if err != nil {
return nil
}
ci, _, err := parseNSDCert(raw)
if err != nil || ci.SelfSigned || ci.IssuerCN == "" {
return nil
}
issuer = ci.IssuerCN
if strings.Contains(strings.ToLower(ci.IssuerCN), "тестов") {
contour = "test3"
} else {
contour = "prod"
}
return nil
})
return contour, issuer
}
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.Type == "disk") && 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)
mux.HandleFunc("/admin/setup/keywizard/from-media", h.keyWizardFromMedia)
}
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")
h.keyWiz.mu.Unlock()
d := h.keyWizView(r.URL.Query().Get("contour"), false)
d.page = nowPage("Установка ключа", "setup")
render(w, h.tpl.a.keyWizard, *d)
}
// keyWizardUseMedia — «использовать ключ с подготовленной флешки как есть»: на флешку ничего не пишется.
// Справочник для ИШ собирается из её копии на диске, флешка подключается к ИШ постоянно и только на чтение.
func (h *setupHandlers) keyWizardUseMedia(w http.ResponseWriter, r *http.Request, staging string) {
h.keyWiz.mu.Lock()
dev, prof := h.keyWiz.MediaDevice, h.keyWiz.MediaProfile
h.keyWiz.mu.Unlock()
found := false
for _, d := range listFlashDrives() {
found = found || d.Device == dev
}
if dev == "" || !found {
h.keyWizFlash(w, r, "Флешка с ключом не найдена — вставьте её и нажмите «Использовать ключ с флешки» ещё раз.")
return
}
contour := r.FormValue("contour")
if contour != "prod" {
contour = "test3"
}
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
defer cancel()
res, err := runKeymediaOp(ctx, "use-media", map[string]string{"staging": staging, "profile": prof, "target_dev": dev, "channel": contourChannel(contour)})
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
}
rebuild, _ := res["rebuild"].(string)
igate, _ := res["igate"].(string)
h.keyWiz.set(1, "ok", "Флешка "+dev+" подключена к ИШ только для чтения — на неё ничего не записано")
if bad := keyInstallProblem(res); bad != "" {
h.keyWiz.set(2, "error", bad)
h.keyWiz.mu.Unlock()
h.keyWizFlash(w, r, "Ключ с флешки не подключён: "+bad)
return
}
h.keyWiz.set(2, "ok", fmt.Sprintf("Справочник для ИШ (%s); профиль «%s» %s ключ подписывает", rebuildRu(rebuild), h.keyWiz.Profile, igateRu(igate)))
h.keyWiz.set(3, "active", "")
h.keyWiz.mu.Unlock()
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()
_ = os.RemoveAll(staging)
refreshCertStore(h.rc)
http.Redirect(w, r, keyWizBack(r)+"flash="+urlQ("Готово! Ключ с флешки подключён к ИШ как есть — флешка не менялась."), http.StatusSeeOther)
}
// keyWizardFromMedia — POST /admin/setup/keywizard/from-media: ключ уже на флешке (подготовленной bj раньше
// или с ключом и справочником от НРД). Флешка только читается: ключ и справочник копируются во временный
// каталог — дальше обычная установка на эту же флешку под тем же именем профиля (с резервной копией флешки).
func (h *setupHandlers) keyWizardFromMedia(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
dev := strings.TrimSpace(r.FormValue("source_device"))
var drive *flashDrive
for _, d := range listFlashDrives() {
if d.Device == dev {
d := d
drive = &d
}
}
if drive == nil { // только обнаруженные съёмные: иначе root-воркер смонтировал бы что угодно из формы
h.keyWizFlash(w, r, "Флешка не найдена среди съёмных носителей — вставьте её и обновите страницу.")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
defer cancel()
res, err := runKeymediaOp(ctx, "stage-media", map[string]string{"target_dev": drive.Device, "target_mnt": drive.Mountpoint})
if err == nil && res["ok"] != true {
err = fmt.Errorf("%v", res["error"])
}
if err != nil {
h.keyWizFlash(w, r, "Ключ с флешки не взят: "+err.Error())
return
}
id, _ := res["staging"].(string)
if !strings.HasPrefix(id, "media-") {
h.keyWizFlash(w, r, "Ключ с флешки не взят: установщик не вернул каталог")
return
}
vdk, _ := res["vdk"].(string)
prof, _ := res["profile"].(string)
spr, _ := res["spr"].(string)
h.keyWiz.mu.Lock()
h.keyWiz.reset()
h.keyWiz.StagingID, h.keyWiz.VDK = id, vdk
h.keyWiz.MediaDevice, h.keyWiz.MediaProfile = drive.Device, prof
// флешка уже подготовлена: используется как есть — этапы называются по-другому, записи на флешку нет
h.keyWiz.Steps[0].Title = "Поиск ключа на флешке"
h.keyWiz.Steps[1].Title = "Подключение флешки к шлюзу как есть — на неё ничего не записывается"
h.keyWiz.Steps[2].Title = "Справочник сертификатов для ИШ — на диске сервера"
h.keyWiz.set(0, "ok", fmt.Sprintf("Ключ найден на флешке %s: %s, справочник %s%s", fallback(drive.Model, drive.Device), vdk, spr, prefixed(", профиль ", prof)))
h.keyWiz.set(1, "active", "")
h.keyWiz.mu.Unlock()
http.Redirect(w, r, strings.TrimRight(keyWizBack(r), "?&"), http.StatusSeeOther)
}
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, strings.TrimRight(keyWizBack(r), "?&"), 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")
// Не копим staging: чистим прошлые распаковки/архивы перед новой загрузкой.
sweepStaging()
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, strings.TrimRight(keyWizBack(r), "?&"), 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)
if r.FormValue("as_is") == "1" {
h.keyWizardUseMedia(w, r, staging)
return
}
// Выбор флешки и имя профиля из формы.
profileName := strings.TrimSpace(r.FormValue("profile_name"))
targetDev := strings.TrimSpace(r.FormValue("target_device"))
targetMnt := ""
if targetDev != "" {
found := false
for _, d := range listFlashDrives() {
if d.Device == targetDev {
targetMnt, found = d.Mountpoint, true
break
}
}
// Устройство должно быть из списка обнаруженных съёмных: иначе root-воркер
// смонтировал бы произвольный блочный девайс/путь, переданный из формы.
if !found {
h.keyWizFlash(w, r, "Выбранное устройство не найдено среди съёмных носителей — обновите список и повторите.")
return
}
}
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
defer cancel()
// Шаг 2-3: запись ключа на флешку + справочник + CRL (привилегированный воркер).
// контур ключа: профиль Валидаты привязывается только к каналу ИШ этого контура (у TEST3 и боевого — свои ключи)
contour := r.FormValue("contour")
if contour != "prod" {
contour = "test3"
}
res, err := runKeymediaOp(ctx, "install", map[string]string{"staging": staging, "profile": profileName,
"target_dev": targetDev, "target_mnt": targetMnt, "channel": contourChannel(contour)})
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))
rebuild, _ := res["rebuild"].(string)
igate, _ := res["igate"].(string)
igp, _ := res["igate_profile_dir"].(string)
if bad := keyInstallProblem(res); bad != "" {
h.keyWiz.set(2, "error", bad)
h.keyWiz.mu.Unlock()
h.keyWizFlash(w, r, "Ключ записан на флешку, но не подключён: "+bad)
return
}
h.keyWiz.set(2, "ok", fmt.Sprintf("Справочник «%s» пересобран (%s); профиль ИШ %s %s",
fallback(spr, "—"), rebuildRu(rebuild), igateRu(igate), igp))
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)
dev := devMode(h.rc)
if dev {
h.keyWiz.set(4, "ok", "Теперь подпишите новым ключом — отправьте тестовый документ роботу НРД ниже")
} else {
h.keyWiz.set(4, "ok", "Ключ установлен — проверьте подключения на странице «Статус»")
}
h.keyWiz.Done = true
h.keyWiz.mu.Unlock()
// Ключ уже на флешке и в ИШ — распаковка staging больше не нужна.
_ = os.RemoveAll(staging)
refreshCertStore(h.rc) // шаг «Сертификаты» и сроки — сразу по новому ключу, а не через 6 часов
final := "Готово! Ключ на флешке, справочник сформирован, ИШ перезапущен и работает."
if dev {
final += " Финальная проверка — тестовым документом роботу."
}
http.Redirect(w, r, keyWizBack(r)+"flash="+urlQ(final), 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
}
}
// rebuildRu — статус пересборки справочника сертификатов на флешке.
// keyInstallProblem — что не так в итоге установщика ключа (пусто — ключ подключён и подписывает).
// Раньше мастер ставил галочки по порядку, даже если справочник не собрался и профиль ИШ не создан.
func keyInstallProblem(res map[string]any) string {
rebuild, _ := res["rebuild"].(string)
igate, _ := res["igate"].(string)
check, _ := res["keycheck"].(string)
switch {
case rebuild != "ok" && rebuild != "as-is":
return "справочник сертификатов не собран — " + rebuildRu(rebuild)
case igate != "connected" && igate != "pending-channel":
return "профиль ИШ не сформирован — " + strings.TrimSuffix(igateRu(igate), ":")
case strings.HasPrefix(check, "fail"):
diag, _ := res["keydiag"].(string)
return "ключ не подписывает: " + strings.TrimPrefix(check, "fail: ") + prefixed(". Что видит шлюз — ", diag) + " Возможно, справочник сертификатов не от этого ключа — ключ (.vdk) и справочник (local.pse/local.gdbm) должны быть из одного архива НРД."
}
return ""
}
func rebuildRu(s string) string {
switch {
case s == "ok":
return "совместимый формат, сертификаты извлечены из архива"
case s == "as-is":
return "справочник с флешки уже совместимый — взят как есть"
case s == "skip":
return "пропущено"
case strings.HasPrefix(s, "fail"):
return "ошибка пересборки (" + strings.TrimPrefix(s, "fail: ") + ")"
default:
return s
}
}
// igateRu — статус формирования профиля для ИШ.
func igateRu(s string) string {
switch s {
case "connected":
return "сформирован, зарегистрирован и подключён к ИШ (cryptography.profile):"
case "pending-channel":
return "сформирован и зарегистрирован; канала этого контура в ИШ ещё нет — ключ подключится, когда вы настроите канал на шаге «Шлюз НРД»:"
case "ok":
return "сформирован и зарегистрирован (но не выбран в ИШ):"
case "skip":
return "не формировался"
case "ini-fail":
return "сформирован, но регистрация в zpki.ini не удалась:"
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
}
// sweepStaging удаляет прошлые распаковки (/var/lib/bj/media/iso/*) и архивы
// (/var/lib/bj/iso/*.7z|zip), чтобы staging не копился. Вызывается перед новой
// загрузкой — текущая распаковка создаётся уже после очистки и не страдает.
func sweepStaging() {
if entries, err := os.ReadDir("/var/lib/bj/media/iso"); err == nil {
for _, e := range entries {
_ = os.RemoveAll(filepath.Join("/var/lib/bj/media/iso", e.Name()))
}
}
for _, pat := range []string{"/var/lib/bj/iso/*.7z", "/var/lib/bj/iso/*.zip"} {
if files, err := filepath.Glob(pat); err == nil {
for _, f := range files {
_ = os.Remove(f)
}
}
}
}
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) {
return runKeymediaOp(ctx, action, map[string]string{"staging": staging, "profile": profile, "target_dev": targetDev, "target_mnt": targetMnt})
}
// runKeymediaOp — общий вход к root-воркеру: action + произвольные поля
// запроса (staging, profile, target_dev, target_mnt, extra). Используется и
// мастером ключа, и автообновлением сертификатов НРД (nsdcerts.go).
func runKeymediaOp(ctx context.Context, action string, fields map[string]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)
req := map[string]string{"action": action}
for k, v := range fields {
req[k] = v
}
reqBody, _ := json.Marshal(req)
// Пишем атомарно (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{}
// systemd/скрипты могут напечатать предупреждение перед JSON — берём с первой «{».
if i := bytes.IndexByte(b, '{'); i > 0 {
b = b[i:]
}
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, keyWizBack(r)+"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()
}