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>
This commit is contained in:
zuevav
2026-09-15 19:41:12 +03:00
parent 59fc774f08
commit da12a9ec1c
132 changed files with 29969 additions and 1089 deletions
+609
View File
@@ -0,0 +1,609 @@
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", " "))
}