Відповідність конфігів вимогам: правила, прогін і знахідки
Some checks are pending
CI / web (push) Waiting to run
CI / server (push) Waiting to run
CI / agent (push) Waiting to run

Чотири види правил над зібраними конфігами — має містити, не має
містити, збіг за виразом, немає збігу. jsonpath зі схеми свідомо не
реалізовано: він для конфігів у JSON, а писати його без жодного такого
пристрою під рукою означало б писати навмання.

Перевірка читає вже зібране й не створює сесій до заліза, тому прогін
синхронний і безкоштовний для мережі. Хост без конфігу пропускається,
а не рахується проваленим: «ще не збирали» і «не відповідає» — різні
речі, і плутати їх означає ховати справжні знахідки.

Знахідка показує рядок і його номер. Для правил «має бути» рядка немає,
і таблиця так і пише: нічого — саме це й проблема.

Вираз компілюється при збереженні, а не під час перевірки, інакше про
друкарську помилку дізнаються з правила, яке мовчки нічого не знаходить.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
byrsapty 2026-08-25 16:05:07 +03:00
parent 4d1fd40e91
commit 3124fe3163
10 changed files with 1130 additions and 1 deletions

View file

@ -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
інтерфейс вкладка поруч із «Конфіги», обидві таблиці з живими
даними, «нічого — саме це й проблема» на місці
```

View file

@ -24,7 +24,7 @@ API віддає готове полотно з живими статусами,
| Автовиявлення LLDP/CDP/ARP/FDB | ✅ | ✅ |
| **Користувачі, ролі, вхід** | ✅ | ✅ |
| **Шаблони опитування** | ✅ | ⚠️ є; немає автопризначення за sysObjectID, snmp.walk, тригерів у шаблоні |
| **NCM (збір конфігів)** | ✅ | ⚠️ збір, розклад, Git і syslog є; немає відкату й compliance |
| **NCM (збір конфігів)** | ✅ | ⚠️ збір, розклад, Git, syslog і compliance є; немає відкату |
| **Керування зондом із UI** | ✅ | ✅ |
| **Алерти й сповіщення** | ✅ | ✅ |
| **Мобільна адаптивність, PWA** | — | ⚠️ адаптив є, PWA немає |

View 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)
}

View file

@ -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))

View 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
})
}

View file

@ -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 />} />} />

View file

@ -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',

View 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>
)
}

View file

@ -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="Історія змін на обладнанні"

View file

@ -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
}