Netpulse_SasS/server/internal/store/checks.go
byrsapty ed8fc831bf Дві сесії роботи: 0058–0068, розгортання однією командою, тести
Один коміт, а не десяток тематичних, свідомо: теми переплетені в
спільних файлах (store.go, docker-compose.yml, deploy/README.md), і
розділити їх можна було б лише індексуванням шматків. Коміти, які не
збираються, гірші за один великий — тим паче що це рівно той стан, який
перевірявся разом.

ЩО ПРАЦЮЄ НА СТЕНДІ Й ПЕРЕВІРЕНО ТАМ

  0058  подієві алерти: syslog, ncm, compliance спрацьовують у мить
        події; правило з нереалізованим джерелом більше не зберігається
        мовчки
  0059  snmp.walk і прототипи шаблонів — таблиці з динамічним індексом
        описуються шаблоном, а не Go
  0060  відкат конфігу: план як різниця, маскування паролів із підписом
        плану, обов'язковий контрольний збір, verifying при обриві
  0061  кнопки Telegram: довге опитування, авторизація не з callback_data
  0062  аудит і архів хостів; тест на AST, що падає на ключі без назви
  0063  RLS: три ролі, окремий пул для фонових тактів
  0064  строки зберігання даних і сторінка сховища
  0065  приймач SNMP-трапів; перевірено справжніми пакетами по дроту,
        переклад v1→v2 за RFC 3584 дає правильний OID
  0066  ескалації сповіщень
  0067  алерт про вичерпання диска
  0068  поля заливки конфігу переїхали в каталог профілів

Плюс: 137 тестів вебу з нуля (їх не було взагалі), одинадцять справжніх
вад, знайдених ними й виправлених, і виправлення двох інтеграційних
тестів grpcapi, які мовчки пропускались півтора року.

ЩО ЩЕ НЕ ЗАПУСКАЛОСЬ

  netpulse            установник: одна команда замість 18 змінних і
                      593 рядків інструкції
  RLS з першого запуску  нова інсталяція під політиками одразу;
                      RLS-EXISTING-INSTALL.md лишається тільки для
                      старих інсталяцій
  .forgejo + CI       раннер не зареєстрований

Ці три перевірені компіляцією й міркуванням, але не виконанням.

ГОЛОВНИЙ ВИСНОВОК ДВОХ СЕСІЙ

Зелена перевірка доводить рівно те, що вона перевіряє. Тест ізоляції RLS
був правильний і зелений — і пропустив зламаний вхід, бо перевіряв «чи
не видно чужого», коли зламалось «чи видно своє». Інтеграційні тести
grpcapi були зелені, бо не виконувались. Схема, довідник і протокол
описували те, чого в коді не існувало, і виглядало це як готове.

Тому в кожному завданні цих сесій стояла вимога назвати НЕПОКРИТЕ, а
чотири задачі закінчились не можливістю, а відмовою: правило з
нереалізованим джерелом не зберігається, профіль без команд заливки
каже про це замість мовчазної кнопки, міграція RLS валить сама себе на
таблиці без політики, тест словника аудиту падає на ключі без назви.

Подробиці — HISTORY.md, розділи за 26 і 27 серпня.
2026-08-27 17:32:49 +03:00

590 lines
21 KiB
Go
Raw Permalink 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 store
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/netpulse/netpulse/server/internal/crypto"
)
var ErrCheckNotFound = errors.New("перевірку не знайдено")
// CheckType — що взагалі вміє система опитувати.
//
// Перелік читається з БД, а не зашитий у фронтенд: плагіни додають свої
// типи міграцією, і UI має підхоплювати їх без перезбирання.
type CheckType struct {
Key string `json:"key"`
PluginKey string `json:"plugin_key"`
Name string `json:"name"`
ParamsSchema json.RawMessage `json:"params_schema"`
Metrics json.RawMessage `json:"metrics"`
/** Чи ввімкнений плагін у цього тенанта. */
Available bool `json:"available"`
// Чек заводить і планує система. З довідника не прибирається —
// перевірка типів при збереженні має його знати, — але в переліку
// для ручного додавання йому не місце: система вже завела свій,
// і другий такий самий упирався б у checks_uniq.
AutoManaged bool `json:"auto_managed"`
}
// Check — одна перевірка на хості.
type Check struct {
ID string `json:"id"`
DeviceID string `json:"device_id"`
CheckType string `json:"check_type"`
TypeName string `json:"type_name,omitempty"`
Params json.RawMessage `json:"params"`
IntervalSec int `json:"interval_sec"`
TimeoutMs int `json:"timeout_ms"`
Retries int `json:"retries"`
Enabled bool `json:"enabled"`
LastRunAt *string `json:"last_run_at,omitempty"`
LastError string `json:"last_error,omitempty"`
}
// ListCheckTypes віддає доступні типи перевірок.
func (s *Store) ListCheckTypes(ctx context.Context, tenantID string) ([]CheckType, error) {
var out []CheckType
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT ct.key, ct.plugin_key::text, ct.name,
ct.params_schema::text, ct.metrics::text,
-- Плагін доступний, якщо його явно ввімкнули цьому
-- тенанту або він базовий: вимагати «встановлення»
-- для пінга означало б зустрічати кожного клієнта
-- порожнім списком перевірок.
(p.is_core OR COALESCE(pi.enabled, false)),
ct.auto_managed
FROM core.check_types ct
JOIN core.plugins p ON p.key = ct.plugin_key
LEFT JOIN core.plugin_installs pi
ON pi.plugin_key = ct.plugin_key AND pi.tenant_id = $1
ORDER BY ct.plugin_key, ct.key
`, tenantID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var c CheckType
var schema, metrics string
if err := rows.Scan(&c.Key, &c.PluginKey, &c.Name, &schema, &metrics,
&c.Available, &c.AutoManaged); err != nil {
return err
}
c.ParamsSchema = json.RawMessage(schema)
c.Metrics = json.RawMessage(metrics)
out = append(out, c)
}
return rows.Err()
})
return out, err
}
// ListDeviceChecks — перевірки одного хоста.
func (s *Store) ListDeviceChecks(ctx context.Context, tenantID, deviceID string) ([]Check, error) {
var out []Check
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT c.id::text, c.device_id::text, c.check_type, COALESCE(ct.name,''),
c.params::text, c.interval_sec, c.timeout_ms, c.retries, c.enabled,
to_char(c.last_run_at, 'YYYY-MM-DD"T"HH24:MI:SSOF'),
COALESCE(c.last_error,'')
FROM core.checks c
LEFT JOIN core.check_types ct ON ct.key = c.check_type
WHERE c.tenant_id = $1 AND c.device_id = $2
AND c.template_id IS NULL
ORDER BY c.check_type
`, tenantID, deviceID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var c Check
var params string
if err := rows.Scan(&c.ID, &c.DeviceID, &c.CheckType, &c.TypeName, &params,
&c.IntervalSec, &c.TimeoutMs, &c.Retries, &c.Enabled,
&c.LastRunAt, &c.LastError); err != nil {
return err
}
c.Params = json.RawMessage(params)
out = append(out, c)
}
return rows.Err()
})
return out, err
}
// CheckInput — те, що приходить із форми. Порожній ID означає нову
// перевірку.
type CheckInput struct {
ID string
CheckType string
Params string
IntervalSec int
TimeoutMs int
Retries int
Enabled bool
}
// SetDeviceChecks замінює набір перевірок хоста цілком.
//
// Заміна, а не додавання: форма показує повний список, і прибраний
// рядок треба вміти прибрати.
//
// Наявні перевірки оновлюються за своїм id, а не перестворюються.
// Причина не в економії: унікальний індекс включає md5(params), тому
// «видалити й вставити» на зміні інтервалу спрацювало б, а на зміні
// параметрів створило б ДРУГУ перевірку того самого типу. Плюс
// перестворення скидає next_run_at і збиває рівномірність опитування
// по всьому парку.
func (s *Store) SetDeviceChecks(ctx context.Context, tenantID, deviceID string, in []CheckInput) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
var owned bool
if err := tx.QueryRow(ctx, `
SELECT EXISTS (SELECT 1 FROM inv.devices
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL)
`, deviceID, tenantID).Scan(&owned); err != nil {
return err
}
if !owned {
return ErrNotFound
}
keep := make([]string, 0, len(in))
for _, c := range in {
if c.IntervalSec < 5 {
c.IntervalSec = 60
}
if c.IntervalSec > 86400 {
c.IntervalSec = 86400
}
if c.TimeoutMs <= 0 {
c.TimeoutMs = 3000
}
if c.Retries < 0 {
c.Retries = 0
}
params := c.Params
if params == "" {
params = "{}"
}
var id string
if c.ID != "" {
err := tx.QueryRow(ctx, `
UPDATE core.checks
SET params = $4::jsonb, interval_sec = $5, timeout_ms = $6,
retries = $7, enabled = $8, updated_at = now()
WHERE tenant_id = $1 AND device_id = $2 AND id = $3
RETURNING id::text
`, tenantID, deviceID, c.ID, params, c.IntervalSec,
c.TimeoutMs, c.Retries, c.Enabled).Scan(&id)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return fmt.Errorf("перевірка %s: %w", c.CheckType, err)
}
}
// Рядка з таким id немає (або його не передали) — створюємо.
if id == "" {
if err := tx.QueryRow(ctx, `
INSERT INTO core.checks
(tenant_id, device_id, check_type, params, interval_sec,
timeout_ms, retries, enabled)
VALUES ($1, $2, $3, $4::jsonb, $5, $6, $7, $8)
RETURNING id::text
`, tenantID, deviceID, c.CheckType, params, c.IntervalSec,
c.TimeoutMs, c.Retries, c.Enabled).Scan(&id); err != nil {
// Дубль ловимо окремо: checks_uniq спрацьовує на
// (хост, тип, інтерфейс, параметри), і для людини це
// не збій сервера, а «такий чек уже є». Без цієї
// гілки вона бачила б «внутрішню помилку» й не мала
// жодної підказки, що робити далі.
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return fmt.Errorf("%w: перевірка %s з такими ж параметрами "+
"у цього хоста вже є", ErrInvalid, c.CheckType)
}
return fmt.Errorf("перевірка %s: %w", c.CheckType, err)
}
}
keep = append(keep, id)
}
// Прибираємо зняте у формі. Перевірки на інтерфейсах не чіпаємо:
// їх заводить автовиявлення, і форма хоста про них не знає.
// Породжені шаблоном — так само: ними володіє реконсиляція, і
// видалення тут означало б, що вони зникають на кожне
// збереження форми, щоб за секунду з'явитися знову.
//
// І окремо — автокеровані типи. Їх ховають від переліку для
// ручного додавання, і саме тому вони НЕ приходять назад у
// цьому запиті: форма про них не знає, а отже не може їх
// «лишити». Без цієї умови кожне збереження форми тихо зносило
// б розпізнавання хоста й опитування портів — тобто ту саму
// автоматику, заради якої їх і сховали.
//
// Помилку внесено разом із auto_managed і знайдено на живому
// стенді: у п'яти хостів із шести зник topology.identify.
_, err := tx.Exec(ctx, `
DELETE FROM core.checks c
WHERE c.tenant_id = $1 AND c.device_id = $2
AND c.interface_id IS NULL
AND c.template_id IS NULL
AND NOT (c.id = ANY($3::uuid[]))
AND NOT EXISTS (
SELECT 1 FROM core.check_types ct
WHERE ct.key = c.check_type AND ct.auto_managed
)
`, tenantID, deviceID, nonNilIDs(keep))
return err
})
}
// ---------------------------------------------------------------------
// Креденшели хоста
// ---------------------------------------------------------------------
type Credential struct {
ID string `json:"id"`
Name string `json:"name"`
Proto string `json:"proto"`
Username string `json:"username,omitempty"`
Port int `json:"port,omitempty"`
IsDefault bool `json:"is_default"`
}
func (s *Store) ListCredentials(ctx context.Context, tenantID string) ([]Credential, error) {
var out []Credential
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT id::text, name, proto::text, COALESCE(username,''),
COALESCE(port,0), is_default
FROM inv.credentials
WHERE tenant_id = $1
ORDER BY proto, name
`, tenantID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var c Credential
if err := rows.Scan(&c.ID, &c.Name, &c.Proto, &c.Username,
&c.Port, &c.IsDefault); err != nil {
return err
}
out = append(out, c)
}
return rows.Err()
})
return out, err
}
// CreateCredential зберігає доступ із зашифрованим секретом.
func (s *Store) CreateCredential(ctx context.Context, tenantID, name, proto, username string,
port int, secret string, options string, ring *crypto.Keyring) (string, error) {
var id string
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
var secretID any
if secret != "" {
if ring == nil {
return errors.New("сервер запущено без ключа шифрування")
}
aad := tenantID + "|inv.credential"
sec, err := ring.Encrypt([]byte(secret), aad)
if err != nil {
return err
}
kind := "snmp_v3"
switch proto {
case "snmp_v2c":
// Community — це не пароль SNMPv3, але шифрується так само:
// у дампі БД вона дає рівно той самий доступ до обладнання.
kind = "api_token"
case "ssh":
kind = "ssh_password"
case "telnet":
kind = "telnet"
}
var sid string
if err := tx.QueryRow(ctx, `
INSERT INTO core.secrets (tenant_id, kind, key_id, nonce, ciphertext, auth_tag, aad)
VALUES ($1, $2::core.secret_kind, $3, $4, $5, $6, $7)
RETURNING id::text
`, tenantID, kind, sec.KeyID, sec.Nonce, sec.Ciphertext, sec.AuthTag, aad).
Scan(&sid); err != nil {
return err
}
secretID = sid
}
opts := options
if opts == "" {
opts = "{}"
}
return tx.QueryRow(ctx, `
INSERT INTO inv.credentials
(tenant_id, name, proto, username, port, secret_id, options)
VALUES ($1, $2, $3::inv.credential_proto, NULLIF($4,''), NULLIF($5,0), $6, $7::jsonb)
RETURNING id::text
`, tenantID, name, proto, username, port, secretID, opts).Scan(&id)
})
return id, err
}
// SetDeviceCredentials замінює прив'язку доступів до хоста.
func (s *Store) SetDeviceCredentials(ctx context.Context, tenantID, deviceID string, credIDs []string) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx, `
DELETE FROM inv.device_credentials dc
USING inv.credentials c
WHERE dc.credential_id = c.id AND dc.device_id = $1 AND c.tenant_id = $2
`, deviceID, tenantID); err != nil {
return err
}
if len(credIDs) == 0 {
return nil
}
_, err := tx.Exec(ctx, `
INSERT INTO inv.device_credentials (device_id, credential_id, priority)
SELECT $1, c.id, 100
FROM inv.credentials c
WHERE c.tenant_id = $2 AND c.id = ANY($3::uuid[])
ON CONFLICT DO NOTHING
`, deviceID, tenantID, credIDs)
return err
})
}
// DeviceCredentialsOf — які доступи прив'язані до кожного хоста.
func (s *Store) DeviceCredentialsOf(ctx context.Context, tenantID string) (map[string][]string, error) {
out := map[string][]string{}
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT dc.device_id::text, dc.credential_id::text
FROM inv.device_credentials dc
JOIN inv.credentials c ON c.id = dc.credential_id
WHERE c.tenant_id = $1
`, tenantID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var d, c string
if err := rows.Scan(&d, &c); err != nil {
return err
}
out[d] = append(out[d], c)
}
return rows.Err()
})
return out, err
}
// UpdateCredential змінює доступ.
//
// Порожній secret означає «пароль лишити як є»: форма ніколи не показує
// збережений секрет (розшифрувати його для показу — це віддати його
// туди, звідки він уже не повернеться), тож надіслати незмінений вона не
// може навіть теоретично.
func (s *Store) UpdateCredential(ctx context.Context, tenantID, id, name, proto, username string,
port int, secret string, options string, ring *crypto.Keyring) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
var curProto string
var oldSecret *string
if err := tx.QueryRow(ctx, `
SELECT proto::text, secret_id::text FROM inv.credentials
WHERE id = $1 AND tenant_id = $2
`, id, tenantID).Scan(&curProto, &oldSecret); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
return err
}
// Порожній proto означає «не чіпати». Зміна протоколу дозволена:
// community SNMP і пароль SSH зберігаються однаково, тож
// переналаштувати запис дешевше, ніж заводити новий і
// перевʼязувати всі хости.
if proto == "" {
proto = curProto
}
secretID := any(nil)
if oldSecret != nil {
secretID = *oldSecret
}
// Секрет зашифрований під видом старого протоколу. Якщо
// протокол змінили, а новий пароль не дали, збережений стає
// сміттям із чужим kind — тому вимагаємо ввести його заново.
if proto != curProto && secret == "" && oldSecret != nil {
return fmt.Errorf("%w: при зміні протоколу треба ввести пароль заново", ErrInvalid)
}
if secret != "" {
if ring == nil {
return errors.New("сервер запущено без ключа шифрування")
}
aad := tenantID + "|inv.credential"
sec, err := ring.Encrypt([]byte(secret), aad)
if err != nil {
return err
}
var sid string
if err := tx.QueryRow(ctx, `
INSERT INTO core.secrets (tenant_id, kind, key_id, nonce, ciphertext, auth_tag, aad)
VALUES ($1, $2::core.secret_kind, $3, $4, $5, $6, $7)
RETURNING id::text
`, tenantID, secretKind(proto), sec.KeyID, sec.Nonce, sec.Ciphertext,
sec.AuthTag, aad).Scan(&sid); err != nil {
return err
}
// Старий секрет прибираємо лише після того, як новий ліг:
// зворотний порядок на помилці шифрування лишив би доступ
// без пароля взагалі.
if oldSecret != nil {
if _, err := tx.Exec(ctx, `DELETE FROM core.secrets WHERE id = $1`, *oldSecret); err != nil {
return err
}
}
secretID = sid
}
opts := options
if opts == "" {
opts = "{}"
}
ct, err := tx.Exec(ctx, `
UPDATE inv.credentials
SET name = $3, proto = $8::inv.credential_proto,
username = NULLIF($4,''), port = NULLIF($5,0),
secret_id = $6, options = $7::jsonb
WHERE id = $1 AND tenant_id = $2
`, id, tenantID, name, username, port, secretID, opts, proto)
if err != nil {
return err
}
if ct.RowsAffected() == 0 {
return ErrNotFound
}
return nil
})
}
// DeleteCredential прибирає доступ разом із секретом.
//
// Прив'язки до хостів зникають каскадом. Це саме те, що треба: доступ,
// якого немає, не має лишатись у списку хоста й створювати враження, що
// пристрій опитується.
func (s *Store) DeleteCredential(ctx context.Context, tenantID, id string) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
var secretID *string
if err := tx.QueryRow(ctx, `
SELECT secret_id::text FROM inv.credentials WHERE id = $1 AND tenant_id = $2
`, id, tenantID).Scan(&secretID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
return err
}
if _, err := tx.Exec(ctx, `
DELETE FROM inv.credentials WHERE id = $1 AND tenant_id = $2
`, id, tenantID); err != nil {
return err
}
if secretID != nil {
_, err := tx.Exec(ctx, `DELETE FROM core.secrets WHERE id = $1`, *secretID)
return err
}
return nil
})
}
// CredentialUsage — скільки хостів користується доступом.
//
// Потрібне попередженню перед видаленням: «доступ використовують 34
// хости» — це інформація, а «Ви впевнені?» — ні.
func (s *Store) CredentialUsage(ctx context.Context, tenantID string) (map[string]int, error) {
out := map[string]int{}
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT c.id::text, count(dc.device_id)
FROM inv.credentials c
LEFT JOIN inv.device_credentials dc ON dc.credential_id = c.id
WHERE c.tenant_id = $1
GROUP BY c.id
`, tenantID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var id string
var n int
if err := rows.Scan(&id, &n); err != nil {
return err
}
out[id] = n
}
return rows.Err()
})
return out, err
}
// secretKind — під яким видом секрет лягає в core.secrets.
//
// Community SNMPv2c — це не пароль, але шифрується так само: у дампі БД
// вона дає рівно той самий доступ до обладнання.
func secretKind(proto string) string {
switch proto {
case "snmp_v2c":
return "api_token"
case "ssh":
return "ssh_password"
case "telnet":
return "telnet"
default:
return "snmp_v3"
}
}
// DeviceCredentialIDs — які доступи прив'язані до хоста.
//
// Потрібне формі: показати порожній список там, де доступи задані,
// означало б стерти їх першим же збереженням.
func (s *Store) DeviceCredentialIDs(ctx context.Context, tenantID, deviceID string) ([]string, error) {
ids := []string{}
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT dc.credential_id::text
FROM inv.device_credentials dc
JOIN inv.credentials c ON c.id = dc.credential_id
WHERE dc.device_id = $1 AND c.tenant_id = $2
ORDER BY dc.priority
`, deviceID, tenantID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return err
}
ids = append(ids, id)
}
return rows.Err()
})
return ids, err
}