Netpulse_SasS/server/internal/httpapi/compliance.go
byrsapty 3b9217de3c
All checks were successful
CI / hygiene (push) Successful in 9s
CI / web (push) Successful in 1m5s
CI / server (push) Successful in 2m17s
CI / agent (push) Successful in 2m53s
Дві шкали серйозності: тригер відповідності фільтрував навпаки
Подія відповідності несла шкалу critical/high/medium, а поріг тригера
порівнювався шкалою алертів, де SeverityRank("critical") = 0. Тригер із
порогом «warning» пропускав середнє й відкидав найважче — тихо.

Подія тепер народжується в шкалі алертів; умова тригера приймає обидві.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 14:10:49 +03:00

345 lines
16 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 httpapi
import (
"encoding/json"
"net/http"
"strings"
"time"
"github.com/netpulse/netpulse/server/internal/alerting"
"github.com/netpulse/netpulse/server/internal/store"
)
// Відповідність конфігів вимогам.
//
// Перевірка читає вже зібрані конфіги й не створює жодної сесії до
// заліза — тому прогін можна запускати скільки завгодно, і він нічого не
// коштує мережі. Через це ж він і синхронний: чекати на нього довелося б
// секунди, а не хвилини.
func (s *Server) handleListComplianceRules(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "ncm:read") {
return
}
rules, err := s.store.ListComplianceRules(r.Context(), p.TenantID)
if err != nil {
s.writeStoreError(w, "правила відповідності", err)
return
}
// Лічильники «стільки з стількох не пройшли» рахуються ТУТ, із тих
// самих рядків, які людина побачить у «Знахідках», і після відсіву
// за видимістю.
//
// ПРИЧИНА: раніше їх давали два count(*) у запиті правил — по всіх
// хостах кабінету, без Scope. Інженер філії читав у списку «5 з 12
// не пройшли», відкривав знахідки й бачив дві. Гірше за розбіжність
// те, що агрегат сам по собі розповідав про хости, яких людині
// бачити не належить.
//
// НАСЛІДОК: два числа не можуть розійтись у принципі — це одне
// число, пораховане один раз.
results, err := s.visibleComplianceResults(r, p, false)
if err != nil {
s.writeStoreError(w, "результати перевірки", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"rules": store.ApplyComplianceCounts(rules, results),
})
}
// visibleComplianceResults — знахідки, з яких прибрано хости поза
// видимістю людини.
//
// Одним місцем на всі три ручки (список правил, знахідки, звіт)
// навмисно: три копії цього циклу розійшлися б на першій правці, і
// розійшлись би тихо — кожне з чисел окремо виглядає правдоподібно.
func (s *Server) visibleComplianceResults(r *http.Request, p *Principal, onlyFailed bool) ([]store.ComplianceResult, error) {
res, err := s.store.ListComplianceResults(r.Context(), p.TenantID, onlyFailed)
if err != nil {
return nil, err
}
// Хости поза видимістю людини не показуємо: перевірка конфігів не
// має бути обхідним шляхом до інвентарю чужої філії.
scope := p.Scope()
out := make([]store.ComplianceResult, 0, len(res))
for _, x := range res {
if scope.CanRead(x.DeviceID) {
out = append(out, x)
}
}
return out, nil
}
func (s *Server) handleSaveComplianceRule(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "ncm:write") {
return
}
var in store.ComplianceRule
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "bad_json", "не вдалося прочитати тіло запиту")
return
}
in.ID = r.PathValue("id")
in.Name = strings.TrimSpace(in.Name)
if in.Name == "" {
writeError(w, http.StatusBadRequest, "bad_request", "правило без назви")
return
}
if in.Severity == "" {
in.Severity = "medium"
}
// Типово — конфіг заліза. Правило без явного типу писали під нього,
// і мовчки перевіряти ним набір файлів сервера означало б дати
// впевнену неправильну відповідь замість жодної.
if in.ConfigType == "" {
in.ConfigType = "running"
}
id, err := s.store.SaveComplianceRule(r.Context(), p.TenantID, in)
if err != nil {
s.writeStoreError(w, "правило відповідності", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"id": id})
}
func (s *Server) handleDeleteComplianceRule(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "ncm:write") {
return
}
if err := s.store.DeleteComplianceRule(r.Context(), p.TenantID, r.PathValue("id")); err != nil {
s.writeStoreError(w, "правило відповідності", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleListComplianceResults(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "ncm:read") {
return
}
onlyFailed := r.URL.Query().Get("failed") == "1"
out, err := s.visibleComplianceResults(r, p, onlyFailed)
if err != nil {
s.writeStoreError(w, "результати перевірки", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"results": out})
}
func (s *Server) handleRunCompliance(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "ncm:read") {
return
}
if s.keyring == nil {
writeError(w, http.StatusServiceUnavailable, "no_keyring",
"сервер запущено без ключа шифрування — тіла конфігів не прочитати")
return
}
stat, err := s.store.RunCompliance(r.Context(), p.TenantID, s.keyring)
if err != nil {
s.writeStoreError(w, "перевірка відповідності", err)
return
}
// Прогін — єдина мить, коли відомі обидві половини правди:
// які хости порушили вимогу й на яких вона більше не порушується.
// Тому тригери джерела `compliance` обробляються рівно тут, а не
// окремим опитуванням таблиці результатів, яке однаково не змогло б
// відрізнити «стало погано» від «було погано вчора».
if s.events != nil && len(stat.Findings) > 0 {
evs := make([]alerting.ComplianceEvent, 0, len(stat.Findings))
for _, f := range stat.Findings {
evs = append(evs, alerting.ComplianceEvent{
RuleID: f.RuleID, RuleName: f.RuleName,
// Алерт народжується зі шкалою АЛЕРТІВ, а не
// відповідності: далі його читають маршрути, пороги
// каналів і драбини ескалації — усе, що знає лише
// info/warning/average/high/disaster.
Severity: store.ComplianceSeverityToAlert(f.Severity),
DeviceID: f.DeviceID, Passed: f.Passed,
Line: f.Line, LineNumber: f.LineNumber,
})
}
s.events.OnCompliance(r.Context(), p.TenantID, evs)
}
writeJSON(w, http.StatusOK, stat)
}
// ---------------------------------------------------------------------
// Перевірка зразка на справжньому конфізі
// ---------------------------------------------------------------------
//
// Окремі ручки, а не поле у відповіді збереження: зразок перевіряють ДО
// збереження й багато разів поспіль — по разу на кожну правку. Змусити
// зберігати правило, щоб побачити наслідок, означало б не лише
// застосувати неперевірений зразок до всього парку, а й СТЕРТИ картину
// попереднього прогону (SaveComplianceRule чистить результати правила).
// Тобто ціна однієї спроби «а якщо так?» була б у втраченій відповіді на
// питання «а як зараз».
//
// Взірець — POST /api/v1/templates/interface-preview: та сама думка про
// зразок, застосований наосліп.
// handleComplianceProbeDevices — на чому можна перевірити зразок.
func (s *Server) handleComplianceProbeDevices(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "ncm:read") {
return
}
configType := r.URL.Query().Get("config_type")
list, err := s.store.ListComplianceProbeDevices(r.Context(), p.TenantID, configType)
if err != nil {
s.writeStoreError(w, "хости з конфігами", err)
return
}
// Хости поза видимістю прибираємо тут, а не в SQL: правило доступу
// живе в Scope, і другий його примірник у запиті розійшовся б із
// першим на найближчій правці.
scope := p.Scope()
out := make([]store.ComplianceProbeDevice, 0, len(list))
for _, d := range list {
if scope.CanRead(d.ID) {
out = append(out, d)
}
}
// Вендори тією ж ручкою: селектор вендорів і вибір хоста для
// перевірки — одне питання «що в мене взагалі стоїть», і зібрані
// двома запитами в різні секунди відповіді суперечили б одна одній.
// Scope їде в запит: перелік вендорів — агрегат про склад парку, і
// зібраний по всьому кабінету він розповів би інженерові філії про
// залізо, якого йому бачити не належить.
vendors, err := s.store.ListComplianceVendors(r.Context(), p.TenantID, scope)
if err != nil {
s.writeStoreError(w, "вендори", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"devices": out, "vendors": vendors})
}
// handleComplianceProbe проганяє незбережений зразок по одному хосту.
func (s *Server) handleComplianceProbe(w http.ResponseWriter, r *http.Request, p *Principal) {
// ncm:read, а не ncm:write. Перевірка нічого не змінює, і вимагати
// на неї право запису означало б, що людина без такого права бачить
// у звіті зразки правил і не має способу зрозуміти, що вони роблять.
if !requirePerm(w, p, "ncm:read") {
return
}
if s.keyring == nil {
writeError(w, http.StatusServiceUnavailable, "no_keyring",
"сервер запущено без ключа шифрування — тіла конфігів не прочитати")
return
}
var in struct {
DeviceID string `json:"device_id"`
Rule store.ComplianceRule `json:"rule"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "bad_json", "не вдалося прочитати тіло запиту")
return
}
if in.DeviceID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "не вказано хост для перевірки")
return
}
if !p.Scope().CanRead(in.DeviceID) {
writeError(w, http.StatusForbidden, "forbidden", "немає доступу до цього хоста")
return
}
if in.Rule.Severity == "" {
in.Rule.Severity = "medium"
}
if in.Rule.ConfigType == "" {
in.Rule.ConfigType = "running"
}
// Помилку в зразку віддаємо як 400 з текстом RE2: людина зараз
// дивиться на поле, у якому її зробила, і «внутрішня помилка»
// відправила б її шукати проблему деінде.
res, err := s.store.ProbeComplianceRule(r.Context(), p.TenantID, in.DeviceID, in.Rule, s.keyring)
if err != nil {
s.writeStoreError(w, "перевірка зразка правила", err)
return
}
writeJSON(w, http.StatusOK, res)
}
// ---------------------------------------------------------------------
// Звіт
// ---------------------------------------------------------------------
// complianceReport збирає звіт із того, що видно ЦІЙ людині.
//
// Спільний для JSON і CSV навмисно: два описи одного звіту розійшлися б
// на першій правці, і сторінка почала б показувати не те, що лежить у
// вивантаженому файлі. А звіряють їх саме так — файл проти екрана.
func (s *Server) complianceReport(r *http.Request, p *Principal) (store.ComplianceReport, error) {
rules, err := s.store.ListComplianceRules(r.Context(), p.TenantID)
if err != nil {
return store.ComplianceReport{}, err
}
// Усі результати, і пройдені теж: «18 порушень» без знаменника не
// означає нічого — це 18 із 28 чи 18 із 2800.
visible, err := s.visibleComplianceResults(r, p, false)
if err != nil {
return store.ComplianceReport{}, err
}
scope := p.Scope()
who := p.Username
if who == "" {
who = p.TokenName
}
// Обмежена видимість їде у звіт полем, а не примітками в коді.
// Інженер філії вивантажить правду про свої хости — і документ, який
// виглядає як правда про всю мережу.
return store.ComplianceReportOf(time.Now(), who, !scope.Unrestricted, rules, visible), nil
}
func (s *Server) handleComplianceReport(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "ncm:read") {
return
}
rep, err := s.complianceReport(r, p)
if err != nil {
s.writeStoreError(w, "звіт відповідності", err)
return
}
writeJSON(w, http.StatusOK, rep)
}
// handleComplianceReportCSV — той самий звіт вивантаженням.
//
// Окремий обробник, а не параметр формату в попередньому: відповідь тут
// не JSON, і плутати два типи вмісту в одному шляху означає, що клієнт
// має вгадувати, як її читати. Квиток завантаження (0038) не потрібен:
// звіт — це десятки кілобайтів, а не десятки мегабайтів.
//
// PDF тут немає, і це рішення, а не недоробка. Пояснення — у
// web/src/pages/CompliancePage.tsx поруч із кнопкою, бо читає його той,
// хто цю кнопку шукає.
func (s *Server) handleComplianceReportCSV(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "ncm:read") {
return
}
rep, err := s.complianceReport(r, p)
if err != nil {
s.writeStoreError(w, "звіт відповідності", err)
return
}
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition",
`attachment; filename="`+store.ComplianceReportFileName(rep)+`"`)
// Кешувати не можна: за тим самим шляхом наступного разу лежатиме
// звіт про інший стан мережі, а розрізнити їх кешу нема чим.
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
if err := store.ComplianceReportCSV(w, rep); err != nil {
// Відповідь уже пішла — статус не змінити. Лишається журнал.
s.log.Error("вивантаження звіту відповідності", "err", err)
}
}