diff --git a/internal/lkgateway/cacerts.go b/internal/lkgateway/cacerts.go new file mode 100644 index 0000000..9696ab8 --- /dev/null +++ b/internal/lkgateway/cacerts.go @@ -0,0 +1,269 @@ +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. Возвращает обновлённую +// CACertsSettings (для записи в runtime-конфиг) и сводку как строку. +func FetchCACertificates(ctx context.Context, s CACertsSettings) (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. + if err := importCertToStore(ctx, der, store); err != nil { + fc.Error = "certmgr: " + err.Error() + fmt.Fprintf(&logBuf, "%s — certmgr упал: %s\n", u, err) + } else { + fmt.Fprintf(&logBuf, "%s — импортирован в %s (CN=%s, sha256=%s...)\n", + u, store, fc.SubjectCN, fc.SHA256[:12]) + } + 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 -file . +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) + 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) + 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 \ No newline at end of file diff --git a/internal/lkgateway/runtimeconfig.go b/internal/lkgateway/runtimeconfig.go index 0e889bb..17e6ba4 100644 --- a/internal/lkgateway/runtimeconfig.go +++ b/internal/lkgateway/runtimeconfig.go @@ -24,10 +24,35 @@ type Settings struct { Crypto CryptoSettings `json:"crypto"` NSD NSDSettings `json:"nsd"` LK LKSettings `json:"lk"` + CACerts CACertsSettings `json:"ca_certs"` LastTest *TestRunResult `json:"last_test,omitempty"` UpdatedAt time.Time `json:"updated_at"` } +// CACertsSettings — URL'ы для авто-загрузки сертификатов УЦ НРД и нашего +// УЦ. Список редактируется пользователем; раз в сутки фоновая горутина +// перекачивает каждый URL и переустанавливает сертификат, если он +// поменялся. Все сертификаты идут в mroot/uRoot хранилища КриптоПро. +type CACertsSettings struct { + URLs []string `json:"urls"` + AutoUpdate bool `json:"auto_update"` + LastFetch time.Time `json:"last_fetch"` + LastFetchLog string `json:"last_fetch_log"` + FetchedCerts []FetchedCACert `json:"fetched_certs"` +} + +// FetchedCACert — информация о последнем удачно скачанном сертификате. +type FetchedCACert struct { + URL string `json:"url"` + SHA256 string `json:"sha256"` + SubjectCN string `json:"subject_cn"` + IssuerCN string `json:"issuer_cn"` + NotAfter time.Time `json:"not_after"` + Store string `json:"store"` + FetchedAt time.Time `json:"fetched_at"` + Error string `json:"error,omitempty"` +} + // PostgresSettings — DSN для подключения к БД (M2-шаг-3). type PostgresSettings struct { DSN string `json:"dsn"` @@ -126,6 +151,15 @@ func (r *RuntimeConfig) UpdateNSD(s NSDSettings) error { } // UpdateLK сохраняет LK callback URL. +// UpdateCACerts сохраняет настройки авто-загрузки сертификатов УЦ. +func (r *RuntimeConfig) UpdateCACerts(s CACertsSettings) error { + r.mu.Lock() + r.data.CACerts = s + r.data.UpdatedAt = time.Now() + r.mu.Unlock() + return r.save() +} + func (r *RuntimeConfig) UpdateLK(s LKSettings) error { r.mu.Lock() r.data.LK = s diff --git a/internal/lkgateway/server.go b/internal/lkgateway/server.go index c283b49..04ba901 100644 --- a/internal/lkgateway/server.go +++ b/internal/lkgateway/server.go @@ -159,6 +159,10 @@ func (s *Server) Mux() http.Handler { return s.mux } func (s *Server) Run(ctx context.Context) error { go s.consumeDecisions(ctx) + // Авто-обновление сертификатов УЦ раз в сутки (если оператор включил). + stopCACerts := StartCACertsAutoUpdater(s.rc) + defer stopCACerts() + errCh := make(chan error, 1) go func() { log.Printf("lk-gateway: listen %s", s.cfg.Addr) diff --git a/internal/lkgateway/setup.go b/internal/lkgateway/setup.go index 17d954b..205b979 100644 --- a/internal/lkgateway/setup.go +++ b/internal/lkgateway/setup.go @@ -70,6 +70,10 @@ func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service mux.HandleFunc("/admin/setup/lk", h.saveLK) mux.HandleFunc("/admin/setup/test-run", h.testRun) + // Авто-загрузка сертификатов УЦ НРД и нашего УЦ. + mux.HandleFunc("/admin/setup/cacerts", h.saveCACerts) + mux.HandleFunc("/admin/setup/cacerts/fetch", h.fetchCACertsNow) + // Пошаговый мастер настройки для нетехнических пользователей. mux.HandleFunc("/admin/wizard", h.renderWizard) } diff --git a/internal/lkgateway/web/templates/admin_setup.html b/internal/lkgateway/web/templates/admin_setup.html index 1a247f7..4f0a9a3 100644 --- a/internal/lkgateway/web/templates/admin_setup.html +++ b/internal/lkgateway/web/templates/admin_setup.html @@ -147,6 +147,53 @@ + +
+

Сертификаты УЦ (НРД и др.) — авто-загрузка

+

Прямые URL .cer-файлов УЦ НРД (см. nsd.ru/workflow/system/cryptography/) и других УЦ. Каждый URL скачивается, парсится X.509, и автоматически импортируется в КриптоПро (mroot для корневых, uRoot для промежуточных). Включите авто-обновление — раз в сутки система перепроверит и переустановит, если сертификат изменился.

+
+ + + +
+ +
+
+
+ + {{if not .Settings.CACerts.LastFetch.IsZero}} + Последнее обновление: {{.Settings.CACerts.LastFetch.Format "02.01.2006 15:04:05"}} + {{end}} +
+ {{if .Settings.CACerts.FetchedCerts}} + + + + {{range .Settings.CACerts.FetchedCerts}} + + + + + + + + + {{end}} + +
URLВладелецХранилищеДействителен доSHA-256Статус
{{.URL}}{{.SubjectCN}}{{.Store}}{{if not .NotAfter.IsZero}}{{.NotAfter.Format "02.01.2006"}}{{end}}{{if .SHA256}}{{slice .SHA256 0 12}}…{{end}}{{if .Error}}ошибка{{else}}ок{{end}}
+ {{end}} + {{if .Settings.CACerts.LastFetchLog}} +
+ Лог последнего обновления +
{{.Settings.CACerts.LastFetchLog}}
+
+ {{end}} +
+

Интеграционный шлюз НРД (ИШ)

diff --git a/internal/lkgateway/web/templates/admin_wizard.html b/internal/lkgateway/web/templates/admin_wizard.html index c6ea97e..a96c948 100644 --- a/internal/lkgateway/web/templates/admin_wizard.html +++ b/internal/lkgateway/web/templates/admin_wizard.html @@ -181,6 +181,24 @@ +

Авто-загрузка сертификатов УЦ НРД

+

Самый простой способ — добавить прямые URL .cer-файлов УЦ НРД (с nsd.ru/workflow/system/cryptography/) и включить авто-обновление. Раз в сутки система перепроверит и переустановит изменённые сертификаты.

+
+ + +
+ +
+
+
+ + {{if not .Settings.CACerts.LastFetch.IsZero}}Последнее обновление: {{.Settings.CACerts.LastFetch.Format "02.01.2006 15:04:05"}}{{end}} +
+ {{if .Certs}}

Установленные сертификаты ({{len .Certs}})