Netpulse_SasS/server/internal/crypto/secrets.go
byrsapty 215bf8c4db Етап 9: алерти й сповіщення
Система вміла малювати мапу, але мовчала, коли щось падало. Схема alr.*
лежала готовою з Етапу 1 і повністю порожньою.

Движок:
- обчислює правила з icmp / interface / metric / no_data і не тримає
  стану між тіками: вікно for_seconds — це запит по часу до TSDB, тож
  перезапуск нічого не збиває
- дві семантики вікна: без agg умова має триматися всі виміри (антифлап),
  з agg порівнюється агрегат
- кореляція за топологією: причина аварії — той, у кого лишився живий
  сусід; хто оточений мертвими, той наслідок. Заради цього й будувалась
  topo.links: інакше падіння маршрутизатора дає сорок сповіщень
- вікна обслуговування, ручне заглушення зі стелею в тиждень
- advisory-блокування на тік: кілька API за балансувальником безпечні

Доставка:
- Telegram, webhook, SMTP; токени в core.secrets тим самим кільцем, що
  й паролі від обладнання
- тенант без маршрутів отримує все в усі придатні канали — підключили
  Telegram, має працювати
- тиха година не глушить disaster
- вебхуки на внутрішні адреси заблоковано; для self-hosted знімається
  прапорцем процесу, бо там ця мережа своя

UI: індикатор у шапці, панель зі списком, ack/mute/close, окремий фільтр
придушених, спільна шина подій замість другого WebSocket.

Знайдено живою роботою й виправлено:
- вимкнене або видалене правило лишало алерти сиротами назавжди
- перехід у suppressed не публікував події — UI дізнавався лише після
  перезавантаження сторінки
- кнопки дій на телефоні були 26 px

20 нових тестів, go vet і tsc чисто. Живий прогін: поріг посередині
розділив два справжні пристрої, 5 доставок на 5 подій без повторів.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 11:09:19 +03:00

211 lines
6.1 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 crypto — шифрування секретів для core.secrets.
//
// AES-GCM-256. Ключі шифрування даних (DEK) живуть поза БД: у
// KMS/Vault або, для self-hosted, у файлі з правами 0600. У таблиці
// лежить лише key_id, тому дамп бази без доступу до ключів не дає
// жодного пароля від обладнання клієнта.
package crypto
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"strings"
"sync"
)
const (
KeySize = 32 // AES-256
NonceSize = 12 // 96 біт, рекомендація NIST для GCM
TagSize = 16
)
var (
ErrUnknownKey = errors.New("невідомий key_id")
ErrBadKeySize = errors.New("ключ має бути 32 байти")
)
// Keyring — набір DEK за ідентифікаторами. Кілька ключів одночасно
// потрібні для ротації: нові секрети шифруються активним ключем,
// старі ще читаються попереднім.
type Keyring struct {
mu sync.RWMutex
keys map[string][]byte
active string
}
func NewKeyring() *Keyring {
return &Keyring{keys: make(map[string][]byte)}
}
// Add додає ключ. Перший доданий стає активним.
func (k *Keyring) Add(keyID string, key []byte) error {
if len(key) != KeySize {
return fmt.Errorf("%w: %d", ErrBadKeySize, len(key))
}
k.mu.Lock()
defer k.mu.Unlock()
cp := make([]byte, KeySize)
copy(cp, key)
k.keys[keyID] = cp
if k.active == "" {
k.active = keyID
}
return nil
}
// SetActive призначає ключ, яким шифруються нові секрети.
func (k *Keyring) SetActive(keyID string) error {
k.mu.Lock()
defer k.mu.Unlock()
if _, ok := k.keys[keyID]; !ok {
return ErrUnknownKey
}
k.active = keyID
return nil
}
func (k *Keyring) ActiveKeyID() string {
k.mu.RLock()
defer k.mu.RUnlock()
return k.active
}
func (k *Keyring) aead(keyID string) (cipher.AEAD, error) {
k.mu.RLock()
key, ok := k.keys[keyID]
k.mu.RUnlock()
if !ok {
return nil, fmt.Errorf("%w: %q", ErrUnknownKey, keyID)
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
return cipher.NewGCM(block)
}
// Secret — те, що лягає в рядок core.secrets.
type Secret struct {
KeyID string
Nonce []byte
Ciphertext []byte
AuthTag []byte
}
// Encrypt шифрує активним ключем.
//
// aad (additional authenticated data) прив'язує шифротекст до
// контексту — зазвичай tenant_id||kind||object_id. Без цього рядок
// із секретом можна було б переставити на інший пристрій або тенант
// прямим UPDATE у БД, і розшифрування б відпрацювало.
func (k *Keyring) Encrypt(plaintext []byte, aad string) (*Secret, error) {
keyID := k.ActiveKeyID()
if keyID == "" {
return nil, errors.New("keyring порожній")
}
aead, err := k.aead(keyID)
if err != nil {
return nil, err
}
nonce := make([]byte, NonceSize)
if _, err := rand.Read(nonce); err != nil {
return nil, err
}
sealed := aead.Seal(nil, nonce, plaintext, []byte(aad))
if len(sealed) < TagSize {
return nil, errors.New("несподівано короткий шифротекст")
}
// Схема тримає тег окремо від шифротексту, тому розділяємо:
// Go дописує його в кінець.
return &Secret{
KeyID: keyID,
Nonce: nonce,
Ciphertext: sealed[:len(sealed)-TagSize],
AuthTag: sealed[len(sealed)-TagSize:],
}, nil
}
// Decrypt розшифровує й перевіряє цілісність.
func (k *Keyring) Decrypt(s *Secret, aad string) ([]byte, error) {
if s == nil {
return nil, errors.New("порожній секрет")
}
if len(s.Nonce) != NonceSize {
return nil, fmt.Errorf("nonce має бути %d байт, отримано %d", NonceSize, len(s.Nonce))
}
if len(s.AuthTag) != TagSize {
return nil, fmt.Errorf("auth_tag має бути %d байт, отримано %d", TagSize, len(s.AuthTag))
}
aead, err := k.aead(s.KeyID)
if err != nil {
return nil, err
}
sealed := make([]byte, 0, len(s.Ciphertext)+TagSize)
sealed = append(sealed, s.Ciphertext...)
sealed = append(sealed, s.AuthTag...)
out, err := aead.Open(nil, s.Nonce, sealed, []byte(aad))
if err != nil {
// Не розкриваємо, що саме не зійшлось: помилка автентифікації
// GCM не має ставати оракулом.
return nil, errors.New("не вдалося розшифрувати секрет")
}
return out, nil
}
// GenerateKey — новий випадковий DEK.
func GenerateKey() ([]byte, error) {
key := make([]byte, KeySize)
if _, err := rand.Read(key); err != nil {
return nil, err
}
return key, nil
}
// ParseKeyring розбирає "key_id=<hex|base64>[,key_id2=...]".
//
// Той самий формат читають обидва процеси — і AgentService, і REST:
// секрети в БД спільні, тому й кільце має збиратися однаково. Порожній
// спец дає nil без помилки: не кожному процесу потрібні секрети.
func ParseKeyring(spec string) (*Keyring, error) {
if strings.TrimSpace(spec) == "" {
return nil, nil
}
ring := NewKeyring()
for _, part := range strings.Split(spec, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
id, raw, ok := strings.Cut(part, "=")
if !ok {
return nil, fmt.Errorf("некоректний запис ключа: %q", part)
}
key, err := hex.DecodeString(raw)
if err != nil {
key, err = base64.StdEncoding.DecodeString(raw)
if err != nil {
return nil, fmt.Errorf("ключ %q: не hex і не base64", id)
}
}
if err := ring.Add(id, key); err != nil {
return nil, fmt.Errorf("ключ %q: %w", id, err)
}
}
return ring, nil
}