Files
Bridge-and-Join-s/internal/lkgateway/cacerts.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

302 lines
11 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
import (
"context"
"crypto/sha256"
"crypto/x509"
"encoding/hex"
"encoding/pem"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
)
// caCertsDir — куда складываются скачанные сертификаты УЦ.
const caCertsDir = "/var/lib/bj/ca-certs"
// 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 и
// сохраняет файл в /var/lib/bj/ca-certs/. Если передан 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) и промежуточные складываем рядом,
// в общую папку /var/lib/bj/ca-certs/.
kind := "intermediate"
if cert.Subject.CommonName == cert.Issuer.CommonName {
kind = "root"
}
fc.Store = kind
// Дедуп: если 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
}
// Сохраняем DER на диск в /var/lib/bj/ca-certs/<sha>.cer.
isNew := true
for _, old := range s.FetchedCerts {
if old.URL == u && old.Error == "" {
isNew = false
break
}
}
if err := saveCertToDir(der, fc.SHA256); err != nil {
fc.Error = "save: " + err.Error()
fmt.Fprintf(&logBuf, "%s — сохранить не удалось: %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, kind, 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",
kind, 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", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0 Safari/537.36")
// noProxyClient определён в news.go — игнорирует HTTPS_PROXY (zetit).
resp, err := noProxyClient.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
}
// saveCertToDir сохраняет DER-байты в /var/lib/bj/ca-certs/<sha>.cer.
func saveCertToDir(der []byte, sha256hex string) error {
if err := os.MkdirAll(caCertsDir, 0o755); err != nil {
return err
}
dst := filepath.Join(caCertsDir, sha256hex+".cer")
return os.WriteFile(dst, der, 0o644)
}
// 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")
}