Подія відповідності несла шкалу critical/high/medium, а поріг тригера
порівнювався шкалою алертів, де SeverityRank("critical") = 0. Тригер із
порогом «warning» пропускав середнє й відкидав найважче — тихо.
Подія тепер народжується в шкалі алертів; умова тригера приймає обидві.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
345 lines
16 KiB
Go
345 lines
16 KiB
Go
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)
|
||
}
|
||
}
|