Files
Bridge-and-Join-s/internal/lkgateway/cacerts.go
T
fontvielle 93f3ec240c feat(admin): блок «Новости» + doc-watcher + авто-уведомления о сертификатах УЦ
Новый раздел /admin/news — лента событий системы (окна техработ НРД,
обновления документации, переустановка сертификатов УЦ). Каждая
новость со временем, типом (maintenance/feature/doc-update/system/
manual), опциональным окном действительности (ValidFrom..ValidTo) и
ссылкой на источник. Лента не очищается — служит журналом для аудита.

На дашборде /admin/ — компактный блок «📢 Новости»: показывает максимум
3 актуальных события (активных сейчас или с окном, начинающимся в
ближайшие 7 дней; в остатке — самые свежие). Окна техработ при
наступлении становятся жёлтыми (border-left, ValidFrom..ValidTo).

В ленте можно добавлять новости вручную (форма на /admin/news), скрывать
(soft-delete через Dismissed). Дедуп по ID.

Doc-watcher: горутина в bj-server, раз в сутки качает страницы НРД
(дефолтные источники — moex-most, программы НРД, криптосервис), парсит
HTML на ссылки .pdf, скачивает новые версии в DOC/ (со старыми
переименовывая в .YYYY-MM-DD.pdf.bak для аудита), и публикует
новость «Обновлена документация: <file>». Sha256-дедуп — пере-импорта
неизменённого PDF не будет.

Cacerts.go: FetchCACertificates теперь принимает *RuntimeConfig и при
успешной переустановке сертификата эмитирует NewsItem «Обновлён
сертификат УЦ: <CN>». Если сертификат истекает в ближайшие 14 дней —
отдельная новость-предупреждение. Это закрывает запрос «получает в авто
режиме и предупреждает об этом» из обсуждения.

SeedDefaultNews публикует при старте bj-server две известные новости:
- TEST3 недоступен 18.05.2026 — 22.05.2026 (НРД письмо НРД-И-2026-8452)
- Робот-автотест MOEX МОСТ доступен на TEST3 с 12.05.2026

Скачаны три свежие инструкции с nsd.ru/services/novye-servisy/moex-most-dlya-m2m/:
- DOC/instruktsiya-po-testirovaniyu-s-robotom.pdf (новая, 12.05.2026)
- DOC/instruktsiya-dlya-osuschestvleniya-obmena-soobscheniyami-...-fizicheskim-litsom-samomu-sebe.pdf (новая, 12.05.2026)
- DOC/servis-most-m2m.pdf (актуальная общая инструкция)

Mastered tasks: #46, #47, #48.
2026-05-14 16:26:41 +03:00

318 lines
12 KiB
Go

package lkgateway
import (
"context"
"crypto/sha256"
"crypto/x509"
"encoding/hex"
"encoding/pem"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// defaultNSDCAURLs — список URL для авто-загрузки сертификатов УЦ НРД.
// Эти URL пользователь может скорректировать в /admin/setup → «Сертификаты
// УЦ» (раздел появляется после первого сохранения настроек). На сайте НРД
// (www.nsd.ru/workflow/system/cryptography/) сертификаты выложены в виде
// .cer файлов — нужно скопировать их прямые URL сюда.
//
// По умолчанию список пустой, потому что прямые URL у НРД меняются от
// релиза к релизу и должны быть проверены оператором перед использованием.
var defaultNSDCAURLs = []string{
// https://www.nsd.ru/workflow/system/cryptography/ — раскомментируйте
// нужные ссылки в UI после того, как уточните URL у НРД.
}
// FetchCACertificates скачивает все URL из настроек, парсит .cer, и при
// успехе вызывает certmgr -inst -store mroot. Если передан rc — на каждое
// фактическое изменение сертификата (новый или изменился SHA-256)
// публикуется новость в ленту через rc.AddNews. На сертификаты,
// истекающие в ближайшие 14 дней — отдельная новость-предупреждение.
func FetchCACertificates(ctx context.Context, s CACertsSettings, rc *RuntimeConfig) (CACertsSettings, string) {
if len(s.URLs) == 0 {
return s, "Список URL пуст. Добавьте ссылки на .cer-файлы УЦ НРД в /admin/setup → «Сертификаты УЦ»."
}
var logBuf strings.Builder
now := time.Now()
newFetched := make([]FetchedCACert, 0, len(s.URLs))
for _, u := range s.URLs {
u = strings.TrimSpace(u)
if u == "" {
continue
}
fc := FetchedCACert{URL: u, FetchedAt: now}
der, err := downloadAndParseCert(ctx, u)
if err != nil {
fc.Error = err.Error()
newFetched = append(newFetched, fc)
fmt.Fprintf(&logBuf, "%s — ОШИБКА: %s\n", u, err)
continue
}
cert, perr := x509.ParseCertificate(der)
if perr != nil {
fc.Error = "не удалось распарсить X.509: " + perr.Error()
newFetched = append(newFetched, fc)
fmt.Fprintf(&logBuf, "%s — не X.509: %s\n", u, perr)
continue
}
fc.SubjectCN = cert.Subject.CommonName
fc.IssuerCN = cert.Issuer.CommonName
fc.NotAfter = cert.NotAfter
fc.SHA256 = hex.EncodeToString(sha256Bytes(der))
// УЦ-сертификаты с самоподписью (Issuer == Subject) идут в mroot,
// промежуточные — в uRoot.
store := "uRoot"
if cert.Subject.CommonName == cert.Issuer.CommonName {
store = "mroot"
}
fc.Store = store
// Дедуп: если sha256 совпадает с уже импортированным — пропускаем
// сам импорт (но фиксируем что проверили).
alreadyImported := false
for _, old := range s.FetchedCerts {
if old.URL == u && old.SHA256 == fc.SHA256 && old.Error == "" {
alreadyImported = true
break
}
}
if alreadyImported {
fmt.Fprintf(&logBuf, "%s — не изменился (sha256=%s...)\n", u, fc.SHA256[:12])
newFetched = append(newFetched, fc)
continue
}
// Импорт через certmgr.
isNew := true
for _, old := range s.FetchedCerts {
if old.URL == u && old.Error == "" {
isNew = false
break
}
}
if err := importCertToStore(ctx, der, store); err != nil {
fc.Error = "certmgr: " + err.Error()
fmt.Fprintf(&logBuf, "%s — certmgr упал: %s\n", u, err)
if rc != nil {
_ = rc.AddNews(NewsItem{
ID: "ca-error-" + fc.SHA256[:12],
At: now,
Kind: "system",
Title: "Не удалось импортировать сертификат УЦ",
Body: "URL: " + u + "\nCN: " + fc.SubjectCN + "\nОшибка: " + err.Error(),
URL: u,
})
}
} else {
fmt.Fprintf(&logBuf, "%s — импортирован в %s (CN=%s, sha256=%s...)\n",
u, store, fc.SubjectCN, fc.SHA256[:12])
if rc != nil {
kindTitle := "Обновлён сертификат УЦ"
if isNew {
kindTitle = "Установлен новый сертификат УЦ"
}
_ = rc.AddNews(NewsItem{
ID: "ca-update-" + fc.SHA256[:12],
At: now,
Kind: "feature",
Title: kindTitle + ": " + fc.SubjectCN,
Body: fmt.Sprintf("Хранилище: %s\nИздатель: %s\nДействителен до: %s\nSHA-256: %s…\nURL источника: %s",
store, fc.IssuerCN, fc.NotAfter.Format("02.01.2006"), fc.SHA256[:16], u),
URL: u,
ValidTo: fc.NotAfter,
})
// Предупреждение если истекает в ближайшие 14 дней.
if !fc.NotAfter.IsZero() && time.Until(fc.NotAfter) < 14*24*time.Hour {
_ = rc.AddNews(NewsItem{
ID: "ca-expiring-" + fc.SHA256[:12],
At: now,
Kind: "system",
Title: "⚠ Сертификат УЦ скоро истечёт: " + fc.SubjectCN,
Body: fmt.Sprintf("Срок действия — %s (через %d дней). Получите новую версию у УЦ и обновите URL в /admin/setup → «Сертификаты УЦ».",
fc.NotAfter.Format("02.01.2006"),
int(time.Until(fc.NotAfter)/(24*time.Hour))),
URL: u,
ValidTo: fc.NotAfter,
})
}
}
}
newFetched = append(newFetched, fc)
}
s.LastFetch = now
s.LastFetchLog = logBuf.String()
s.FetchedCerts = newFetched
return s, logBuf.String()
}
func sha256Bytes(b []byte) []byte {
h := sha256.Sum256(b)
return h[:]
}
// downloadAndParseCert качает URL и возвращает DER-байты сертификата.
// Поддерживает PEM (-----BEGIN CERTIFICATE-----) и сырой DER.
func downloadAndParseCert(ctx context.Context, rawURL string) ([]byte, error) {
u, err := url.Parse(rawURL)
if err != nil {
return nil, fmt.Errorf("не URL: %w", err)
}
if u.Scheme != "http" && u.Scheme != "https" {
return nil, fmt.Errorf("поддерживаются только http/https, получено %q", u.Scheme)
}
reqCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "bj-server/1.0 (cacerts auto-fetch)")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("сеть: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, 5<<20))
if err != nil {
return nil, err
}
// Пробуем PEM.
if block, _ := pem.Decode(data); block != nil && block.Type == "CERTIFICATE" {
return block.Bytes, nil
}
// Иначе считаем что DER.
return data, nil
}
// importCertToStore вызывает certmgr -inst -store <store> -file <tmp>.
func importCertToStore(ctx context.Context, der []byte, store string) error {
const certmgr = "/opt/cprocsp/bin/amd64/certmgr"
if _, err := os.Stat(certmgr); err != nil {
return fmt.Errorf("certmgr не найден (КриптоПро CSP не установлен?): %w", err)
}
tmp, err := os.CreateTemp("", "bj-ca-*.cer")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if _, err := tmp.Write(der); err != nil {
tmp.Close()
return err
}
tmp.Close()
cmd := exec.CommandContext(ctx, certmgr, "-inst", "-store", store, "-file", tmp.Name())
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%w / %s", err, strings.TrimSpace(string(out)))
}
return nil
}
// StartCACertsAutoUpdater запускает горутину, которая раз в сутки
// перекачивает сертификаты УЦ и переустанавливает изменённые. Возвращает
// функцию остановки. Если AutoUpdate=false — фон не запускается.
func StartCACertsAutoUpdater(rc *RuntimeConfig) func() {
ctx, cancel := context.WithCancel(context.Background())
go func() {
// При старте — небольшой запас, чтобы не лезть в сеть в ту же
// секунду запуска bj-server.
select {
case <-ctx.Done():
return
case <-time.After(30 * time.Second):
}
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
for {
s := rc.Snapshot().CACerts
if s.AutoUpdate && len(s.URLs) > 0 {
updated, _ := FetchCACertificates(ctx, s, rc)
if err := rc.UpdateCACerts(updated); err != nil {
log.Printf("ca-certs auto-update: save failed: %v", err)
} else {
log.Printf("ca-certs auto-update: %d url'ов проверено", len(s.URLs))
}
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}()
return cancel
}
// saveCACerts — POST /admin/setup/cacerts.
// Принимает форму с textarea (одна URL на строку) и чекбоксом auto_update.
func (h *setupHandlers) saveCACerts(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
raw := r.FormValue("urls")
auto := r.FormValue("auto_update") == "on"
urls := []string{}
for _, line := range strings.Split(raw, "\n") {
line = strings.TrimSpace(line)
if line != "" && !strings.HasPrefix(line, "#") {
urls = append(urls, line)
}
}
cur := h.rc.Snapshot().CACerts
cur.URLs = urls
cur.AutoUpdate = auto
if err := h.rc.UpdateCACerts(cur); err != nil {
setupFlash(w, r, "Сертификаты УЦ: не получилось сохранить: "+err.Error())
return
}
setupFlash(w, r, fmt.Sprintf("Сертификаты УЦ: сохранено %d URL'ов, авто-обновление: %v", len(urls), auto))
}
// fetchCACertsNow — POST /admin/setup/cacerts/fetch.
// Ручной триггер «скачать сейчас», вызывает FetchCACertificates сразу.
func (h *setupHandlers) fetchCACertsNow(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()
cur := h.rc.Snapshot().CACerts
updated, summary := FetchCACertificates(ctx, cur, h.rc)
if err := h.rc.UpdateCACerts(updated); err != nil {
setupFlash(w, r, "Сертификаты УЦ: ошибка сохранения: "+err.Error())
return
}
if summary == "" {
summary = "готово"
}
// Обрезаем длинный лог в flash-сообщении.
if len(summary) > 800 {
summary = summary[:800] + "…"
}
setupFlash(w, r, "Сертификаты УЦ обновлены: "+strings.TrimSpace(summary))
}
// caCertsTemplateString — компактный URL для отображения в UI.
func caCertsTemplateString(s CACertsSettings) string {
return strings.Join(s.URLs, "\n")
}
// доп. защита от пустых импортов (linter)
var _ = filepath.Join