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/.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/.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") }