package lkgateway // Сроки сертификатов в справочнике ИШ (05.10.2026). Своих сертификатов bj напрямую не видит (ключи — в // Валидате и ИШ), но знает содержимое справочника активного профиля ИШ: его раз в 6 часов читает сверка // сертификатов НРД (nsdcerts.go, NSDCerts.LastStore). Свой сертификат — тот, что не из комплекта НРД, // не самоподписанный (не УЦ) и выдан не НРД, бирже или НКЦ. По нему же отмечается шаг «Сертификаты». // // Уведомления: за 30, 14, 7, 3 и 1 день до окончания и в день окончания — для своего сертификата и для // сертификатов НРД, если им нет замены с более поздним сроком. import ( "encoding/json" "fmt" "log" "os" "regexp" "strings" "sync" "time" "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/cryptocli" ) // ИНН организаций, чьи сертификаты лежат в справочнике рядом с нашим (без ведущих нулей). var counterpartINN = map[string]string{"7702165310": "НРД", "7702077840": "Московская Биржа", "7750004023": "НКЦ"} var subjectINNRe = regexp.MustCompile(`(?:^|,)\s*INN=0*(\d{10,12})`) func subjectINN(subject string) string { if m := subjectINNRe.FindStringSubmatch(subject); m != nil { return m[1] } return "" } // storeNotAfter — срок из справочника («02.01.2006»): сертификат действует весь последний день. func storeNotAfter(c NSDStoreCert) time.Time { t, err := time.ParseInLocation("02.01.2006", strings.TrimSpace(c.NotAfter), time.Local) if err != nil { return time.Time{} } return t.Add(24*time.Hour - time.Second) } func certDaysLeft(t time.Time) int { if t.IsZero() { return 1 << 20 } d := time.Until(t) if d < 0 { return -1 } return int(d.Hours() / 24) } func bundleSerials() map[string]bool { out := map[string]bool{} bundle, _ := loadNSDCertBundle() for _, b := range bundle { out[normSerial(b.Serial)] = true if b.ReplacesSerial != "" { out[normSerial(b.ReplacesSerial)] = true } } return out } // storeCertKind — own (свой), counterpart (НРД/биржа/НКЦ), ca (УЦ) или "" (не сертификат). func storeCertKind(c NSDStoreCert, bundle map[string]bool) string { if c.Kind != "x509" { return "" } if c.SelfSigned || strings.Contains(c.SubjectCN, "УЦ") { return "ca" } if bundle[normSerial(c.Serial)] || counterpartINN[subjectINN(c.Subject)] != "" { return "counterpart" } return "own" } // ownStoreCerts — свои сертификаты в справочнике ИШ. func ownStoreCerts(s Settings) []NSDStoreCert { bundle := bundleSerials() var out []NSDStoreCert for _, c := range s.NSDCerts.LastStore { if storeCertKind(c, bundle) == "own" { out = append(out, c) } } return out } // storeCertsForUI — сертификаты справочника ИШ (кроме УЦ) для шага «Сертификаты»: свой — с отметкой «ключ есть». func storeCertsForUI(s Settings) []cryptocli.Certificate { bundle := bundleSerials() var out []cryptocli.Certificate seen := map[string]bool{} for _, c := range s.NSDCerts.LastStore { k := storeCertKind(c, bundle) if k == "" || k == "ca" || seen[c.Serial] { continue } seen[c.Serial] = true out = append(out, cryptocli.Certificate{SubjectCN: c.SubjectCN, IssuerCN: c.IssuerCN, Serial: c.Serial, NotAfter: storeNotAfter(c), INN: subjectINN(c.Subject), HasPrivateKey: k == "own"}) } return out } var certExpiryMarks = []int{30, 14, 7, 3, 1, 0} var certExpiry = struct { sync.Mutex file string }{file: "/var/lib/bj/.bj/cert-expiry.json"} // certExpiryTick — разослать уведомления о сроках (каждая отметка — один раз на сертификат). func certExpiryTick(rc *RuntimeConfig) { s := rc.Snapshot() if len(s.NSDCerts.LastStore) == 0 { return } certExpiry.Lock() defer certExpiry.Unlock() sent := map[string][]int{} if raw, err := os.ReadFile(certExpiry.file); err == nil { _ = json.Unmarshal(raw, &sent) } bundle := bundleSerials() // сертификат НРД, которому уже есть замена с более поздним сроком, не тревожим latest := map[string]time.Time{} for _, c := range s.NSDCerts.LastStore { if t := storeNotAfter(c); c.Kind == "x509" && t.After(latest[c.SubjectCN]) { latest[c.SubjectCN] = t } } changed := false for _, c := range s.NSDCerts.LastStore { kind := storeCertKind(c, bundle) if kind != "own" && kind != "counterpart" { continue } until := storeNotAfter(c) if until.IsZero() || (kind == "counterpart" && latest[c.SubjectCN].After(until)) { continue } days := certDaysLeft(until) key := normSerial(c.Serial) mark := -1 for _, m := range certExpiryMarks { if days <= m && !containsInt(sent[key], m) { mark = m // самая близкая к сроку из ещё не отправленных } } if mark < 0 { continue } for _, m := range certExpiryMarks { if m >= mark && !containsInt(sent[key], m) { sent[key] = append(sent[key], m) } } changed = true title, body := certExpiryText(c, kind, days, until) log.Printf("cert-expiry: %s", title) raiseSystemAlert(rc, title, body) } if changed { if raw, err := json.MarshalIndent(sent, "", " "); err == nil { _ = os.WriteFile(certExpiry.file, raw, 0o600) } } } func containsInt(a []int, v int) bool { for _, x := range a { if x == v { return true } } return false } func certExpiryText(c NSDStoreCert, kind string, days int, until time.Time) (string, string) { when := fmt.Sprintf("через %d дн.", days) switch { case days < 0: when = "ИСТЁК" case days == 0: when = "истекает сегодня" } who := fmt.Sprintf("«%s» (издатель «%s», серийный номер %s)", c.SubjectCN, c.IssuerCN, c.Serial) if kind == "own" { title := "Сертификат ключа организации " + when if days > 0 { title = "Сертификат ключа организации истекает " + when } return title, fmt.Sprintf("Сертификат %s действует до %s включительно. После этой даты подписать пакеты для НРД будет нечем — переводы M2M остановятся. "+ "Закажите новый ключ в Удостоверяющем центре Московской биржи заранее (выпуск занимает время) и установите его мастером установки ключа: Настройка → Криптография.", who, until.Format("02.01.2006")) } org := pickStr(counterpartINN[subjectINN(c.Subject)], "НРД") title := "Сертификат " + org + " в справочнике ИШ " + when if days > 0 { title = "Сертификат " + org + " в справочнике ИШ истекает " + when } return title, fmt.Sprintf("Сертификат %s действует до %s, замены с более поздним сроком в справочнике нет. Новые сертификаты НРД приезжают с обновлением bj — проверьте, что bj обновлён (Настройка → Обновления), и сверку сертификатов НРД (Настройка → Криптография).", who, until.Format("02.01.2006")) } // StartCertExpiryWatch — проверка сроков раз в 6 часов (первая — через 3 минуты после запуска). func StartCertExpiryWatch(rc *RuntimeConfig) func() { stop := make(chan struct{}) go func() { t := time.NewTimer(3 * time.Minute) defer t.Stop() for { select { case <-stop: return case <-t.C: certExpiryTick(rc) t.Reset(6 * time.Hour) } } }() return func() { close(stop) } }