42ae5ed12b
Пересборку справочника crestor запускает igate, а /var/lib/bj был 0750 bj:bj — igate не мог пройти в staging и выдавал ложное «нет local.pse». bj-server при старте даёт /var/lib/bj traverse (0751) — доезжает обновлением. Воркер bj-keymedia-install тоже даёт права и показывает реальную ошибку crestor вместо спама «dropped older CRL» (для новых ISO). Убрана ошибочная подсказка про ГСЧ. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7eREaYwUt1i4fNLZcGnjn
844 lines
34 KiB
Go
844 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)
|
|
// igate (пересборка справочника воркером bj-keymedia-install) должен проходить в staging
|
|
// под /var/lib/bj: даём каталогу traverse (bj — владелец). Без этого пересборка падала
|
|
// ложным «нет local.pse». Доезжает обновлением bj-server.
|
|
_ = os.Chmod(filepath.Dir(keymediaReqDir), 0o751)
|
|
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()
|
|
}
|