Відповідність конфігів вимогам: правила, прогін і знахідки
Чотири види правил над зібраними конфігами — має містити, не має містити, збіг за виразом, немає збігу. jsonpath зі схеми свідомо не реалізовано: він для конфігів у JSON, а писати його без жодного такого пристрою під рукою означало б писати навмання. Перевірка читає вже зібране й не створює сесій до заліза, тому прогін синхронний і безкоштовний для мережі. Хост без конфігу пропускається, а не рахується проваленим: «ще не збирали» і «не відповідає» — різні речі, і плутати їх означає ховати справжні знахідки. Знахідка показує рядок і його номер. Для правил «має бути» рядка немає, і таблиця так і пише: нічого — саме це й проблема. Вираз компілюється при збереженні, а не під час перевірки, інакше про друкарську помилку дізнаються з правила, яке мовчки нічого не знаходить. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
4d1fd40e91
commit
3124fe3163
10 changed files with 1130 additions and 1 deletions
63
HISTORY.md
63
HISTORY.md
|
|
@ -2635,3 +2635,66 @@ SQL_ASCII не кодування, а його відсутність: серв
|
|||
кодування SQL_ASCII → міграція зупиняється з поясненням;
|
||||
свіжа UTF8 → 26 міграцій
|
||||
```
|
||||
|
||||
## Відповідність конфігів вимогам
|
||||
|
||||
### Що це відповідає
|
||||
|
||||
Питання, яке в мережі задають постійно, а перевіряють руками раз на рік:
|
||||
«чи на всіх комутаторах закритий telnet», «чи скрізь наш NTP», «чи не
|
||||
лишився десь community public». Руками це знаходить половину.
|
||||
|
||||
Чотири види правил: `має містити`, `не має містити`, `збіг за виразом`,
|
||||
`немає збігу`. П'ятий із схеми — `jsonpath` — свідомо не реалізовано й не
|
||||
показано в інтерфейсі: він потрібен лише для конфігів у JSON, а тягнути
|
||||
заради цього залежність без жодного пристрою під рукою означало б
|
||||
писати код навмання.
|
||||
|
||||
### Перевірка нічого не коштує мережі
|
||||
|
||||
Читаються вже зібрані конфіги, жодної сесії до заліза не створюється.
|
||||
Через це прогін і синхронний, і кнопка «Перевірити» стоїть без
|
||||
попереджень: її можна тиснути скільки завгодно й одразу після правки
|
||||
правила.
|
||||
|
||||
Хост без жодного зібраного конфігу **пропускається**, а не рахується
|
||||
проваленим: «ще не збирали» і «не відповідає вимозі» — різні речі, і
|
||||
плутати їх означає ховати справжні знахідки серед шуму. Пропущені
|
||||
рахуються окремо.
|
||||
|
||||
### Порядково, а не по всьому тексту
|
||||
|
||||
Знахідку треба показати людині, і «рядок 412» — відповідь, з якою можна
|
||||
щось зробити, на відміну від «десь у конфігу є». Для правил «має бути»
|
||||
рядок навпаки порожній, і таблиця чесно пише: «нічого — саме це й
|
||||
проблема».
|
||||
|
||||
### Дрібниці, які легко проґавити
|
||||
|
||||
Регулярний вираз компілюється при **збереженні**, а не під час
|
||||
перевірки: інакше про друкарську помилку людина дізнається не з форми, а
|
||||
з правила, яке мовчки нічого не знаходить.
|
||||
|
||||
Зміна зразка стирає старі результати того ж правила: вони відповідали на
|
||||
питання, якого вже ніхто не ставить.
|
||||
|
||||
Результати фільтруються за доступом до хостів — перевірка конфігів не
|
||||
має стати обхідним шляхом до інвентарю чужої філії.
|
||||
|
||||
### Перевірено наскрізно
|
||||
|
||||
На справжньому конфігу stand-host (`cat /etc/os-release /etc/hostname`):
|
||||
|
||||
```
|
||||
має містити ID=debian → пройдено, рядок 7
|
||||
не має містити transport input → пройдено (у конфігу такого немає)
|
||||
має містити ntp server 10.0.0.1 → порушення, рядка немає
|
||||
немає збігу VERSION_ID="13" → порушення, рядок 3
|
||||
|
||||
прогін rules 3, checks 3, failed 1, skipped 9
|
||||
фільтр «лише проблеми» лишає саме знахідки
|
||||
валідація невідомий вид → 400; зламаний вираз → 400 з текстом
|
||||
помилки; порожній зразок → 400
|
||||
інтерфейс вкладка поруч із «Конфіги», обидві таблиці з живими
|
||||
даними, «нічого — саме це й проблема» на місці
|
||||
```
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ API віддає готове полотно з живими статусами,
|
|||
| Автовиявлення LLDP/CDP/ARP/FDB | ✅ | ✅ |
|
||||
| **Користувачі, ролі, вхід** | ✅ | ✅ |
|
||||
| **Шаблони опитування** | ✅ | ⚠️ є; немає автопризначення за sysObjectID, snmp.walk, тригерів у шаблоні |
|
||||
| **NCM (збір конфігів)** | ✅ | ⚠️ збір, розклад, Git і syslog є; немає відкату й compliance |
|
||||
| **NCM (збір конфігів)** | ✅ | ⚠️ збір, розклад, Git, syslog і compliance є; немає відкату |
|
||||
| **Керування зондом із UI** | ✅ | ✅ |
|
||||
| **Алерти й сповіщення** | ✅ | ✅ |
|
||||
| **Мобільна адаптивність, PWA** | — | ⚠️ адаптив є, PWA немає |
|
||||
|
|
|
|||
106
server/internal/httpapi/compliance.go
Normal file
106
server/internal/httpapi/compliance.go
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"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
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"rules": rules})
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
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"
|
||||
res, err := s.store.ListComplianceResults(r.Context(), p.TenantID, onlyFailed)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "результати перевірки", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Хости поза видимістю людини не показуємо: перевірка конфігів не
|
||||
// має бути обхідним шляхом до інвентарю чужої філії.
|
||||
scope := p.Scope()
|
||||
out := make([]store.ComplianceResult, 0, len(res))
|
||||
for _, x := range res {
|
||||
if scope.CanRead(x.DeviceID) {
|
||||
out = append(out, x)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
writeJSON(w, http.StatusOK, stat)
|
||||
}
|
||||
|
|
@ -121,6 +121,13 @@ func (s *Server) Handler() http.Handler {
|
|||
mux.Handle("GET /api/v1/devices/{id}/backup-policy", s.authenticated(s.handleGetBackupPolicy))
|
||||
mux.Handle("PUT /api/v1/devices/{id}/backup-policy", s.authenticated(s.handleSetBackupPolicy))
|
||||
mux.Handle("GET /api/v1/ncm-profiles", s.authenticated(s.handleListProfiles))
|
||||
mux.Handle("GET /api/v1/ncm/compliance/rules", s.authenticated(s.handleListComplianceRules))
|
||||
mux.Handle("POST /api/v1/ncm/compliance/rules", s.authenticated(s.handleSaveComplianceRule))
|
||||
mux.Handle("PUT /api/v1/ncm/compliance/rules/{id}", s.authenticated(s.handleSaveComplianceRule))
|
||||
mux.Handle("DELETE /api/v1/ncm/compliance/rules/{id}", s.authenticated(s.handleDeleteComplianceRule))
|
||||
mux.Handle("GET /api/v1/ncm/compliance/results", s.authenticated(s.handleListComplianceResults))
|
||||
mux.Handle("POST /api/v1/ncm/compliance/run", s.authenticated(s.handleRunCompliance))
|
||||
|
||||
mux.Handle("GET /api/v1/ncm/backup-defaults", s.authenticated(s.handleGetBackupDefaults))
|
||||
mux.Handle("PUT /api/v1/ncm/backup-defaults", s.authenticated(s.handleSetBackupDefaults))
|
||||
|
||||
|
|
|
|||
394
server/internal/store/ncm_compliance.go
Normal file
394
server/internal/store/ncm_compliance.go
Normal file
|
|
@ -0,0 +1,394 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/netpulse/netpulse/server/internal/crypto"
|
||||
)
|
||||
|
||||
// ComplianceRule — вимога до конфігурації пристрою.
|
||||
//
|
||||
// Відповідає на питання, яке в мережі задають постійно й перевіряють
|
||||
// руками: «чи на всіх комутаторах закритий telnet», «чи скрізь наш NTP»,
|
||||
// «чи не лишився десь community public». Руками це роблять раз на рік і
|
||||
// знаходять половину.
|
||||
type ComplianceRule struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
Pattern string `json:"pattern"`
|
||||
Severity string `json:"severity"`
|
||||
Selector Selector `json:"selector"`
|
||||
Remediation string `json:"remediation,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
|
||||
// Скільки хостів під правилом не пройшли перевірку — для списку.
|
||||
Failed int `json:"failed"`
|
||||
Passed int `json:"passed"`
|
||||
}
|
||||
|
||||
// ComplianceResult — результат однієї перевірки на одному хості.
|
||||
type ComplianceResult struct {
|
||||
RuleID string `json:"rule_id"`
|
||||
RuleName string `json:"rule_name"`
|
||||
Severity string `json:"severity"`
|
||||
DeviceID string `json:"device_id"`
|
||||
DeviceName string `json:"device_name"`
|
||||
ConfigID string `json:"config_id,omitempty"`
|
||||
Passed bool `json:"passed"`
|
||||
// Рядок конфігу, який спричинив висновок, і його номер. Для правил
|
||||
// «не має бути» це знахідка, для «має бути» — порожньо: показувати
|
||||
// нічого, бо саме відсутність і є проблемою.
|
||||
Line string `json:"line,omitempty"`
|
||||
LineNumber int `json:"line_number,omitempty"`
|
||||
CheckedAt time.Time `json:"checked_at"`
|
||||
}
|
||||
|
||||
var complianceKinds = map[string]bool{
|
||||
"must_contain": true, "must_not_contain": true,
|
||||
"regex_match": true, "regex_absent": true,
|
||||
}
|
||||
|
||||
// ListComplianceRules — правила разом із підсумком останньої перевірки.
|
||||
func (s *Store) ListComplianceRules(ctx context.Context, tenantID string) ([]ComplianceRule, error) {
|
||||
out := []ComplianceRule{}
|
||||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT r.id::text, r.name, COALESCE(r.description,''), r.kind::text,
|
||||
r.pattern, r.severity::text, r.selector::text,
|
||||
COALESCE(r.remediation,''), r.enabled,
|
||||
(SELECT count(*) FROM ncm.compliance_results x
|
||||
WHERE x.rule_id = r.id AND NOT x.passed),
|
||||
(SELECT count(*) FROM ncm.compliance_results x
|
||||
WHERE x.rule_id = r.id AND x.passed)
|
||||
FROM ncm.compliance_rules r
|
||||
WHERE r.tenant_id = $1
|
||||
ORDER BY r.name
|
||||
`, tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var r ComplianceRule
|
||||
var sel string
|
||||
if err := rows.Scan(&r.ID, &r.Name, &r.Description, &r.Kind, &r.Pattern,
|
||||
&r.Severity, &sel, &r.Remediation, &r.Enabled,
|
||||
&r.Failed, &r.Passed); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := json.Unmarshal([]byte(sel), &r.Selector); err != nil {
|
||||
return fmt.Errorf("правило %s: selector: %w", r.Name, err)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return rows.Err()
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// SaveComplianceRule створює або оновлює правило.
|
||||
func (s *Store) SaveComplianceRule(ctx context.Context, tenantID string, r ComplianceRule) (string, error) {
|
||||
if !complianceKinds[r.Kind] {
|
||||
return "", fmt.Errorf("%w: невідомий вид правила %q", ErrInvalid, r.Kind)
|
||||
}
|
||||
if strings.TrimSpace(r.Pattern) == "" {
|
||||
return "", fmt.Errorf("%w: правило без зразка нічого не перевіряє", ErrInvalid)
|
||||
}
|
||||
// Регулярний вираз компілюємо тут, а не під час перевірки: інакше
|
||||
// про друкарську помилку людина дізнається не з форми, а з правила,
|
||||
// яке мовчки нічого не знаходить.
|
||||
if r.Kind == "regex_match" || r.Kind == "regex_absent" {
|
||||
if _, err := regexp.Compile(r.Pattern); err != nil {
|
||||
return "", fmt.Errorf("%w: зразок не компілюється: %v", ErrInvalid, err)
|
||||
}
|
||||
}
|
||||
|
||||
sel, err := json.Marshal(r.Selector)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var id string
|
||||
err = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
if r.ID == "" {
|
||||
return tx.QueryRow(ctx, `
|
||||
INSERT INTO ncm.compliance_rules
|
||||
(tenant_id, name, description, kind, pattern, severity,
|
||||
selector, remediation, enabled)
|
||||
VALUES ($1,$2,NULLIF($3,''),$4::ncm.rule_kind,$5,$6::ncm.rule_severity,
|
||||
$7::jsonb,NULLIF($8,''),$9)
|
||||
RETURNING id::text
|
||||
`, tenantID, r.Name, r.Description, r.Kind, r.Pattern, r.Severity,
|
||||
string(sel), r.Remediation, r.Enabled).Scan(&id)
|
||||
}
|
||||
id = r.ID
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE ncm.compliance_rules SET
|
||||
name = $3, description = NULLIF($4,''), kind = $5::ncm.rule_kind,
|
||||
pattern = $6, severity = $7::ncm.rule_severity, selector = $8::jsonb,
|
||||
remediation = NULLIF($9,''), enabled = $10
|
||||
WHERE id = $1 AND tenant_id = $2
|
||||
`, r.ID, tenantID, r.Name, r.Description, r.Kind, r.Pattern, r.Severity,
|
||||
string(sel), r.Remediation, r.Enabled)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
// Зразок міг змінитись — старі результати більше ні про що не
|
||||
// свідчать, і лишати їх означає показувати відповідь на питання,
|
||||
// якого вже ніхто не ставив.
|
||||
_, err = tx.Exec(ctx, `DELETE FROM ncm.compliance_results WHERE rule_id = $1`, r.ID)
|
||||
return err
|
||||
})
|
||||
return id, err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteComplianceRule(ctx context.Context, tenantID, id string) error {
|
||||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
tag, err := tx.Exec(ctx,
|
||||
`DELETE FROM ncm.compliance_rules WHERE id = $1 AND tenant_id = $2`, id, tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// ListComplianceResults — знахідки, спершу провалені й найважчі.
|
||||
func (s *Store) ListComplianceResults(ctx context.Context, tenantID string, onlyFailed bool) ([]ComplianceResult, error) {
|
||||
out := []ComplianceResult{}
|
||||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT r.rule_id::text, cr.name, cr.severity::text,
|
||||
r.device_id::text, d.name, COALESCE(r.config_id::text,''),
|
||||
r.passed, COALESCE(r.details->>'line',''),
|
||||
COALESCE((r.details->>'line_number')::int, 0),
|
||||
r.checked_at
|
||||
FROM ncm.compliance_results r
|
||||
JOIN ncm.compliance_rules cr ON cr.id = r.rule_id
|
||||
JOIN inv.devices d ON d.id = r.device_id
|
||||
WHERE r.tenant_id = $1 AND ($2 = false OR NOT r.passed)
|
||||
ORDER BY r.passed,
|
||||
array_position(ARRAY['critical','high','medium','low','info'],
|
||||
cr.severity::text),
|
||||
d.name
|
||||
`, tenantID, onlyFailed)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var x ComplianceResult
|
||||
if err := rows.Scan(&x.RuleID, &x.RuleName, &x.Severity, &x.DeviceID,
|
||||
&x.DeviceName, &x.ConfigID, &x.Passed, &x.Line, &x.LineNumber,
|
||||
&x.CheckedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
out = append(out, x)
|
||||
}
|
||||
return rows.Err()
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// ComplianceStat — підсумок прогону.
|
||||
type ComplianceStat struct {
|
||||
Rules int `json:"rules"`
|
||||
// Checks — скільки пар «правило + хост» справді перевірено. Не число
|
||||
// хостів: одне правило може накрити десять, а десять правил — один.
|
||||
Checks int `json:"checks"`
|
||||
Failed int `json:"failed"`
|
||||
// Skipped — пари, де в хоста ще немає зібраного конфігу.
|
||||
Skipped int `json:"skipped"`
|
||||
}
|
||||
|
||||
// RunCompliance проганяє всі увімкнені правила по останніх конфігах.
|
||||
//
|
||||
// Останній конфіг, а не поточний стан пристрою: перевірка читає те, що
|
||||
// вже зібрано, і не створює жодної сесії до заліза. Тому її можна
|
||||
// запускати скільки завгодно й одразу після зміни правила — на відміну
|
||||
// від опитування, вона нічого не коштує мережі.
|
||||
//
|
||||
// Хост без жодного зібраного конфігу пропускається, а не рахується
|
||||
// проваленим: «ще не збирали» і «не відповідає вимозі» — різні речі, і
|
||||
// плутати їх означає ховати справжні знахідки серед шуму.
|
||||
func (s *Store) RunCompliance(ctx context.Context, tenantID string, ring *crypto.Keyring) (ComplianceStat, error) {
|
||||
var stat ComplianceStat
|
||||
|
||||
rules, err := s.ListComplianceRules(ctx, tenantID)
|
||||
if err != nil {
|
||||
return stat, err
|
||||
}
|
||||
|
||||
for _, r := range rules {
|
||||
if !r.Enabled {
|
||||
continue
|
||||
}
|
||||
stat.Rules++
|
||||
|
||||
devices, err := s.complianceTargets(ctx, tenantID, r.Selector)
|
||||
if err != nil {
|
||||
return stat, fmt.Errorf("правило %s: %w", r.Name, err)
|
||||
}
|
||||
|
||||
var re *regexp.Regexp
|
||||
if r.Kind == "regex_match" || r.Kind == "regex_absent" {
|
||||
re, err = regexp.Compile(r.Pattern)
|
||||
if err != nil {
|
||||
// Зразок перевіряється при збереженні, тож сюди можна
|
||||
// потрапити лише після правки в обхід API.
|
||||
return stat, fmt.Errorf("правило %s: %w", r.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, d := range devices {
|
||||
configID, body, err := s.latestConfigBody(ctx, tenantID, d, ring)
|
||||
if err != nil || configID == "" {
|
||||
stat.Skipped++
|
||||
continue
|
||||
}
|
||||
stat.Checks++
|
||||
|
||||
passed, line, num := checkCompliance(r, re, body)
|
||||
if !passed {
|
||||
stat.Failed++
|
||||
}
|
||||
if err := s.saveComplianceResult(ctx, tenantID, r.ID, d, configID, passed, line, num); err != nil {
|
||||
return stat, err
|
||||
}
|
||||
}
|
||||
}
|
||||
return stat, nil
|
||||
}
|
||||
|
||||
// checkCompliance застосовує одне правило до тексту конфігу.
|
||||
//
|
||||
// Порядково, а не по всьому тексту: знахідку треба показати людині, і
|
||||
// «рядок 412» — відповідь, з якою можна щось зробити, на відміну від
|
||||
// «десь у конфігу є».
|
||||
func checkCompliance(r ComplianceRule, re *regexp.Regexp, body string) (bool, string, int) {
|
||||
lines := strings.Split(body, "\n")
|
||||
|
||||
for i, ln := range lines {
|
||||
var hit bool
|
||||
switch r.Kind {
|
||||
case "must_contain", "must_not_contain":
|
||||
hit = strings.Contains(ln, r.Pattern)
|
||||
case "regex_match", "regex_absent":
|
||||
hit = re.MatchString(ln)
|
||||
}
|
||||
if !hit {
|
||||
continue
|
||||
}
|
||||
switch r.Kind {
|
||||
case "must_contain", "regex_match":
|
||||
// Знайшли те, що мало бути — далі шукати нема чого.
|
||||
return true, strings.TrimSpace(ln), i + 1
|
||||
default:
|
||||
// Знайшли те, чого бути не мало — це і є знахідка.
|
||||
return false, strings.TrimSpace(ln), i + 1
|
||||
}
|
||||
}
|
||||
|
||||
// Нічого не знайшли: для «має бути» це провал, для «не має бути» —
|
||||
// успіх.
|
||||
switch r.Kind {
|
||||
case "must_contain", "regex_match":
|
||||
return false, "", 0
|
||||
default:
|
||||
return true, "", 0
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) complianceTargets(ctx context.Context, tenantID string, sel Selector) ([]string, error) {
|
||||
a := &args{}
|
||||
a.add(tenantID)
|
||||
where, err := s.selectorSQL(sel, a, "d.id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var out []string
|
||||
err = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT d.id::text FROM inv.devices d
|
||||
WHERE d.tenant_id = $1 AND d.deleted_at IS NULL
|
||||
`+where+`
|
||||
ORDER BY d.name
|
||||
`, a.vals...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return err
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
return rows.Err()
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (s *Store) latestConfigBody(ctx context.Context, tenantID, deviceID string, ring *crypto.Keyring) (string, string, error) {
|
||||
var configID string
|
||||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
return tx.QueryRow(ctx, `
|
||||
SELECT id::text FROM ncm.configs
|
||||
WHERE device_id = $1 AND tenant_id = $2
|
||||
ORDER BY collected_at DESC LIMIT 1
|
||||
`, deviceID, tenantID).Scan(&configID)
|
||||
})
|
||||
if err != nil {
|
||||
if isNoRows(err) {
|
||||
return "", "", nil
|
||||
}
|
||||
return "", "", err
|
||||
}
|
||||
body, _, err := s.ConfigBody(ctx, tenantID, configID, ring)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return configID, body, nil
|
||||
}
|
||||
|
||||
func (s *Store) saveComplianceResult(
|
||||
ctx context.Context,
|
||||
tenantID, ruleID, deviceID, configID string,
|
||||
passed bool, line string, num int,
|
||||
) error {
|
||||
details, err := json.Marshal(map[string]any{"line": line, "line_number": num})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
_, err := tx.Exec(ctx, `
|
||||
INSERT INTO ncm.compliance_results
|
||||
(tenant_id, rule_id, device_id, config_id, passed, details, checked_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6::jsonb, now())
|
||||
ON CONFLICT (rule_id, device_id) DO UPDATE SET
|
||||
config_id = EXCLUDED.config_id,
|
||||
passed = EXCLUDED.passed,
|
||||
details = EXCLUDED.details,
|
||||
checked_at = now()
|
||||
`, tenantID, ruleID, deviceID, nullUUID(configID), passed, string(details))
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import { CredentialsPage } from './pages/CredentialsPage'
|
|||
import { MetricsPage } from './pages/MetricsPage'
|
||||
import { DashboardPage } from './pages/DashboardPage'
|
||||
import { ConfigsPage } from './pages/ConfigsPage'
|
||||
import { CompliancePage } from './pages/CompliancePage'
|
||||
import { AlertsPage } from './pages/AlertsPage'
|
||||
import { RulesPage } from './pages/RulesPage'
|
||||
import { ChannelsPage } from './pages/ChannelsPage'
|
||||
|
|
@ -85,6 +86,7 @@ function Workspace() {
|
|||
<Route path="/credentials" element={<Guard perm="devices:read" element={<CredentialsPage />} />} />
|
||||
<Route path="/templates" element={<Guard perm="devices:read" element={<TemplatesPage />} />} />
|
||||
<Route path="/configs" element={<Guard perm="ncm:read" element={<ConfigsPage />} />} />
|
||||
<Route path="/compliance" element={<Guard perm="ncm:read" element={<CompliancePage />} />} />
|
||||
<Route path="/alerts" element={<Guard perm="alerts:read" element={<AlertsPage />} />} />
|
||||
<Route path="/rules" element={<Guard perm="alerts:read" element={<RulesPage />} />} />
|
||||
<Route path="/channels" element={<Guard perm="alerts:read" element={<ChannelsPage />} />} />
|
||||
|
|
|
|||
|
|
@ -35,6 +35,9 @@ import type {
|
|||
MetricResult,
|
||||
SeriesInfo,
|
||||
Template,
|
||||
ComplianceResult,
|
||||
ComplianceRule,
|
||||
ComplianceStat,
|
||||
TemplateGraph,
|
||||
TemplateTrigger,
|
||||
} from '../types'
|
||||
|
|
@ -624,6 +627,26 @@ export const api = {
|
|||
setDefaultDashboard: (id: string) =>
|
||||
request<void>(`/api/v1/dashboards/${id}/default`, { method: 'POST' }),
|
||||
|
||||
listComplianceRules: () =>
|
||||
request<{ rules: ComplianceRule[] }>('/api/v1/ncm/compliance/rules').then((r) => r.rules ?? []),
|
||||
|
||||
saveComplianceRule: (id: string | null, rule: Partial<ComplianceRule>) =>
|
||||
request<{ id: string }>(
|
||||
id ? `/api/v1/ncm/compliance/rules/${id}` : '/api/v1/ncm/compliance/rules',
|
||||
{ method: id ? 'PUT' : 'POST', body: JSON.stringify(rule) },
|
||||
),
|
||||
|
||||
deleteComplianceRule: (id: string) =>
|
||||
request<void>(`/api/v1/ncm/compliance/rules/${id}`, { method: 'DELETE' }),
|
||||
|
||||
listComplianceResults: (onlyFailed: boolean) =>
|
||||
request<{ results: ComplianceResult[] }>(
|
||||
`/api/v1/ncm/compliance/results${onlyFailed ? '?failed=1' : ''}`,
|
||||
).then((r) => r.results ?? []),
|
||||
|
||||
runCompliance: () =>
|
||||
request<ComplianceStat>('/api/v1/ncm/compliance/run', { method: 'POST' }),
|
||||
|
||||
createDashboardLink: (id: string) =>
|
||||
request<{ token: string; path: string }>(`/api/v1/dashboards/${id}/public-link`, {
|
||||
method: 'POST',
|
||||
|
|
|
|||
487
web/src/pages/CompliancePage.tsx
Normal file
487
web/src/pages/CompliancePage.tsx
Normal file
|
|
@ -0,0 +1,487 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { api } from '../api/client'
|
||||
import { session } from '../api/session'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Confirm,
|
||||
DataTable,
|
||||
ErrorNote,
|
||||
Field,
|
||||
Modal,
|
||||
PageBody,
|
||||
PageHeader,
|
||||
SectionTabs,
|
||||
Spinner,
|
||||
StatusBadge,
|
||||
Toggle,
|
||||
ago,
|
||||
inputClass,
|
||||
plural,
|
||||
} from '../components/ui'
|
||||
import type { ConfirmRequest } from '../components/ui'
|
||||
import { Picker } from '../components/Picker'
|
||||
import type { ComplianceResult, ComplianceRule, DeviceGroup } from '../types'
|
||||
|
||||
/**
|
||||
* Відповідність конфігів вимогам.
|
||||
*
|
||||
* Питання, яке в мережі задають постійно й перевіряють руками раз на рік:
|
||||
* «чи на всіх комутаторах закритий telnet», «чи скрізь наш NTP», «чи не
|
||||
* лишився десь community public». Руками це знаходить половину.
|
||||
*
|
||||
* Перевірка читає вже зібрані конфіги й не створює жодної сесії до
|
||||
* заліза, тому кнопка «Перевірити» тут без попереджень: вона нічого не
|
||||
* коштує мережі.
|
||||
*/
|
||||
|
||||
const KINDS: { key: string; label: string; hint: string }[] = [
|
||||
{ key: 'must_contain', label: 'має містити', hint: 'Рядок, який мусить бути в конфігу' },
|
||||
{ key: 'must_not_contain', label: 'не має містити', hint: 'Рядок, якого бути не повинно' },
|
||||
{ key: 'regex_match', label: 'збіг за виразом', hint: 'Регулярний вираз, який мусить знайтись' },
|
||||
{ key: 'regex_absent', label: 'немає збігу', hint: 'Регулярний вираз, який не має знайтись' },
|
||||
]
|
||||
|
||||
const SEVERITIES: { key: string; label: string }[] = [
|
||||
{ key: 'critical', label: 'критична' },
|
||||
{ key: 'high', label: 'висока' },
|
||||
{ key: 'medium', label: 'середня' },
|
||||
{ key: 'low', label: 'низька' },
|
||||
{ key: 'info', label: 'інформація' },
|
||||
]
|
||||
|
||||
const kindLabel = Object.fromEntries(KINDS.map((k) => [k.key, k.label]))
|
||||
const sevLabel = Object.fromEntries(SEVERITIES.map((s) => [s.key, s.label]))
|
||||
|
||||
// Важкість тут своя, не алертна: правило відповідності описує ризик, а
|
||||
// не подію. Кольори підібрані так само, щоб очі не перевчались.
|
||||
const sevTone: Record<string, string> = {
|
||||
critical: 'disaster',
|
||||
high: 'high',
|
||||
medium: 'average',
|
||||
low: 'warning',
|
||||
info: 'info',
|
||||
}
|
||||
|
||||
export function CompliancePage() {
|
||||
const canWrite = session.can('ncm:write')
|
||||
|
||||
const [rules, setRules] = useState<ComplianceRule[]>([])
|
||||
const [results, setResults] = useState<ComplianceResult[]>([])
|
||||
const [groups, setGroups] = useState<DeviceGroup[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [running, setRunning] = useState(false)
|
||||
const [onlyFailed, setOnlyFailed] = useState(true)
|
||||
const [editing, setEditing] = useState<ComplianceRule | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
const [confirm, setConfirm] = useState<ConfirmRequest | null>(null)
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
try {
|
||||
const [r, res] = await Promise.all([
|
||||
api.listComplianceRules(),
|
||||
api.listComplianceResults(onlyFailed),
|
||||
])
|
||||
setRules(r)
|
||||
setResults(res)
|
||||
setErr(null)
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [onlyFailed])
|
||||
|
||||
useEffect(() => {
|
||||
void reload()
|
||||
api
|
||||
.listDeviceGroups()
|
||||
.then(setGroups)
|
||||
.catch(() => {})
|
||||
}, [reload])
|
||||
|
||||
async function run() {
|
||||
setRunning(true)
|
||||
setErr(null)
|
||||
try {
|
||||
const st = await api.runCompliance()
|
||||
await reload()
|
||||
setErr(
|
||||
st.rules === 0
|
||||
? 'Увімкнених правил немає — перевіряти нічого'
|
||||
: st.checks === 0
|
||||
? 'Жоден хост під правилами ще не має зібраного конфігу'
|
||||
: null,
|
||||
)
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const failed = results.filter((r) => !r.passed).length
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionTabs
|
||||
tabs={[
|
||||
{ to: '/configs', label: 'Конфіги' },
|
||||
{ to: '/compliance', label: 'Відповідність' },
|
||||
]}
|
||||
/>
|
||||
<PageHeader
|
||||
title="Відповідність конфігів"
|
||||
subtitle={
|
||||
loading
|
||||
? undefined
|
||||
: `${plural(rules.length, 'правило', 'правила', 'правил')} · ` +
|
||||
(failed > 0
|
||||
? `${plural(failed, 'знахідка', 'знахідки', 'знахідок')}`
|
||||
: 'знахідок немає')
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Button disabled={running} onClick={() => void run()}>
|
||||
{running ? 'Перевірка…' : 'Перевірити'}
|
||||
</Button>
|
||||
{canWrite && (
|
||||
<Button kind="primary" onClick={() => setCreating(true)}>
|
||||
+ Правило
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<PageBody className="space-y-4">
|
||||
<ErrorNote>{err}</ErrorNote>
|
||||
|
||||
{loading ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<>
|
||||
<Card>
|
||||
<div className="border-b border-slate-800 px-3 py-2 text-xs font-medium text-slate-400">
|
||||
Правила
|
||||
</div>
|
||||
<DataTable
|
||||
rows={rules}
|
||||
rowKey={(r) => r.id}
|
||||
empty="Правил ще немає — перевіряти конфіги нема на що"
|
||||
columns={[
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Назва',
|
||||
cell: (r) => (
|
||||
<div className="min-w-0">
|
||||
<span className="font-medium text-slate-100">{r.name}</span>
|
||||
{r.description && (
|
||||
<div className="mt-0.5 truncate text-[11px] text-slate-500">
|
||||
{r.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'kind',
|
||||
header: 'Умова',
|
||||
hideOnMobile: true,
|
||||
cell: (r) => (
|
||||
<span className="text-slate-400">
|
||||
{kindLabel[r.kind] ?? r.kind}{' '}
|
||||
<code className="text-[11px] text-slate-500">{r.pattern}</code>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'sev',
|
||||
header: 'Важкість',
|
||||
cell: (r) => (
|
||||
<StatusBadge
|
||||
status={sevTone[r.severity] ?? 'unknown'}
|
||||
label={sevLabel[r.severity] ?? r.severity}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'stat',
|
||||
header: 'Стан',
|
||||
cell: (r) =>
|
||||
r.failed > 0 ? (
|
||||
<span className="text-red-300">
|
||||
{r.failed} з {r.failed + r.passed} не пройшли
|
||||
</span>
|
||||
) : r.passed > 0 ? (
|
||||
<span className="text-emerald-400">усі {r.passed} пройшли</span>
|
||||
) : (
|
||||
<span className="text-slate-600">ще не перевірялось</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'act',
|
||||
header: '',
|
||||
cell: (r) =>
|
||||
canWrite && (
|
||||
<div className="flex gap-1">
|
||||
<Button onClick={() => setEditing(r)}>Змінити</Button>
|
||||
<Button
|
||||
kind="danger"
|
||||
onClick={() =>
|
||||
setConfirm({
|
||||
title: 'Видалити правило',
|
||||
message: (
|
||||
<>
|
||||
Правило <b>{r.name}</b> і його знахідки буде видалено.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Видалити',
|
||||
onConfirm: async () => {
|
||||
await api.deleteComplianceRule(r.id)
|
||||
await reload()
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
Видалити
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="flex items-center justify-between border-b border-slate-800 px-3 py-2">
|
||||
<span className="text-xs font-medium text-slate-400">Знахідки</span>
|
||||
<label className="flex items-center gap-2 text-[11px] text-slate-500">
|
||||
<Toggle checked={onlyFailed} onChange={setOnlyFailed} />
|
||||
лише проблеми
|
||||
</label>
|
||||
</div>
|
||||
<DataTable
|
||||
rows={results}
|
||||
rowKey={(r) => `${r.rule_id}|${r.device_id}`}
|
||||
empty={
|
||||
onlyFailed
|
||||
? 'Порушень немає — усі перевірені конфіги відповідають правилам'
|
||||
: 'Перевірок ще не було'
|
||||
}
|
||||
columns={[
|
||||
{
|
||||
key: 'device',
|
||||
header: 'Хост',
|
||||
cell: (r) => <span className="text-slate-200">{r.device_name}</span>,
|
||||
},
|
||||
{
|
||||
key: 'rule',
|
||||
header: 'Правило',
|
||||
cell: (r) => <span className="text-slate-400">{r.rule_name}</span>,
|
||||
},
|
||||
{
|
||||
key: 'line',
|
||||
header: 'Що знайдено',
|
||||
hideOnMobile: true,
|
||||
cell: (r) =>
|
||||
r.line ? (
|
||||
<code className="text-[11px] text-slate-400">
|
||||
{r.line_number ? `${r.line_number}: ` : ''}
|
||||
{r.line}
|
||||
</code>
|
||||
) : (
|
||||
<span className="text-[11px] text-slate-600">
|
||||
{r.passed ? '—' : 'нічого — саме це й проблема'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'state',
|
||||
header: 'Результат',
|
||||
cell: (r) =>
|
||||
r.passed ? (
|
||||
<span className="text-emerald-400">пройдено</span>
|
||||
) : (
|
||||
<span className="text-red-300">порушення</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'when',
|
||||
header: 'Перевірено',
|
||||
hideOnMobile: true,
|
||||
cell: (r) => <span className="text-slate-600">{ago(r.checked_at)} тому</span>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</PageBody>
|
||||
|
||||
{(creating || editing) && (
|
||||
<RuleForm
|
||||
rule={editing}
|
||||
groups={groups}
|
||||
onClose={() => {
|
||||
setCreating(false)
|
||||
setEditing(null)
|
||||
}}
|
||||
onSaved={() => {
|
||||
setCreating(false)
|
||||
setEditing(null)
|
||||
void reload()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Confirm request={confirm} onClose={() => setConfirm(null)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function RuleForm({
|
||||
rule,
|
||||
groups,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
rule: ComplianceRule | null
|
||||
groups: DeviceGroup[]
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const [name, setName] = useState(rule?.name ?? '')
|
||||
const [description, setDescription] = useState(rule?.description ?? '')
|
||||
const [kind, setKind] = useState(rule?.kind ?? 'must_contain')
|
||||
const [pattern, setPattern] = useState(rule?.pattern ?? '')
|
||||
const [severity, setSeverity] = useState(rule?.severity ?? 'medium')
|
||||
const [remediation, setRemediation] = useState(rule?.remediation ?? '')
|
||||
const [groupIDs, setGroupIDs] = useState<string[]>(rule?.selector?.group_ids ?? [])
|
||||
const [enabled, setEnabled] = useState(rule?.enabled ?? true)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
|
||||
const hint = KINDS.find((k) => k.key === kind)?.hint
|
||||
|
||||
return (
|
||||
<Modal title={rule ? `Правило: ${rule.name}` : 'Нове правило'} onClose={onClose} wide>
|
||||
<div className="space-y-3">
|
||||
<Field label="Назва">
|
||||
<input
|
||||
className={inputClass}
|
||||
value={name}
|
||||
placeholder="Telnet вимкнено"
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Опис" hint="Навіщо ця вимога">
|
||||
<input
|
||||
className={inputClass}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field label="Умова">
|
||||
<select className={inputClass} value={kind} onChange={(e) => setKind(e.target.value)}>
|
||||
{KINDS.map((k) => (
|
||||
<option key={k.key} value={k.key}>
|
||||
{k.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Важкість">
|
||||
<select
|
||||
className={inputClass}
|
||||
value={severity}
|
||||
onChange={(e) => setSeverity(e.target.value)}
|
||||
>
|
||||
{SEVERITIES.map((s) => (
|
||||
<option key={s.key} value={s.key}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label="Зразок" hint={hint}>
|
||||
<input
|
||||
className={`${inputClass} font-mono`}
|
||||
value={pattern}
|
||||
placeholder={kind.startsWith('regex') ? '^\\s*transport input telnet' : 'no ip http server'}
|
||||
onChange={(e) => setPattern(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Групи хостів"
|
||||
hint="Порожньо — правило застосовується до всіх хостів із зібраним конфігом"
|
||||
>
|
||||
<Picker
|
||||
items={groups.map((g) => ({
|
||||
id: g.id,
|
||||
label: g.name,
|
||||
hint: `${g.device_count} хостів`,
|
||||
}))}
|
||||
value={groupIDs}
|
||||
onChange={setGroupIDs}
|
||||
placeholder="усі хости"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Як виправити" hint="Побачить той, хто розбиратиме знахідку">
|
||||
<input
|
||||
className={inputClass}
|
||||
value={remediation}
|
||||
placeholder="line vty 0 4 / transport input ssh"
|
||||
onChange={(e) => setRemediation(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-slate-400">
|
||||
<Toggle checked={enabled} onChange={setEnabled} />
|
||||
Увімкнено
|
||||
</label>
|
||||
|
||||
<ErrorNote>{err}</ErrorNote>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Button onClick={onClose}>Скасувати</Button>
|
||||
<Button
|
||||
kind="primary"
|
||||
disabled={busy || !name.trim() || !pattern.trim()}
|
||||
onClick={async () => {
|
||||
setBusy(true)
|
||||
setErr(null)
|
||||
try {
|
||||
await api.saveComplianceRule(rule?.id ?? null, {
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
kind,
|
||||
pattern,
|
||||
severity,
|
||||
remediation: remediation.trim(),
|
||||
enabled,
|
||||
selector: groupIDs.length > 0 ? { group_ids: groupIDs } : {},
|
||||
})
|
||||
onSaved()
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{busy ? 'Збереження…' : 'Зберегти'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import {
|
|||
DataTable,
|
||||
ErrorNote,
|
||||
PageHeader,
|
||||
SectionTabs,
|
||||
Spinner,
|
||||
StatusBadge,
|
||||
ago,
|
||||
|
|
@ -79,6 +80,12 @@ export function ConfigsPage() {
|
|||
|
||||
return (
|
||||
<>
|
||||
<SectionTabs
|
||||
tabs={[
|
||||
{ to: '/configs', label: 'Конфіги' },
|
||||
{ to: '/compliance', label: 'Відповідність' },
|
||||
]}
|
||||
/>
|
||||
<PageHeader
|
||||
title="Конфіги"
|
||||
subtitle="Історія змін на обладнанні"
|
||||
|
|
|
|||
|
|
@ -671,3 +671,43 @@ export type DeviceGraph = TemplateGraph & {
|
|||
series_ids: number[]
|
||||
}
|
||||
|
||||
// --- відповідність конфігів вимогам ---
|
||||
|
||||
export interface ComplianceRule {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
kind: string
|
||||
pattern: string
|
||||
severity: string
|
||||
selector: { group_ids?: string[]; device_ids?: string[] }
|
||||
remediation?: string
|
||||
enabled: boolean
|
||||
/** Підсумок останньої перевірки. */
|
||||
failed: number
|
||||
passed: number
|
||||
}
|
||||
|
||||
export interface ComplianceResult {
|
||||
rule_id: string
|
||||
rule_name: string
|
||||
severity: string
|
||||
device_id: string
|
||||
device_name: string
|
||||
config_id?: string
|
||||
passed: boolean
|
||||
/** Рядок конфігу, який спричинив висновок. Порожньо — знахідкою є сама відсутність. */
|
||||
line?: string
|
||||
line_number?: number
|
||||
checked_at: string
|
||||
}
|
||||
|
||||
export interface ComplianceStat {
|
||||
rules: number
|
||||
/** Пар «правило + хост», які справді перевірено. */
|
||||
checks: number
|
||||
failed: number
|
||||
/** Пар, де в хоста ще немає зібраного конфігу. */
|
||||
skipped: number
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue