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