package lkgateway // Доверенные сертификаты НРД в справочнике сертификатов активного профиля ИШ. // // НРД периодически меняет ключ крипто-сервера (например, в ночь на 10.09.2026 — // сертификат Шляппо А.А. от 21.08.2026). Участник обязан ДО смены добавить // новый сертификат в локальный справочник, а старый удалить в разрешённое окно. // Вручную это делают через GUI «Справочник сертификатов»; у нас ИШ работает // без GUI, поэтому bj делает это сам: // // - комплект сертификатов + manifest.json ВШИТ в бинарь (go:embed nsd-certs/), // т.е. приезжает всем клиентам вместе с обновлением bj; // - дополнительно читаются ручные .cer из /var/lib/bj/.bj/nsd-certs/; // - root-воркер (bj-keymedia-install certs-list / certs-apply) сверяет и // пересобирает справочник профиля: бэкап → пересборка → проверка подписи → // откат при ошибке → перезапуск ИШ; // - сертификат добавляется ТОЛЬКО если его УЦ уже есть в справочнике — // боевой сертификат никогда не попадёт в тестовый профиль и наоборот; // - старый сертификат удаляется не раньше remove_replaced_after из манифеста. import ( "bytes" "context" "crypto/x509/pkix" "embed" "encoding/asn1" "encoding/json" "encoding/pem" "fmt" "log" "math/big" "net/http" "os" "path/filepath" "sort" "strings" "time" ) //go:embed nsd-certs/* var nsdCertsFS embed.FS const ( nsdCertsDropinDir = "/var/lib/bj/.bj/nsd-certs" // ручные .cer оператора nsdCertsApplyDir = "/var/lib/bj/nsd-certs-apply" // staging для root-воркера nsdCertsInterval = 6 * time.Hour nsdCertsStartWait = 90 * time.Second ) // NSDCertsSettings — состояние автообновления доверенных сертификатов НРД. type NSDCertsSettings struct { Manual bool `json:"manual"` // true = только по кнопке «Применить» LastRun time.Time `json:"last_run"` LastLog string `json:"last_log"` LastProfile string `json:"last_profile"` LastRows []NSDCertRow `json:"last_rows"` LastStore []NSDStoreCert `json:"last_store"` LastApplied time.Time `json:"last_applied"` } // UpdateNSDCerts сохраняет состояние автообновления сертификатов НРД. func (r *RuntimeConfig) UpdateNSDCerts(s NSDCertsSettings) error { r.mu.Lock() r.data.NSDCerts = s r.data.UpdatedAt = time.Now() r.mu.Unlock() return r.save() } // ---- манифест и разбор сертификатов ---- type nsdManifest struct { Certs []nsdManifestCert `json:"certs"` } type nsdManifestCert struct { File string `json:"file"` Title string `json:"title"` Contour string `json:"contour"` // prod | test | manual Serial string `json:"serial"` ReplacesSerial string `json:"replaces_serial"` ReplacesTitle string `json:"replaces_title"` RemoveReplacedAfter string `json:"remove_replaced_after"` // RFC3339 Source string `json:"source"` Note string `json:"note"` } type nsdCertInfo struct { SubjectCN, SubjectO, IssuerCN, Serial string NotBefore, NotAfter time.Time SelfSigned bool } type nsdBundleCert struct { nsdManifestCert Info nsdCertInfo DER []byte Origin string // bundle | dropin } // asn1-структуры: разбираем только TBS-заголовок — стандартная x509 не // понимает ГОСТ-ключи («malformed certificate»), а нам нужны только // subject/issuer/serial/validity. Хвост SEQUENCE Go asn1 допускает. type nsdASN1Validity struct{ NotBefore, NotAfter time.Time } type nsdASN1TBS struct { Version int `asn1:"optional,explicit,default:0,tag:0"` Serial *big.Int SigAlg asn1.RawValue Issuer asn1.RawValue Validity nsdASN1Validity Subject asn1.RawValue } type nsdASN1Cert struct { TBS nsdASN1TBS SigAlg asn1.RawValue Sig asn1.BitString } func nsdName(raw asn1.RawValue) (cn, o string) { var rdn pkix.RDNSequence if _, err := asn1.Unmarshal(raw.FullBytes, &rdn); err != nil { return "", "" } var n pkix.Name n.FillFromRDNSequence(&rdn) if len(n.Organization) > 0 { o = n.Organization[0] } return n.CommonName, o } // parseNSDCert принимает DER или PEM, возвращает сведения + DER. func parseNSDCert(raw []byte) (nsdCertInfo, []byte, error) { der := raw if bytes.Contains(raw, []byte("-----BEGIN")) { blk, _ := pem.Decode(raw) if blk == nil { return nsdCertInfo{}, nil, fmt.Errorf("PEM не разобран") } der = blk.Bytes } var c nsdASN1Cert if _, err := asn1.Unmarshal(der, &c); err != nil { return nsdCertInfo{}, nil, fmt.Errorf("ASN.1: %v", err) } info := nsdCertInfo{Serial: fmt.Sprintf("%X", c.TBS.Serial), NotBefore: c.TBS.Validity.NotBefore, NotAfter: c.TBS.Validity.NotAfter} info.SubjectCN, info.SubjectO = nsdName(c.TBS.Subject) info.IssuerCN, _ = nsdName(c.TBS.Issuer) info.SelfSigned = bytes.Equal(c.TBS.Subject.FullBytes, c.TBS.Issuer.FullBytes) return info, der, nil } func normSerial(s string) string { s = strings.ToUpper(strings.TrimSpace(s)) s = strings.TrimPrefix(s, "0X") return strings.TrimLeft(s, "0") } // loadNSDCertBundle — вшитый комплект + ручные .cer оператора. func loadNSDCertBundle() ([]nsdBundleCert, []string) { var out []nsdBundleCert var warns []string mb, err := nsdCertsFS.ReadFile("nsd-certs/manifest.json") if err != nil { warns = append(warns, "манифест не найден: "+err.Error()) } var m nsdManifest if err == nil { if jerr := json.Unmarshal(mb, &m); jerr != nil { warns = append(warns, "манифест не разобран: "+jerr.Error()) } } for _, mc := range m.Certs { raw, err := nsdCertsFS.ReadFile("nsd-certs/" + mc.File) if err != nil { warns = append(warns, mc.File+": "+err.Error()) continue } info, der, err := parseNSDCert(raw) if err != nil { warns = append(warns, mc.File+": "+err.Error()) continue } if mc.Serial != "" && normSerial(mc.Serial) != normSerial(info.Serial) { warns = append(warns, fmt.Sprintf("%s: серийный в манифесте %s ≠ в файле %s — беру из файла", mc.File, mc.Serial, info.Serial)) } mc.Serial = info.Serial if mc.Title == "" { mc.Title = info.SubjectCN } out = append(out, nsdBundleCert{nsdManifestCert: mc, Info: info, DER: der, Origin: "bundle"}) } // ручные сертификаты оператора — без замен/удалений if files, _ := filepath.Glob(filepath.Join(nsdCertsDropinDir, "*.cer")); len(files) > 0 { sort.Strings(files) for _, f := range files { raw, err := os.ReadFile(f) if err != nil { continue } info, der, err := parseNSDCert(raw) if err != nil { warns = append(warns, filepath.Base(f)+": "+err.Error()) continue } dup := false for _, b := range out { if normSerial(b.Serial) == normSerial(info.Serial) { dup = true break } } if dup { continue } out = append(out, nsdBundleCert{ nsdManifestCert: nsdManifestCert{File: filepath.Base(f), Title: info.SubjectCN + " (ручной, " + filepath.Base(f) + ")", Contour: "manual", Serial: info.Serial}, Info: info, DER: der, Origin: "dropin", }) } } return out, warns } // ---- содержимое справочника (от root-воркера) ---- // NSDStoreCert — запись справочника профиля ИШ (JSON от certs-list). type NSDStoreCert struct { Kind string `json:"kind"` // x509 | crl Store string `json:"store"` Subject string `json:"subject"` SubjectCN string `json:"subject_cn"` IssuerCN string `json:"issuer_cn"` Serial string `json:"serial"` NotBefore string `json:"not_before"` NotAfter string `json:"not_after"` SelfSigned bool `json:"self_signed"` } func nsdStoreFromResult(res map[string]any) []NSDStoreCert { b, _ := json.Marshal(res["certs"]) var st []NSDStoreCert _ = json.Unmarshal(b, &st) for i := range st { st[i].NotBefore = nsdPrettyDate(st[i].NotBefore) st[i].NotAfter = nsdPrettyDate(st[i].NotAfter) } return st } func nsdPrettyDate(s string) string { if t, err := time.Parse("Jan 2 15:04:05 2006 MST", strings.Join(strings.Fields(s), " ")); err == nil { return t.Format("02.01.2006") } return s } func workerCertsList(ctx context.Context) (string, []NSDStoreCert, error) { res, err := runKeymediaOp(ctx, "certs-list", map[string]string{"channel": contourChannel(contourOf(currentNSDProfile()))}) if err != nil { return "", nil, err } prof, _ := res["profile"].(string) return prof, nsdStoreFromResult(res), nil } // ---- план ---- // NSDCertRow — строка таблицы «комплект ↔ справочник». type NSDCertRow struct { Title string `json:"title"` Contour string `json:"contour"` SubjectCN string `json:"subject_cn"` IssuerCN string `json:"issuer_cn"` Serial string `json:"serial"` ValidFrom string `json:"valid_from"` ValidTo string `json:"valid_to"` Status string `json:"status"` Kind string `json:"kind"` // ok | add | skip | expired | unknown Action string `json:"action,omitempty"` RemoveNote string `json:"remove_note,omitempty"` RemoveSerial string `json:"remove_serial,omitempty"` bundleIdx int } func nsdCertsPlan(bundle []nsdBundleCert, store []NSDStoreCert, now time.Time) []NSDCertRow { leaf := map[string]NSDStoreCert{} ca := map[string]bool{} for _, c := range store { if c.Kind != "x509" { continue } leaf[normSerial(c.Serial)] = c if c.SelfSigned { ca[strings.TrimSpace(c.SubjectCN)] = true } } var rows []NSDCertRow for i, b := range bundle { row := NSDCertRow{ Title: b.Title, Contour: b.Contour, SubjectCN: b.Info.SubjectCN, IssuerCN: b.Info.IssuerCN, Serial: b.Info.Serial, ValidFrom: b.Info.NotBefore.Format("02.01.2006"), ValidTo: b.Info.NotAfter.Format("02.01.2006"), bundleIdx: i, } _, present := leaf[normSerial(b.Info.Serial)] caPresent := ca[strings.TrimSpace(b.Info.IssuerCN)] switch { case now.After(b.Info.NotAfter): row.Kind, row.Status = "expired", "истёк "+row.ValidTo+" — пропущен" case present: row.Kind, row.Status = "ok", "в справочнике ИШ" case !caPresent: row.Kind, row.Status = "skip", "не для этого контура — в справочнике нет УЦ «"+b.Info.IssuerCN+"»" default: row.Kind, row.Status, row.Action = "add", "будет добавлен в справочник", "add" } // замена старого сертификата if b.ReplacesSerial != "" && row.Kind != "skip" && row.Kind != "expired" { if old, ok := leaf[normSerial(b.ReplacesSerial)]; ok { title := b.ReplacesTitle if title == "" { title = old.SubjectCN } after, perr := time.Parse(time.RFC3339, b.RemoveReplacedAfter) switch { case perr != nil || !now.Before(after): row.RemoveSerial = old.Serial row.RemoveNote = "старый сертификат «" + title + "» будет удалён" if perr == nil { row.RemoveNote += " (срок " + after.Local().Format("02.01.2006 15:04") + " наступил)" } default: row.RemoveNote = "старый «" + title + "» остаётся до " + after.Local().Format("02.01.2006 15:04") + ", затем удалится автоматически" } } } rows = append(rows, row) } return rows } func nsdPending(rows []NSDCertRow) int { n := 0 for _, r := range rows { if r.Action == "add" { n++ } if r.RemoveSerial != "" { n++ } } return n } // ---- проверка и применение ---- func nsdSaveState(rc *RuntimeConfig, profile string, rows []NSDCertRow, store []NSDStoreCert, logText string, applied bool) { s := rc.Snapshot().NSDCerts s.LastRun = time.Now() s.LastLog = logText if profile != "" { s.LastProfile = profile } if rows != nil { s.LastRows = rows } if store != nil { s.LastStore = store } if applied { s.LastApplied = time.Now() } if err := rc.UpdateNSDCerts(s); err != nil { log.Printf("nsdcerts: сохранение состояния: %v", err) } } // nsdCertsCheck — сверка без изменений. func nsdCertsCheck(ctx context.Context, rc *RuntimeConfig) ([]NSDCertRow, error) { profile, store, err := workerCertsList(ctx) if err != nil { nsdSaveState(rc, "", nil, nil, "сверка не выполнена: "+err.Error(), false) return nil, err } bundle, warns := loadNSDCertBundle() rows := nsdCertsPlan(bundle, store, time.Now()) var sb strings.Builder fmt.Fprintf(&sb, "профиль %s: в справочнике %d записей, в комплекте %d сертификатов, ожидает изменений: %d", profile, len(store), len(bundle), nsdPending(rows)) for _, w := range warns { sb.WriteString("\n⚠ " + w) } nsdSaveState(rc, profile, rows, store, sb.String(), false) return rows, nil } // nsdCertsApply — сверка и применение изменений (если они есть). func nsdCertsApply(ctx context.Context, rc *RuntimeConfig, trigger string) (string, bool, error) { profile, store, err := workerCertsList(ctx) if err != nil { nsdSaveState(rc, "", nil, nil, "сверка не выполнена: "+err.Error(), false) return "", false, err } bundle, warns := loadNSDCertBundle() now := time.Now() rows := nsdCertsPlan(bundle, store, now) var adds []nsdBundleCert var removes []string var what []string for _, r := range rows { if r.Action == "add" { adds = append(adds, bundle[r.bundleIdx]) what = append(what, "добавить «"+r.Title+"»") } if r.RemoveSerial != "" { removes = append(removes, r.RemoveSerial) what = append(what, r.RemoveNote) } } if len(adds) == 0 && len(removes) == 0 { msg := fmt.Sprintf("профиль %s: изменений не требуется (в справочнике %d записей)", profile, len(store)) for _, w := range warns { msg += "\n⚠ " + w } nsdSaveState(rc, profile, rows, store, msg, false) return msg, false, nil } dir := filepath.Join(nsdCertsApplyDir, now.Format("20060102-150405")) if err := os.MkdirAll(dir, 0o755); err != nil { return "", false, fmt.Errorf("staging: %w", err) } defer os.RemoveAll(dir) for _, a := range adds { if err := os.WriteFile(filepath.Join(dir, normSerial(a.Info.Serial)+".cer"), a.DER, 0o644); err != nil { return "", false, fmt.Errorf("staging: %w", err) } } res, err := runKeymediaOp(ctx, "certs-apply", map[string]string{"staging": dir, "extra": strings.Join(removes, ","), "channel": contourChannel(contourOf(currentNSDProfile()))}) if err != nil { msg := "НЕ ПРИМЕНЕНО (" + trigger + "): " + err.Error() + "\nплан: " + strings.Join(what, "; ") nsdSaveState(rc, profile, rows, store, msg, false) _ = rc.AddNews(NewsItem{At: time.Now(), Kind: "system", Title: "Сертификаты НРД: не удалось обновить справочник ИШ", Body: "Профиль " + profile + ". " + err.Error() + ". Справочник откачен из бэкапа, ИШ работает на прежнем наборе. План: " + strings.Join(what, "; ") + ". Проверьте носитель ключа и повторите в Настройки → Криптография."}) return "", false, err } newStore := nsdStoreFromResult(res) rows2 := nsdCertsPlan(bundle, newStore, now) // контроль результата var problems []string for _, r := range rows2 { if r.Action == "add" { problems = append(problems, "«"+r.Title+"» так и не появился") } if r.RemoveSerial != "" { problems = append(problems, "старый "+r.RemoveSerial+" всё ещё в справочнике") } } igate, _ := res["igate"].(string) backup, _ := res["backup"].(string) msg := fmt.Sprintf("ПРИМЕНЕНО (%s) в %s: %s. ИШ перезапущен (%s), бэкап %s", trigger, now.Format("02.01.2006 15:04"), strings.Join(what, "; "), igate, backup) if len(problems) > 0 { msg += "\n⚠ " + strings.Join(problems, "; ") } for _, w := range warns { msg += "\n⚠ " + w } nsdSaveState(rc, profile, rows2, newStore, msg, true) _ = rc.AddNews(NewsItem{At: time.Now(), Kind: "system", Title: "Сертификаты НРД обновлены в справочнике ИШ", Body: "Профиль " + profile + ": " + strings.Join(what, "; ") + ". Справочник пересобран, подпись проверена, ИШ перезапущен. Бэкап: " + backup + "."}) log.Printf("nsdcerts: %s", strings.ReplaceAll(msg, "\n", " | ")) return msg, true, nil } // nsdCertsTick — один проход планировщика. func nsdCertsTick(ctx context.Context, rc *RuntimeConfig) { s := rc.Snapshot() if s.NSD.IGWBaseURL == "" { return // mock-режим, реального ИШ нет } if st, err := os.Stat(keymediaReqDir); err != nil || !st.IsDir() { return // root-воркер не установлен } tctx, cancel := context.WithTimeout(ctx, 4*time.Minute) defer cancel() if s.NSDCerts.Manual { if rows, err := nsdCertsCheck(tctx, rc); err == nil && nsdPending(rows) > 0 { log.Printf("nsdcerts: ожидает изменений %d, автоприменение выключено", nsdPending(rows)) } return } if _, changed, err := nsdCertsApply(tctx, rc, "авто"); err != nil { log.Printf("nsdcerts: авто: %v", err) } else if !changed { log.Printf("nsdcerts: авто: изменений не требуется") } } // StartNSDCertsSync — при старте (через 90 с, чтобы ИШ/воркер поднялись) и // далее каждые 6 ч. Обновление бинаря bj → перезапуск → новый комплект // применится сам. func StartNSDCertsSync(rc *RuntimeConfig) func() { ctx, cancel := context.WithCancel(context.Background()) go func() { select { case <-ctx.Done(): return case <-time.After(nsdCertsStartWait): } for { func() { defer func() { if r := recover(); r != nil { log.Printf("nsdcerts: panic: %v", r) } }() nsdCertsTick(ctx, rc) }() select { case <-ctx.Done(): return case <-time.After(nsdCertsInterval): } } }() return cancel } // ---- UI ---- // NSDCertsView — данные карточки «Сертификаты НРД в справочнике ИШ». type NSDCertsView struct { Profile string Rows []NSDCertRow Store []NSDStoreCert LastRun time.Time LastLog string Manual bool Pending int Bundle int } func nsdCertsView(s Settings) NSDCertsView { v := NSDCertsView{Profile: s.NSDCerts.LastProfile, Rows: s.NSDCerts.LastRows, Store: s.NSDCerts.LastStore, LastRun: s.NSDCerts.LastRun, LastLog: s.NSDCerts.LastLog, Manual: s.NSDCerts.Manual} bundle, _ := loadNSDCertBundle() v.Bundle = len(bundle) if len(v.Rows) == 0 { for _, b := range bundle { v.Rows = append(v.Rows, NSDCertRow{Title: b.Title, Contour: b.Contour, SubjectCN: b.Info.SubjectCN, IssuerCN: b.Info.IssuerCN, Serial: b.Info.Serial, ValidFrom: b.Info.NotBefore.Format("02.01.2006"), ValidTo: b.Info.NotAfter.Format("02.01.2006"), Kind: "unknown", Status: "ещё не сверялось"}) } } v.Pending = nsdPending(v.Rows) return v } func (h *setupHandlers) saveNSDCerts(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } s := h.rc.Snapshot().NSDCerts s.Manual = r.FormValue("auto") == "" if err := h.rc.UpdateNSDCerts(s); err != nil { setupFlash(w, r, "Сертификаты НРД: ошибка сохранения: "+err.Error()) return } if s.Manual { setupFlash(w, r, "Сертификаты НРД: автоприменение выключено — только по кнопке «Применить сейчас».") } else { setupFlash(w, r, "Сертификаты НРД: автоприменение включено (при старте и каждые 6 ч).") } } func (h *setupHandlers) checkNSDCerts(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute) defer cancel() rows, err := nsdCertsCheck(ctx, h.rc) if err != nil { setupFlash(w, r, "Сертификаты НРД: сверка не выполнена — "+err.Error()) return } if n := nsdPending(rows); n > 0 { setupFlash(w, r, fmt.Sprintf("Сертификаты НРД: сверено, ожидает изменений: %d — нажмите «Применить сейчас» или дождитесь автоприменения.", n)) return } setupFlash(w, r, "Сертификаты НРД: сверено, справочник ИШ актуален.") } func (h *setupHandlers) applyNSDCerts(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } ctx, cancel := context.WithTimeout(r.Context(), 4*time.Minute) defer cancel() msg, _, err := nsdCertsApply(ctx, h.rc, "вручную") if err != nil { setupFlash(w, r, "Сертификаты НРД: НЕ применено — "+err.Error()) return } if len(msg) > 600 { msg = msg[:600] + "…" } setupFlash(w, r, "Сертификаты НРД: "+strings.ReplaceAll(msg, "\n", " ")) }