Netpulse_SasS/server/internal/store/ncm_read.go
byrsapty c28852df66
Some checks are pending
CI / web (push) Waiting to run
CI / server (push) Waiting to run
CI / agent (push) Waiting to run
Syslog: транспорт до сервера і тригер позачергового бекапу
Приймач на зонді лежав без діла — тепер під'єднаний. Окремий стрім
StreamLogs, а не контрольний канал: сплеск логів під час аварії не має
заважати heartbeat і командам.

Хост зіставляється за адресою джерела на зонді: у сервера немає
контексту мережі клієнта, а один приватний діапазон трапляється в
десятках кабінетів. Невідома адреса не привід викинути подію.

Подія, що збіглася зі зразком у ncm.device_policies.syslog_match,
ставить позачерговий збір конфігу. Типовий зразок покриває Cisco,
HP/Huawei, Juniper і MikroTik — навмисно широкий: зайвий бекап коштує
секунд, пропущений — цілої зміни.

Заразом увесь репозиторій прогнано через gofmt: CI, написаний два
кроки тому, перевіряє це і впав би на 29 файлах із порушеннями,
накопиченими за весь проєкт.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 15:20:32 +03:00

181 lines
6.4 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"
"time"
"github.com/jackc/pgx/v5"
"github.com/netpulse/netpulse/server/internal/crypto"
"github.com/netpulse/netpulse/server/internal/difftext"
)
var ErrConfigNotFound = errors.New("версію конфігу не знайдено")
// ConfigVersion — рядок історії конфігів.
type ConfigVersion struct {
ID string `json:"id"`
DeviceID string `json:"device_id"`
ConfigType string `json:"config_type"`
SizeBytes int `json:"size_bytes"`
LineCount int `json:"line_count"`
ContentHash string `json:"content_hash"`
PrevConfigID string `json:"prev_config_id,omitempty"`
LinesAdded int `json:"lines_added"`
LinesRemoved int `json:"lines_removed"`
IsChange bool `json:"is_change"`
CollectedAt time.Time `json:"collected_at"`
}
// ListDeviceConfigs — історія версій конфігу хоста.
func (s *Store) ListDeviceConfigs(ctx context.Context, tenantID, deviceID string, limit int) ([]ConfigVersion, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
var out []ConfigVersion
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT id::text, device_id::text, config_type, size_bytes,
COALESCE(line_count,0), encode(content_hash,'hex'),
COALESCE(prev_config_id::text,''),
lines_added, lines_removed, is_change, collected_at
FROM ncm.configs
WHERE tenant_id = $1 AND device_id = $2
ORDER BY collected_at DESC
LIMIT $3
`, tenantID, deviceID, limit)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var c ConfigVersion
if err := rows.Scan(&c.ID, &c.DeviceID, &c.ConfigType, &c.SizeBytes,
&c.LineCount, &c.ContentHash, &c.PrevConfigID,
&c.LinesAdded, &c.LinesRemoved, &c.IsChange, &c.CollectedAt); err != nil {
return err
}
out = append(out, c)
}
return rows.Err()
})
return out, err
}
// ConfigBody повертає розшифрований текст конфігу.
//
// Розшифровка робиться тут, а не в обробнику: DEK не має покидати шар
// доступу до даних, а обробнику потрібен лише текст.
func (s *Store) ConfigBody(ctx context.Context, tenantID, configID string, ring *crypto.Keyring) (string, ConfigVersion, error) {
var (
v ConfigVersion
keyID, aad *string
nonce, ct, tag []byte
)
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
return tx.QueryRow(ctx, `
SELECT c.id::text, c.device_id::text, c.config_type, c.size_bytes,
COALESCE(c.line_count,0), encode(c.content_hash,'hex'),
COALESCE(c.prev_config_id::text,''),
c.lines_added, c.lines_removed, c.is_change, c.collected_at,
s.key_id, s.nonce, s.ciphertext, s.auth_tag, COALESCE(s.aad,'')
FROM ncm.configs c
LEFT JOIN core.secrets s ON s.id = c.body_secret_id
WHERE c.tenant_id = $1 AND c.id = $2
`, tenantID, configID).Scan(&v.ID, &v.DeviceID, &v.ConfigType, &v.SizeBytes,
&v.LineCount, &v.ContentHash, &v.PrevConfigID,
&v.LinesAdded, &v.LinesRemoved, &v.IsChange, &v.CollectedAt,
&keyID, &nonce, &ct, &tag, &aad)
})
if errors.Is(err, pgx.ErrNoRows) {
return "", v, ErrConfigNotFound
}
if err != nil {
return "", v, err
}
if keyID == nil {
// Тіло не збереглося — таке буває лише після ручного втручання
// в БД, але мовчати про це не можна.
return "", v, fmt.Errorf("тіло версії %s відсутнє", configID)
}
if ring == nil {
return "", v, errors.New("сервер запущено без ключа шифрування")
}
plain, err := ring.Decrypt(&crypto.Secret{
KeyID: *keyID, Nonce: nonce, Ciphertext: ct, AuthTag: tag,
}, derefStr(aad))
if err != nil {
return "", v, fmt.Errorf("розшифровка конфігу: %w", err)
}
return string(plain), v, nil
}
// DiffConfigs порівнює дві версії й кешує результат.
//
// Кеш потрібен не для швидкості: diff між двома конкретними версіями
// незмінний назавжди, і рахувати його щоразу, коли хтось відкриває
// сторінку історії, — це палити процесор на відому відповідь.
func (s *Store) DiffConfigs(ctx context.Context, tenantID, fromID, toID string, ring *crypto.Keyring) (difftext.Result, error) {
var res difftext.Result
// Кеш.
var cached string
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
return tx.QueryRow(ctx, `
SELECT hunks::text FROM ncm.diffs
WHERE tenant_id = $1 AND from_config_id = $2 AND to_config_id = $3
AND format = 'json_hunks'
`, tenantID, fromID, toID).Scan(&cached)
})
if err == nil && cached != "" {
if err := json.Unmarshal([]byte(cached), &res); err == nil {
return res, nil
}
// Пошкоджений кеш — не привід відмовляти: перерахуємо.
} else if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return res, err
}
oldText, _, err := s.ConfigBody(ctx, tenantID, fromID, ring)
if err != nil {
return res, err
}
newText, _, err := s.ConfigBody(ctx, tenantID, toID, ring)
if err != nil {
return res, err
}
res = difftext.Compare(oldText, newText)
payload, err := json.Marshal(res)
if err != nil {
return res, err
}
_ = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
_, err := tx.Exec(ctx, `
INSERT INTO ncm.diffs
(tenant_id, from_config_id, to_config_id, format, hunks,
lines_added, lines_removed)
VALUES ($1, $2, $3, 'json_hunks', $4::jsonb, $5, $6)
ON CONFLICT (from_config_id, to_config_id, format) DO NOTHING
`, tenantID, fromID, toID, string(payload), res.LinesAdded, res.LinesRemoved)
return err
})
// Підсумок змін пишемо й у саму версію: список історії показує
// «+12/3» без читання й розшифровки двох тіл на кожен рядок.
_ = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
_, err := tx.Exec(ctx, `
UPDATE ncm.configs
SET lines_added = $3, lines_removed = $4
WHERE tenant_id = $1 AND id = $2 AND prev_config_id = $5
`, tenantID, toID, res.LinesAdded, res.LinesRemoved, fromID)
return err
})
return res, nil
}