Один коміт, а не десяток тематичних, свідомо: теми переплетені в
спільних файлах (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 серпня.
304 lines
12 KiB
Go
304 lines
12 KiB
Go
package store
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"errors"
|
||
"fmt"
|
||
"log/slog"
|
||
"strings"
|
||
"unicode"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
"github.com/netpulse/netpulse/server/internal/crypto"
|
||
"github.com/netpulse/netpulse/server/internal/gitstore"
|
||
)
|
||
|
||
// ConfigSubmission — зібраний із чанків конфіг.
|
||
type ConfigSubmission struct {
|
||
JobID string
|
||
DeviceID string
|
||
ConfigType string
|
||
Body []byte
|
||
// sha256 тіла, як його порахував агент.
|
||
ClaimedHash []byte
|
||
}
|
||
|
||
// ConfigOutcome — що сервер із цим зробив.
|
||
type ConfigOutcome struct {
|
||
Accepted bool
|
||
Unchanged bool
|
||
// Те, що не спрацювало, але не завадило зберегти версію.
|
||
// Порожньо — усе гаразд.
|
||
Warning string
|
||
ConfigID string
|
||
CommitSHA string
|
||
Err error
|
||
}
|
||
|
||
var ErrChecksumMismatch = errors.New("контрольна сума не збіглася")
|
||
|
||
// StoreConfig приймає конфіг: звіряє суму, визначає, чи є зміна,
|
||
// зберігає нову версію й кладе її в Git.
|
||
//
|
||
// Два сховища замість одного — свідомо. Тіло лежить зашифрованим у
|
||
// core.secrets і відповідає на «який конфіг зараз»; Git відповідає на
|
||
// «що і коли змінилось за півроку». Друге питання ставлять під час
|
||
// розбору аварії, і відповідь не має залежати від того, чи не почистив
|
||
// таблицю ретеншен.
|
||
//
|
||
// Без під'єднаного Git (UseGit не викликали) працює як і раніше:
|
||
// commit_sha містить hex контентного хеша. Це робочий режим для
|
||
// інсталяцій без окремого диска під історію.
|
||
func (s *Store) StoreConfig(ctx context.Context, a *Agent, sub ConfigSubmission, ring *crypto.Keyring) (ConfigOutcome, error) {
|
||
sum := sha256.Sum256(sub.Body)
|
||
if len(sub.ClaimedHash) > 0 && !equalBytes(sum[:], sub.ClaimedHash) {
|
||
return ConfigOutcome{Err: ErrChecksumMismatch}, nil
|
||
}
|
||
|
||
out := ConfigOutcome{}
|
||
|
||
err := s.InTenantTx(ctx, a.TenantID, func(tx pgx.Tx) error {
|
||
configType := sub.ConfigType
|
||
if configType == "" {
|
||
configType = "running"
|
||
}
|
||
|
||
// Чи змінилось? Порівняння за хешем нормалізованого тіла —
|
||
// саме тому scrub робиться на сервері: інакше кожен збір
|
||
// виглядав би зміною через рядок з uptime.
|
||
var (
|
||
prevID string
|
||
prevHash []byte
|
||
)
|
||
err := tx.QueryRow(ctx, `
|
||
SELECT id::text, content_hash
|
||
FROM ncm.configs
|
||
WHERE device_id = $1 AND tenant_id = $2 AND config_type = $3
|
||
ORDER BY collected_at DESC
|
||
LIMIT 1
|
||
`, sub.DeviceID, a.TenantID, configType).Scan(&prevID, &prevHash)
|
||
|
||
hasPrev := err == nil
|
||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||
return err
|
||
}
|
||
|
||
if hasPrev && equalBytes(prevHash, sum[:]) {
|
||
out.Accepted = true
|
||
out.Unchanged = true
|
||
out.ConfigID = prevID
|
||
return nil
|
||
}
|
||
|
||
var repoID string
|
||
err = tx.QueryRow(ctx, `
|
||
SELECT id::text FROM ncm.repos WHERE tenant_id = $1 ORDER BY created_at LIMIT 1
|
||
`, a.TenantID).Scan(&repoID)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
// Репозиторій заводиться при першому ж бекапі.
|
||
// $1 тут потрібен і як uuid (колонка tenant_id), і як text
|
||
// (частина шляху). Приведення на місці не допомагає — воно
|
||
// не розв'язує конфлікт, а нав'язує один тип обом уживанням.
|
||
// Тому лишаємо параметр текстовим і кастуємо там, де треба uuid.
|
||
err = tx.QueryRow(ctx, `
|
||
INSERT INTO ncm.repos (tenant_id, name, storage_path)
|
||
VALUES ($1::uuid, 'default', '/var/lib/netpulse/git/' || $1 || '.git')
|
||
RETURNING id::text
|
||
`, a.TenantID).Scan(&repoID)
|
||
}
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// Тіло — секрет: у конфігах живуть ключі, community-рядки
|
||
// і хеші паролів. Кладемо зашифрованим тим самим механізмом,
|
||
// що й креденшели.
|
||
aad := a.TenantID + "|config|" + sub.DeviceID
|
||
sec, err := ring.Encrypt(sub.Body, aad)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
var secretID string
|
||
err = tx.QueryRow(ctx, `
|
||
INSERT INTO core.secrets (tenant_id, kind, key_id, nonce, ciphertext, auth_tag, aad)
|
||
VALUES ($1, 'generic', $2, $3, $4, $5, $6)
|
||
RETURNING id::text
|
||
`, a.TenantID, sec.KeyID, sec.Nonce, sec.Ciphertext, sec.AuthTag, aad).Scan(&secretID)
|
||
if err != nil {
|
||
return fmt.Errorf("збереження зашифрованого тіла: %w", err)
|
||
}
|
||
|
||
hexSum := hex.EncodeToString(sum[:])
|
||
lines := strings.Count(string(sub.Body), "\n") + 1
|
||
|
||
var deviceName, deviceAddr string
|
||
if err := tx.QueryRow(ctx, `
|
||
SELECT name, COALESCE(host(address), '')
|
||
FROM inv.devices WHERE id = $1 AND tenant_id = $2
|
||
`, sub.DeviceID, a.TenantID).Scan(&deviceName, &deviceAddr); err != nil {
|
||
return fmt.Errorf("пристрій %s: %w", sub.DeviceID, err)
|
||
}
|
||
|
||
// Як звалась гілка минулого разу. Порожньо — пристрій ще не
|
||
// комітився.
|
||
var prevBranch string
|
||
_ = tx.QueryRow(ctx, `
|
||
SELECT branch FROM ncm.configs
|
||
WHERE device_id = $1 AND tenant_id = $2 AND branch <> ''
|
||
ORDER BY collected_at DESC LIMIT 1
|
||
`, sub.DeviceID, a.TenantID).Scan(&prevBranch)
|
||
|
||
// Шлях за іменем, гілка — за іменем і адресою.
|
||
//
|
||
// Складання обох — у ConfigPath і DeviceBranch (ncm_paths.go):
|
||
// ті самі правила потрібні переливанню історії, і два їхні
|
||
// примірники розійшлися б на першій же правці одного з них.
|
||
path := ConfigPath(deviceName, configType)
|
||
branch := DeviceBranch(sub.DeviceID, deviceName, deviceAddr)
|
||
|
||
// Хост перейменували або змінили адресу — переносимо гілку, а
|
||
// не заводимо другу. Дві гілки на один пристрій означали б, що
|
||
// половина історії лежить під старим іменем, і знайти її можна
|
||
// лише знаючи, як хост звався колись.
|
||
if s.git != nil && prevBranch != "" && prevBranch != branch {
|
||
if err := s.git.RenameBranch(RepoName(a.TenantID), prevBranch, branch); err != nil {
|
||
// Не привід не зберегти конфіг: сама версія цінна, а
|
||
// підпис гілки — ні.
|
||
out.Warning = fmt.Sprintf("не вдалося перейменувати гілку %s → %s: %v",
|
||
prevBranch, branch, err)
|
||
}
|
||
}
|
||
|
||
commitSHA, blobSHA := hexSum, hexSum
|
||
if s.git != nil {
|
||
res, gerr := s.git.Write(gitstore.Commit{
|
||
Repo: RepoName(a.TenantID),
|
||
Branch: branch,
|
||
Path: path,
|
||
Body: sub.Body,
|
||
Message: fmt.Sprintf("%s: %s", deviceName, configType),
|
||
Author: "NetPulse",
|
||
Email: "netpulse@localhost",
|
||
})
|
||
if gerr != nil {
|
||
// Помилка Git не має губити бекап: конфіг уже зібрано з
|
||
// пристрою, і викинути його через проблему з диском —
|
||
// найдорожчий спосіб відреагувати. Версія лягає в базу
|
||
// з контентним хешем, а несправність видно в журналі.
|
||
slog.Error("git: конфіг не записано, лишаємо тільки в базі",
|
||
"device", sub.DeviceID, "error", gerr)
|
||
} else {
|
||
commitSHA, blobSHA = res.CommitSHA, res.BlobSHA
|
||
}
|
||
}
|
||
|
||
var prevArg any
|
||
if hasPrev {
|
||
prevArg = prevID
|
||
}
|
||
|
||
err = tx.QueryRow(ctx, `
|
||
INSERT INTO ncm.configs
|
||
(tenant_id, device_id, repo_id, commit_sha, blob_sha, branch, path,
|
||
config_type, size_bytes, line_count, content_hash, body_secret_id,
|
||
prev_config_id, is_change)
|
||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13::uuid,$14)
|
||
RETURNING id::text
|
||
`, a.TenantID, sub.DeviceID, repoID, commitSHA, blobSHA,
|
||
branch, path, configType,
|
||
len(sub.Body), lines, sum[:], secretID, prevArg, hasPrev).Scan(&out.ConfigID)
|
||
if err != nil {
|
||
return fmt.Errorf("вставка версії конфігу: %w", err)
|
||
}
|
||
|
||
if _, err := tx.Exec(ctx, `
|
||
UPDATE ncm.device_policies
|
||
SET last_backup_at = now()
|
||
WHERE device_id = $1 AND tenant_id = $2
|
||
`, sub.DeviceID, a.TenantID); err != nil {
|
||
return err
|
||
}
|
||
|
||
out.Accepted = true
|
||
out.CommitSHA = commitSHA
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return out, err
|
||
}
|
||
|
||
// Версія й серійний номер із тіла конфігу.
|
||
//
|
||
// Не кожен пристрій каже їх по SNMP: D-Link у sysDescr дає саму
|
||
// лише модель, а в шапці власного конфігу пише «Firmware: Build
|
||
// 1.05.B026». Конфіг ми щойно зібрали — дістати з нього ці рядки
|
||
// коштує нуль запитів до пристрою.
|
||
//
|
||
// Помилка тут не скасовує вже прийнятий конфіг: він цінний сам
|
||
// собою, а версія — приємне доповнення.
|
||
if err := s.fillFromConfig(ctx, a.TenantID, sub.DeviceID, sub.Body); err != nil {
|
||
return out, nil
|
||
}
|
||
|
||
return out, nil
|
||
}
|
||
|
||
func equalBytes(a, b []byte) bool {
|
||
if len(a) != len(b) {
|
||
return false
|
||
}
|
||
for i := range a {
|
||
if a[i] != b[i] {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// sanitizePath робить з імені пристрою придатну частину шляху.
|
||
//
|
||
// Імена заводять люди: там трапляються скісні риски, пробіли й крапки
|
||
// на початку. Скісна риска створила б зайвий рівень дерева, а ".." —
|
||
// вийшла б за межі репозиторію.
|
||
func sanitizePath(name string) string {
|
||
var b strings.Builder
|
||
for _, r := range name {
|
||
switch {
|
||
// Літери й цифри БУДЬ-ЯКОЇ абетки, а не лише латиниця.
|
||
//
|
||
// Було інакше — і кирилиця перетворювалась на риски, які потім
|
||
// обрізались. На живому стенді з цього виходило:
|
||
//
|
||
// «ZTE Миронівка» → ZTE
|
||
// «Леніна.21» → 21
|
||
// «ПОЛЬОВЕ.7» → 7
|
||
// «ПОЛЬОВЕ-СВ» → device
|
||
// «Миронівка.Ядро.1» → 1
|
||
//
|
||
// Тобто той, хто клонує дзеркало, бачив би каталоги «1», «7» і
|
||
// «21» замість імен своїх пристроїв. Git зберігає шляхи в UTF-8
|
||
// і кирилиці не боїться; боялась її лише ця функція.
|
||
case unicode.IsLetter(r), unicode.IsDigit(r):
|
||
b.WriteRune(r)
|
||
case r == '-' || r == '_' || r == '.':
|
||
b.WriteRune(r)
|
||
default:
|
||
b.WriteRune('-')
|
||
}
|
||
}
|
||
out := strings.Trim(b.String(), ".-")
|
||
|
||
// «..» лишається небезпечним незалежно від абетки: у дереві Git це
|
||
// вихід на рівень вище. Крапки всередині імені при цьому законні —
|
||
// «Леніна.21» має лишитись собою.
|
||
for strings.Contains(out, "..") {
|
||
out = strings.ReplaceAll(out, "..", ".")
|
||
}
|
||
if out == "" {
|
||
return "device"
|
||
}
|
||
return out
|
||
}
|