package store import ( "context" "crypto/sha256" "encoding/hex" "errors" "fmt" "log/slog" "strings" "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 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 string if err := tx.QueryRow(ctx, ` SELECT name FROM inv.devices WHERE id = $1 AND tenant_id = $2 `, sub.DeviceID, a.TenantID).Scan(&deviceName); err != nil { return fmt.Errorf("пристрій %s: %w", sub.DeviceID, err) } // Шлях за іменем, гілка за ідентифікатором. Ім'я в дереві // читабельне й змінюється разом із пристроєм; гілка мусить // пережити перейменування, інакше історія розсипається на дві. path := fmt.Sprintf("%s/%s.cfg", sanitizePath(deviceName), configType) branch := "device/" + sub.DeviceID commitSHA, blobSHA := hexSum, hexSum if s.git != nil { res, gerr := s.git.Write(gitstore.Commit{ Repo: a.TenantID + ".git", 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 }) return out, err } 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 { case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': b.WriteRune(r) case r == '-' || r == '_' || r == '.': b.WriteRune(r) default: b.WriteRune('-') } } out := strings.Trim(b.String(), ".-") if out == "" { return "device" } return out }