Files
Bridge-and-Join-s/internal/lkgateway/nsdcerts.go
T
zuevav da12a9ec1c feat(bj): модуль ЛК на PostgreSQL, обмен с DEPO, боевой ЛК с имитатором НРД, QUIK, НПР
- bj_exchange и «кран» Fancy убраны: данные модуля ЛК в PostgreSQL (схема lk)
- трансферные таблицы FTV_M2M: создание при установке, события, справочник участников
- сверка с DEPO: счёт, ФИО, дата рождения, ИИС по договору, квал-инвестор из опции DEPO
- боевой ЛК ЕСИА Финанс при тестовом ИШ: журнал, отказ через DEPO, статусы в ЛК
- онлайн-лицензирование в bj, план тестирования M2M для контрагента (интерактивный HTML)
- модули QUIK МНП, экспорт QUIK, НПР/УДС; документы и тестовые материалы

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 19:41:12 +03:00

610 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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", nil)
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, ",")})
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", " "))
}