Етап 2: серверна сторона AgentService
Автентифікація зондів за токеном, побудова TaskPlan із детермінованим schedule_offset, видача розшифрованих креденшелів із TTL, запис телеметрії в гіпертаблиці, резолвер сусідів LLDP/CDP у topo.links, прийом конфігів. Ізоляція тенантів робиться двічі — RLS плюс явний предикат tenant_id, бо RLS не працює на гіпертаблях, а саме туди йде вся телеметрія. Агент: -token і передача його в метаданих; MarkAllPending() перереєстровує серії на початку сесії замість обнуляти нумерацію й губити буфер. Перевірено на Debian 13 / PG 17.11 / TimescaleDB 2.29.1: 11 інтеграційних тестів проти живої БД (-race), плюс живий прогін справжнього агента проти справжнього сервера — телеметрія, статус пристрою, heartbeat у базі. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
c8075ee2a6
commit
8a92ef8a45
18 changed files with 3695 additions and 14 deletions
79
HISTORY.md
79
HISTORY.md
|
|
@ -189,10 +189,79 @@ Debian 13, Go 1.25.13. `go vet` чисто, `go test ./... -race` — усі п
|
|||
публічний репозиторій без автентифікації (`Credentials are incorrect`).
|
||||
Коміти лежать локально в `main` і чекають на токен.
|
||||
|
||||
### Далі (Етап 2, частина 3)
|
||||
---
|
||||
|
||||
## 2026-08-14 — Етап 2 (частина 3): серверна сторона AgentService
|
||||
|
||||
### Створено
|
||||
|
||||
```
|
||||
netpulse/server/
|
||||
├── README.md рішення, параметри, стан перевірки
|
||||
├── cmd/netpulse-server/ TLS, keepalive, m'яка зупинка, keyring із -dek
|
||||
└── internal/
|
||||
├── crypto/ AES-GCM-256, keyring із ротацією ключів
|
||||
├── store/ agents, plan, credentials, telemetry, discovery, ncm
|
||||
└── grpcapi/ AgentService + перехоплювачі автентифікації
|
||||
```
|
||||
|
||||
Плюс правки в агенті: `-token` і передача його в метаданих кожного виклику;
|
||||
`Interner.MarkAllPending()` — перереєстрація серій на початку сесії.
|
||||
|
||||
### Прийняті рішення
|
||||
|
||||
1. **Ізоляція тенантів робиться двічі:** RLS (`SET LOCAL app.tenant_id`) плюс явний
|
||||
предикат `tenant_id`. Не перестраховка: RLS не працює на гіпертаблях, а саме
|
||||
туди йде вся телеметрія.
|
||||
2. **`schedule_offset` — чиста функція від `check_id`**, тож будь-який вузол
|
||||
сервера дає те саме значення.
|
||||
3. **Хеш плану — лише з полів, що впливають на поведінку.** Зміна опису пристрою
|
||||
не змушує переливати 50 000 задач.
|
||||
4. **Токен каже, ЯКИЙ це зонд; сертифікат — що він має право говорити.** Чужий
|
||||
`agent_id` при валідному токені → `PermissionDenied`.
|
||||
5. **DEK не покидає сервер.** Дамп БД без ключів не дає жодного пароля.
|
||||
Комплект креденшелів із TTL 1 год.
|
||||
6. **Запис телеметрії — `ON CONFLICT DO NOTHING`** (at-least-once).
|
||||
7. **Статус пристрою — один запит із умовним записом в історію**, інакше два
|
||||
воркери наввипередки писали б неіснуючі переходи `up→up`.
|
||||
8. **Впевненість зіставлення спадає за надійністю ознаки:** chassis-id 95 →
|
||||
MAC 90 → IP 80 → sysName 60. Останнє низьке навмисно: sysName вводить людина.
|
||||
9. **Перереєстрація серій замість обнулення нумерації.** Спершу агент мав би
|
||||
скидати interner на реконекті, але це викидало б увесь накопичений за час
|
||||
обриву буфер — саме ті дані, заради яких він накопичувався.
|
||||
|
||||
### Перевірено на стенді
|
||||
|
||||
11 інтеграційних тестів проти **живої БД зі схемою Етапу 1** і справжнього gRPC —
|
||||
усі PASS з `-race`. Найцінніше: повтор батчу не дублює ані рядки, ані переходи в
|
||||
історії; зустрічний звіт B→A не створює другий лінк; ручний (`is_pinned`) лінк не
|
||||
затирається; тіло конфігу лежить зашифрованим.
|
||||
|
||||
**Живий наскрізний прогін** (справжній агент + справжній сервер + БД, 40 с,
|
||||
`icmp.ping` кожні 5 с проти 127.0.0.1): 5 ICMP-семплів, метрики `icmp.rtt_avg`
|
||||
0.108 мс / jitter / loss, статус `unknown → up` з причиною `icmp` і рівно одним
|
||||
переходом в історії, heartbeat із RSS 11.6 МБ і `dropped_samples=0`, зонд
|
||||
позначений `offline` після зупинки, clock skew −0.9 мс.
|
||||
|
||||
### Знайдено під час перевірки
|
||||
|
||||
Приведення типу на місці (`$1::text`) **не розв'язує** конфлікт виведення типів у
|
||||
Postgres, а нав'язує тип обом уживанням параметра. Коли `$1` потрібен і як `uuid`
|
||||
для колонки, і як `text` для конкатенації, кастувати треба протилежне уживання:
|
||||
`VALUES ($1::uuid, …, '/шлях/' || $1 || '.git')`. Та сама пастка двічі: у сіді
|
||||
тесту (`VALUES ($1, $1, …)` для `core.slug` і `text`) і в `ncm.repos`.
|
||||
|
||||
### Чого ще немає
|
||||
|
||||
- Git-двигун (libgit2): тіло конфігу шифрується в `core.secrets`,
|
||||
`commit_sha` тимчасово = hex контентного хеша. Дедуплікація й `prev_config_id`
|
||||
працюють, тож diff будується вже зараз.
|
||||
- `EnrollmentService` — зонди заводяться вставкою в `core.agents`.
|
||||
- Сервер не надсилає `TaskDelta` (лише повний план) і не ініціює `ConfigJob`.
|
||||
|
||||
### Далі
|
||||
|
||||
- Серверна сторона `AgentService` поверх схеми Етапу 1: запис телеметрії в
|
||||
hypertables, резолвер `series_ref` → `ts.series.id`, планувальник, що
|
||||
роздає `TaskPlan` із `schedule_offset`.
|
||||
- Модуль topology на агенті: LLDP/CDP/ARP/FDB → `topo.neighbors`.
|
||||
- `EnrollmentService`: видача сертифікатів зондам.
|
||||
- `EnrollmentService` + видача сертифікатів.
|
||||
- Планувальник NCM: бекап за cron і за Syslog-подією.
|
||||
- REST/WebSocket API для UI поверх тієї ж БД.
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ type Config struct {
|
|||
Endpoint string
|
||||
AgentID string
|
||||
Hostname string
|
||||
// Токен зонда, виданий при Enroll. Визначає, ЯКИЙ це зонд;
|
||||
// сертифікат mTLS — що він узагалі має право говорити з сервером.
|
||||
Token string
|
||||
|
||||
// mTLS. Порожні шляхи + Insecure = локальний стенд без шифрування.
|
||||
CertFile string
|
||||
|
|
@ -70,6 +73,7 @@ func Parse(args []string) (*Config, error) {
|
|||
fs.StringVar(&c.AgentID, "agent-id", envOr("NETPULSE_AGENT_ID", ""), "ідентифікатор зонда")
|
||||
fs.StringVar(&c.Hostname, "hostname", envOr("NETPULSE_HOSTNAME", host), "ім'я хоста для журналу сервера")
|
||||
|
||||
fs.StringVar(&c.Token, "token", envOr("NETPULSE_TOKEN", ""), "токен зонда з Enroll")
|
||||
fs.StringVar(&c.CertFile, "cert", envOr("NETPULSE_CERT", ""), "клієнтський сертифікат (mTLS)")
|
||||
fs.StringVar(&c.KeyFile, "key", envOr("NETPULSE_KEY", ""), "приватний ключ")
|
||||
fs.StringVar(&c.CAFile, "ca", envOr("NETPULSE_CA", ""), "CA сервера")
|
||||
|
|
@ -112,6 +116,9 @@ func (c *Config) validate() error {
|
|||
if c.AgentID == "" {
|
||||
return errors.New("не вказано -agent-id: зонд має бути зареєстрований через Enroll")
|
||||
}
|
||||
if c.Token == "" {
|
||||
return errors.New("не вказано -token (або NETPULSE_TOKEN)")
|
||||
}
|
||||
if !c.Insecure && (c.CertFile == "" || c.KeyFile == "") {
|
||||
return errors.New("потрібні -cert і -key для mTLS (або -insecure для локального стенду)")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -205,6 +205,11 @@ func (s *Session) runOnce(ctx context.Context) error {
|
|||
return fmt.Errorf("замість Welcome прийшло %T", first.Payload)
|
||||
}
|
||||
s.applyWelcome(welcome)
|
||||
// Таблиця series_ref на сервері живе рівно стільки, скільки сесія.
|
||||
// Перереєстровуємо всі відомі серії, замість обнуляти нумерацію:
|
||||
// інакше довелося б викинути буфер, накопичений за час обриву, —
|
||||
// саме ті дані, заради яких він і накопичувався.
|
||||
s.cfg.Buffer.Interner().MarkAllPending()
|
||||
s.connected.Store(true)
|
||||
s.log.Info("сесію встановлено",
|
||||
"session_id", welcome.SessionId,
|
||||
|
|
|
|||
|
|
@ -23,16 +23,17 @@ type Interner struct {
|
|||
next uint32
|
||||
// канонічний ключ серії → присвоєний ref
|
||||
refs map[string]uint32
|
||||
// усі створені дескриптори за ref — потрібні, щоб після
|
||||
// реконекту перереєструвати серії, не втрачаючи буфер
|
||||
descs map[uint32]*npv1.SeriesDescriptor
|
||||
// дескриптори, які ще не поїхали на сервер
|
||||
pending []*npv1.SeriesDescriptor
|
||||
// плагін-власник, потрібен лише для заповнення дескриптора
|
||||
pluginOf map[uint32]string
|
||||
}
|
||||
|
||||
func NewInterner() *Interner {
|
||||
return &Interner{
|
||||
refs: make(map[string]uint32),
|
||||
pluginOf: make(map[uint32]string),
|
||||
refs: make(map[string]uint32),
|
||||
descs: make(map[uint32]*npv1.SeriesDescriptor),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -54,7 +55,6 @@ func (i *Interner) Ref(deviceID, pluginKey string, m module.Metric) (uint32, boo
|
|||
i.next++
|
||||
ref := i.next
|
||||
i.refs[key] = ref
|
||||
i.pluginOf[ref] = pluginKey
|
||||
|
||||
// Копіюємо labels: модуль може перевикористати мапу між викликами.
|
||||
var labels map[string]string
|
||||
|
|
@ -65,7 +65,7 @@ func (i *Interner) Ref(deviceID, pluginKey string, m module.Metric) (uint32, boo
|
|||
}
|
||||
}
|
||||
|
||||
i.pending = append(i.pending, &npv1.SeriesDescriptor{
|
||||
desc := &npv1.SeriesDescriptor{
|
||||
SeriesRef: ref,
|
||||
DeviceId: deviceID,
|
||||
InterfaceId: m.InterfaceID,
|
||||
|
|
@ -73,7 +73,9 @@ func (i *Interner) Ref(deviceID, pluginKey string, m module.Metric) (uint32, boo
|
|||
MetricKey: m.MetricKey,
|
||||
Unit: m.Unit,
|
||||
Labels: labels,
|
||||
})
|
||||
}
|
||||
i.descs[ref] = desc
|
||||
i.pending = append(i.pending, desc)
|
||||
return ref, true
|
||||
}
|
||||
|
||||
|
|
@ -102,14 +104,35 @@ func (i *Interner) ReturnPending(descs []*npv1.SeriesDescriptor) {
|
|||
i.pending = append(descs, i.pending...)
|
||||
}
|
||||
|
||||
// Reset обнуляє таблицю: нова сесія або reset_series_table від сервера.
|
||||
// MarkAllPending повертає ВСІ відомі серії в чергу на відправку.
|
||||
//
|
||||
// Викликається на початку кожної нової сесії. Таблиця series_ref живе
|
||||
// на сервері рівно стільки, скільки сесія, тому після реконекту він
|
||||
// нічого не пам'ятає. Альтернатива — обнулити нумерацію на агенті —
|
||||
// означала б викинути весь накопичений за час обриву буфер: саме ті
|
||||
// дані, заради яких він і накопичувався.
|
||||
func (i *Interner) MarkAllPending() {
|
||||
i.mu.Lock()
|
||||
defer i.mu.Unlock()
|
||||
|
||||
if len(i.descs) == 0 {
|
||||
return
|
||||
}
|
||||
pending := make([]*npv1.SeriesDescriptor, 0, len(i.descs))
|
||||
for _, d := range i.descs {
|
||||
pending = append(pending, d)
|
||||
}
|
||||
i.pending = pending
|
||||
}
|
||||
|
||||
// Reset обнуляє таблицю: сервер попросив перереєстрацію з нуля.
|
||||
func (i *Interner) Reset() {
|
||||
i.mu.Lock()
|
||||
defer i.mu.Unlock()
|
||||
|
||||
i.next = 0
|
||||
i.refs = make(map[string]uint32)
|
||||
i.pluginOf = make(map[uint32]string)
|
||||
i.descs = make(map[uint32]*npv1.SeriesDescriptor)
|
||||
i.pending = nil
|
||||
}
|
||||
|
||||
|
|
|
|||
146
server/README.md
Normal file
146
server/README.md
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
# NetPulse Server — AgentService
|
||||
|
||||
Приймальна сторона: зонди підключаються сюди самі, сервер до них не ходить.
|
||||
|
||||
```bash
|
||||
go build -trimpath -ldflags "-s -w" -o netpulse-server ./cmd/netpulse-server
|
||||
```
|
||||
|
||||
```bash
|
||||
./netpulse-server -listen :9443 -dsn "postgres://..." -dek "k1=<hex32>" -cert srv.pem -key srv.key -client-ca agents-ca.pem
|
||||
```
|
||||
|
||||
## Будова
|
||||
|
||||
```
|
||||
cmd/netpulse-server/ точка входу, TLS, keepalive, m'яка зупинка
|
||||
internal/
|
||||
crypto/ AES-GCM-256 для core.secrets, keyring із ротацією
|
||||
store/
|
||||
store.go пул pgx, InTenantTx із SET LOCAL app.tenant_id
|
||||
agents.go автентифікація зонда за токеном, heartbeat, статуси задач
|
||||
plan.go побудова TaskPlan, детермінований schedule_offset, хеш плану
|
||||
credentials.go розшифровка секретів і видача комплекту з TTL
|
||||
telemetry.go резолвер серій + запис у гіпертаблиці
|
||||
discovery.go сусіди → зіставлення з інвентарем → topo.links
|
||||
ncm.go прийом конфігів, дедуплікація, шифроване тіло
|
||||
grpcapi/ AgentService: Control, StreamTelemetry, StreamLogs,
|
||||
ReportDiscovery, UploadConfig + перехоплювачі автентифікації
|
||||
```
|
||||
|
||||
## Рішення, які варто розуміти
|
||||
|
||||
**Ізоляція тенантів робиться двічі.** RLS у БД (через `SET LOCAL app.tenant_id`) плюс
|
||||
явний предикат `tenant_id` у кожному запиті. Дублювання не зайве: RLS **не працює на
|
||||
гіпертаблях** — TimescaleDB не поєднує row level security зі стисненням, а саме туди
|
||||
йде вся телеметрія. Для неї другий механізм єдиний, тому він не «про всяк випадок».
|
||||
|
||||
**`SET LOCAL`, а не `SET`.** Значення живе до кінця транзакції й не протікає на
|
||||
наступний запит, який візьме те саме з'єднання з пулу. Протікання тут означало б показ
|
||||
чужих даних.
|
||||
|
||||
**`schedule_offset` рахує сервер, детерміновано від `check_id`** (`fnv64 % interval`).
|
||||
Функція чиста, тому будь-який вузол сервера дає те саме значення, а зонд після
|
||||
перезапуску повертається у свій слот. Інакше 5000 чеків з інтервалом 60 с раз на
|
||||
хвилину били б сплеском.
|
||||
|
||||
**Хеш плану рахується з полів, що впливають на поведінку** — id, тип, параметри,
|
||||
інтервали. Зміна опису пристрою не змушує переливати 50 000 задач після кожного обриву
|
||||
зв'язку. Агент шле хеш у `Hello`, сервер відповідає `task_plan_follows=false`, якщо він
|
||||
збігся.
|
||||
|
||||
**Токен каже, ЯКИЙ це зонд; сертифікат — що він має право говорити.** Обидва потрібні.
|
||||
Зонд, який назвався чужим `agent_id` при валідному токені, отримує `PermissionDenied`:
|
||||
інакше він забрав би чужий план і чужі креденшели.
|
||||
|
||||
**Креденшели розшифровуються на сервері.** DEK не покидає сервер; у БД лише `key_id`,
|
||||
тому дамп бази без доступу до ключів не дає жодного пароля від обладнання клієнта.
|
||||
Комплект їде з TTL в годину — після відкликання доступу зонд перестане ним
|
||||
користуватись сам, навіть якщо зв'язок із ним втрачено.
|
||||
|
||||
**Один нечитабельний секрет не рушить весь комплект.** Решта пристроїв мусить
|
||||
опитуватись далі.
|
||||
|
||||
**Запис телеметрії — `ON CONFLICT DO NOTHING`.** Семантика доставки at-least-once, тож
|
||||
повторний батч після реконекту не має ані падати, ані дублювати рядки. Первинні ключі
|
||||
`(ts, device_id)`, `(ts, interface_id)`, `(ts, series_id)` роблять це безпечним.
|
||||
|
||||
**Статус пристрою змінюється одним запитом із умовним записом в історію.** Не
|
||||
read-modify-write: два воркери, що обробляють сусідні батчі, наввипередки писали б
|
||||
неіснуючі переходи `up→up`.
|
||||
|
||||
**Автовиявлення інтерпретує сервер.** Агент доповідає лише «на порту X бачу chassis Y».
|
||||
Впевненість зіставлення спадає за надійністю ознаки: chassis-id (95) → MAC (90) →
|
||||
IP керування (80) → sysName (60). Останнє низьке навмисно: `sysName` вводить людина, і
|
||||
на двох комутаторах цілком може бути `switch`. Лінк із `is_pinned` автовиявлення не
|
||||
чіпає — інакше кожен запуск затирав би ручні правки.
|
||||
|
||||
## Параметри
|
||||
|
||||
| Прапорець | Змінна | Призначення |
|
||||
|-----------|--------|-------------|
|
||||
| `-listen` | `NETPULSE_LISTEN` | адреса gRPC, типово `:9443` |
|
||||
| `-dsn` | `NETPULSE_DSN` | PostgreSQL |
|
||||
| `-dek` | `NETPULSE_DEK` | ключі шифрування `id=<hex\|base64>[,...]` |
|
||||
| `-cert` / `-key` | `NETPULSE_CERT` / `_KEY` | сертифікат сервера |
|
||||
| `-client-ca` | `NETPULSE_CLIENT_CA` | CA зондів; вмикає mTLS |
|
||||
| `-insecure` | `NETPULSE_INSECURE=1` | без TLS, лише локальний стенд |
|
||||
|
||||
## Стан перевірки
|
||||
|
||||
Стенд Debian 13 / PostgreSQL 17.11 / TimescaleDB 2.29.1 / Go 1.25.
|
||||
`go vet` чисто, `go test -race` — усі тести проходять.
|
||||
|
||||
Інтеграційні тести працюють проти **справжньої БД зі схемою Етапу 1** і справжнього
|
||||
gRPC (`NETPULSE_TEST_DSN`; без змінної пропускаються):
|
||||
|
||||
| Тест | Що доводить |
|
||||
|------|-------------|
|
||||
| `TestControlHandshake` | план зібрано з `core.checks`, offset детермінований і в межах інтервалу, креденшели розшифрувались тим самим ключем і AAD, зонд позначений online |
|
||||
| `TestControlRejectsMismatchedAgentID` | чужий `agent_id` при валідному токені відхилено |
|
||||
| `TestUnauthenticatedRejected` | без токена сесії немає |
|
||||
| `TestTelemetryPersisted` | семпл, ICMP і `util_out_pct` у гіпертаблицях; статус пристрою піднявся; **повтор батчу не продублював ані рядки, ані переходи в історії** |
|
||||
| `TestTelemetryUnknownSeriesRef` | невідомий ref → `reset_series_table`, а не тихе відкидання |
|
||||
| `TestDiscoveryResolvesLink` | сусід зіставлений за chassis-id, лінк створено з портами й `capacity_bps`; **зустрічний звіт B→A не створив дублікат** |
|
||||
| `TestDiscoveryRespectsPinnedLink` | ручний лінк не затерто |
|
||||
| `TestConfigUploadAndDedup` | чанки склеєні, тіло збережено **зашифрованим** і читається назад; повторний збір не створює версію |
|
||||
| `TestConfigUploadRejectsBadChecksum` | зіпсований конфіг не потрапляє в базу |
|
||||
| `TestHeartbeatPersisted` | самометрики в `ts.agent_health`, `dropped_samples` видно у зведенні зонда |
|
||||
| `TestPlanHashSkipsResend` | збіг хеша → сервер не шле план |
|
||||
|
||||
### Живий наскрізний прогін
|
||||
|
||||
Справжній `netpulse-agent` проти справжнього `netpulse-server` і живої БД,
|
||||
40 секунд, чек `icmp.ping` кожні 5 с проти `127.0.0.1`:
|
||||
|
||||
| Що перевірено | Результат |
|
||||
|---------------|-----------|
|
||||
| Зонд підключився, отримав план | `tasks=1, plan_unchanged=false` |
|
||||
| ICMP-семпли в `ts.icmp_samples` | 5 за 25 с — рівно за розкладом |
|
||||
| Узагальнені метрики | `icmp.rtt_avg` 0.108 мс, `icmp.jitter`, `icmp.loss_pct` |
|
||||
| Статус пристрою | `unknown → up`, причина `icmp`, **рівно один перехід в історії** |
|
||||
| Heartbeat | записано, RSS зонда 11.6 МБ, `dropped_samples=0` |
|
||||
| Версія/ОС/архітектура зонда | зафіксовані в `core.agents` |
|
||||
| Розрив сесії | зонд позначений `offline` |
|
||||
| Розсинхронізація годинника | −0.9 мс |
|
||||
|
||||
### Знайдено під час перевірки
|
||||
|
||||
Приведення типу на місці (`$1::text`) **не розв'язує** конфлікт виведення типів у
|
||||
Postgres, а нав'язує тип обом уживанням параметра. Коли `$1` потрібен і як `uuid` для
|
||||
колонки, і як `text` для конкатенації, кастувати треба протилежне уживання:
|
||||
`VALUES ($1::uuid, …, '/шлях/' || $1 || '.git')`.
|
||||
|
||||
## Чого ще немає
|
||||
|
||||
- **Git-двигун (libgit2) не підключено.** Тіло конфігу зберігається зашифрованим у
|
||||
`core.secrets`, а `ncm.configs.commit_sha` тимчасово містить hex контентного хеша.
|
||||
Дедуплікація, підрахунок рядків і ланцюжок `prev_config_id` працюють уже зараз, тож
|
||||
diff між версіями будується без Git. Коли двигун з'явиться, зміниться лише джерело
|
||||
`commit_sha`.
|
||||
- **`EnrollmentService` не реалізовано** — зонди поки заводяться вставкою в
|
||||
`core.agents` з `sha256` токена.
|
||||
- **`TaskDelta` сервер не надсилає**: при зміні плану поки йде повна синхронізація.
|
||||
Механізм на боці агента вже є.
|
||||
- **`ConfigJob` / `ConfigApplyJob` сервер не ініціює** — планувальник бекапів за cron
|
||||
і Syslog-подією ще не написаний.
|
||||
12
server/go.mod
Normal file
12
server/go.mod
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
module github.com/netpulse/netpulse/server
|
||||
|
||||
go 1.24
|
||||
|
||||
require (
|
||||
github.com/jackc/pgx/v5 v5.7.6
|
||||
github.com/netpulse/netpulse/gen/go v0.0.0
|
||||
google.golang.org/grpc v1.76.0
|
||||
google.golang.org/protobuf v1.36.12
|
||||
)
|
||||
|
||||
replace github.com/netpulse/netpulse/gen/go => ../gen/go
|
||||
64
server/go.sum
Normal file
64
server/go.sum
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
|
||||
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
|
||||
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ=
|
||||
google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
|
||||
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
||||
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
173
server/internal/crypto/secrets.go
Normal file
173
server/internal/crypto/secrets.go
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
// Package crypto — шифрування секретів для core.secrets.
|
||||
//
|
||||
// AES-GCM-256. Ключі шифрування даних (DEK) живуть поза БД: у
|
||||
// KMS/Vault або, для self-hosted, у файлі з правами 0600. У таблиці
|
||||
// лежить лише key_id, тому дамп бази без доступу до ключів не дає
|
||||
// жодного пароля від обладнання клієнта.
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"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
|
||||
}
|
||||
959
server/internal/grpcapi/integration_test.go
Normal file
959
server/internal/grpcapi/integration_test.go
Normal file
|
|
@ -0,0 +1,959 @@
|
|||
// Наскрізний тест серверної сторони: справжній PostgreSQL/TimescaleDB,
|
||||
// справжній gRPC, справжня схема з Етапу 1.
|
||||
//
|
||||
// Запуск:
|
||||
//
|
||||
// NETPULSE_TEST_DSN="postgres://netpulse:netpulse@localhost/netpulse_it" go test ./...
|
||||
//
|
||||
// Без змінної тест пропускається — щоб `go test ./...` не падав там,
|
||||
// де бази немає.
|
||||
package grpcapi_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/netpulse/netpulse/server/internal/crypto"
|
||||
"github.com/netpulse/netpulse/server/internal/grpcapi"
|
||||
"github.com/netpulse/netpulse/server/internal/store"
|
||||
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/test/bufconn"
|
||||
"google.golang.org/protobuf/types/known/durationpb"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
type fixture struct {
|
||||
pool *pgxpool.Pool
|
||||
store *store.Store
|
||||
ring *crypto.Keyring
|
||||
client npv1.AgentServiceClient
|
||||
ctx context.Context
|
||||
tenantID string
|
||||
agentID string
|
||||
deviceID string
|
||||
ifaceID string
|
||||
peerID string
|
||||
peerIfID string
|
||||
checkID string
|
||||
token string
|
||||
}
|
||||
|
||||
func setup(t *testing.T) *fixture {
|
||||
t.Helper()
|
||||
|
||||
dsn := os.Getenv("NETPULSE_TEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("NETPULSE_TEST_DSN не задано — інтеграційний тест пропущено")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
st, err := store.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("підключення до БД: %v", err)
|
||||
}
|
||||
t.Cleanup(st.Close)
|
||||
|
||||
ring := crypto.NewKeyring()
|
||||
key, err := crypto.GenerateKey()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKey: %v", err)
|
||||
}
|
||||
if err := ring.Add("test-key", key); err != nil {
|
||||
t.Fatalf("Add: %v", err)
|
||||
}
|
||||
|
||||
f := &fixture{pool: st.Pool(), store: st, ring: ring, ctx: ctx}
|
||||
f.seed(t)
|
||||
|
||||
// Сервер із тими самими перехоплювачами, що й у проді.
|
||||
svc := grpcapi.New(st, ring,
|
||||
slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn})))
|
||||
|
||||
lis := bufconn.Listen(1 << 20)
|
||||
srv := grpc.NewServer(
|
||||
grpc.ChainUnaryInterceptor(svc.UnaryInterceptor),
|
||||
grpc.ChainStreamInterceptor(svc.StreamInterceptor),
|
||||
)
|
||||
npv1.RegisterAgentServiceServer(srv, svc)
|
||||
go func() { _ = srv.Serve(lis) }()
|
||||
|
||||
conn, err := grpc.NewClient("passthrough:///bufnet",
|
||||
grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
|
||||
return lis.DialContext(ctx)
|
||||
}),
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("клієнт: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = conn.Close()
|
||||
srv.Stop()
|
||||
_ = lis.Close()
|
||||
})
|
||||
|
||||
f.client = npv1.NewAgentServiceClient(conn)
|
||||
return f
|
||||
}
|
||||
|
||||
// authCtx додає токен зонда так само, як це робить агент.
|
||||
func (f *fixture) authCtx() context.Context {
|
||||
return metadata.AppendToOutgoingContext(f.ctx, "authorization", "Bearer "+f.token)
|
||||
}
|
||||
|
||||
func (f *fixture) seed(t *testing.T) {
|
||||
t.Helper()
|
||||
ctx := f.ctx
|
||||
|
||||
slug := fmt.Sprintf("it-%d", time.Now().UnixNano())
|
||||
f.token = "np_test_" + slug
|
||||
sum := sha256.Sum256([]byte(f.token))
|
||||
|
||||
must := func(q string, args ...any) {
|
||||
t.Helper()
|
||||
if _, err := f.pool.Exec(ctx, q, args...); err != nil {
|
||||
t.Fatalf("seed %q: %v", q, err)
|
||||
}
|
||||
}
|
||||
scan := func(dest *string, q string, args ...any) {
|
||||
t.Helper()
|
||||
if err := f.pool.QueryRow(ctx, q, args...).Scan(dest); err != nil {
|
||||
t.Fatalf("seed %q: %v", q, err)
|
||||
}
|
||||
}
|
||||
|
||||
// slug має домен core.slug, name — text. Один і той самий $1 для
|
||||
// обох Postgres вивести не може, тому передаємо окремо.
|
||||
scan(&f.tenantID, `
|
||||
INSERT INTO core.tenants (slug, name, status) VALUES ($1, $2, 'active')
|
||||
RETURNING id::text`, slug, slug)
|
||||
|
||||
t.Cleanup(func() {
|
||||
// Каскади прибирають усе, крім гіпертаблиць — у них немає FK.
|
||||
_, _ = f.pool.Exec(context.Background(),
|
||||
`DELETE FROM ts.icmp_samples WHERE tenant_id = $1`, f.tenantID)
|
||||
_, _ = f.pool.Exec(context.Background(),
|
||||
`DELETE FROM ts.if_counters WHERE tenant_id = $1`, f.tenantID)
|
||||
_, _ = f.pool.Exec(context.Background(),
|
||||
`DELETE FROM ts.device_status_history WHERE tenant_id = $1`, f.tenantID)
|
||||
_, _ = f.pool.Exec(context.Background(),
|
||||
`DELETE FROM ts.agent_health WHERE tenant_id = $1`, f.tenantID)
|
||||
_, _ = f.pool.Exec(context.Background(),
|
||||
`DELETE FROM ts.samples WHERE series_id IN (SELECT id FROM ts.series WHERE tenant_id = $1)`, f.tenantID)
|
||||
_, _ = f.pool.Exec(context.Background(),
|
||||
`DELETE FROM core.tenants WHERE id = $1`, f.tenantID)
|
||||
})
|
||||
|
||||
scan(&f.agentID, `
|
||||
INSERT INTO core.agents (tenant_id, name, token_hash, status, enabled_modules)
|
||||
VALUES ($1, 'probe-it', $2, 'online', '{icmp,snmp,topology}')
|
||||
RETURNING id::text`, f.tenantID, sum[:])
|
||||
|
||||
scan(&f.deviceID, `
|
||||
INSERT INTO inv.devices (tenant_id, agent_id, name, address, kind, vendor, chassis_id, system_name)
|
||||
VALUES ($1, $2, 'core-sw-01', '10.10.0.1', 'switch', 'cisco', '0011.2233.4455', 'core-sw-01')
|
||||
RETURNING id::text`, f.tenantID, f.agentID)
|
||||
|
||||
// Сусід, якого автовиявлення має знайти.
|
||||
scan(&f.peerID, `
|
||||
INSERT INTO inv.devices (tenant_id, agent_id, name, address, kind, chassis_id, system_name)
|
||||
VALUES ($1, $2, 'edge-rtr-01', '10.10.0.2', 'router', '0011.2233.6677', 'edge-rtr-01')
|
||||
RETURNING id::text`, f.tenantID, f.agentID)
|
||||
|
||||
scan(&f.ifaceID, `
|
||||
INSERT INTO inv.interfaces (tenant_id, device_id, if_index, name, speed_bps, oper_status)
|
||||
VALUES ($1, $2, 1, 'GigabitEthernet0/1', 1000000000, 'up')
|
||||
RETURNING id::text`, f.tenantID, f.deviceID)
|
||||
|
||||
scan(&f.peerIfID, `
|
||||
INSERT INTO inv.interfaces (tenant_id, device_id, if_index, name, speed_bps, oper_status)
|
||||
VALUES ($1, $2, 1, 'ether1', 1000000000, 'up')
|
||||
RETURNING id::text`, f.tenantID, f.peerID)
|
||||
|
||||
scan(&f.checkID, `
|
||||
INSERT INTO core.checks (tenant_id, device_id, check_type, params, interval_sec, timeout_ms, retries)
|
||||
VALUES ($1, $2, 'icmp.ping', '{"count":3}'::jsonb, 60, 3000, 2)
|
||||
RETURNING id::text`, f.tenantID, f.deviceID)
|
||||
|
||||
// Креденшел зі справжнім шифруванням: тест має пройти той самий
|
||||
// шлях, що й прод, включно з AAD.
|
||||
aad := f.tenantID + "|snmp_v2c|" + f.deviceID
|
||||
sec, err := f.ring.Encrypt([]byte("public-it"), aad)
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt: %v", err)
|
||||
}
|
||||
|
||||
var secretID string
|
||||
scan(&secretID, `
|
||||
INSERT INTO core.secrets (tenant_id, kind, key_id, nonce, ciphertext, auth_tag, aad)
|
||||
VALUES ($1, 'snmp_v3', $2, $3, $4, $5, $6)
|
||||
RETURNING id::text`,
|
||||
f.tenantID, sec.KeyID, sec.Nonce, sec.Ciphertext, sec.AuthTag, aad)
|
||||
|
||||
var credID string
|
||||
scan(&credID, `
|
||||
INSERT INTO inv.credentials (tenant_id, name, proto, username, port, secret_id)
|
||||
VALUES ($1, 'snmp-ro', 'snmp_v2c', '', 161, $2)
|
||||
RETURNING id::text`, f.tenantID, secretID)
|
||||
|
||||
must(`INSERT INTO inv.device_credentials (device_id, credential_id, priority)
|
||||
VALUES ($1, $2, 10)`, f.deviceID, credID)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 1. Рукостискання, план задач, креденшели
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
func TestControlHandshake(t *testing.T) {
|
||||
f := setup(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(f.authCtx(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stream, err := f.client.Control(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Control: %v", err)
|
||||
}
|
||||
|
||||
if err := stream.Send(&npv1.ControlUp{
|
||||
Payload: &npv1.ControlUp_Hello{Hello: &npv1.Hello{
|
||||
AgentId: f.agentID,
|
||||
Hostname: "probe-it",
|
||||
Build: &npv1.AgentBuild{Version: "test", Os: "linux", Arch: "amd64"},
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("Hello: %v", err)
|
||||
}
|
||||
|
||||
down, err := stream.Recv()
|
||||
if err != nil {
|
||||
t.Fatalf("Welcome: %v", err)
|
||||
}
|
||||
w := down.GetWelcome()
|
||||
if w == nil {
|
||||
t.Fatalf("замість Welcome прийшло %T", down.Payload)
|
||||
}
|
||||
if !w.TaskPlanFollows {
|
||||
t.Fatal("сервер не збирається слати план, хоча агент прийшов без хеша")
|
||||
}
|
||||
if w.TelemetryMaxBatchSize == 0 || w.MaxConcurrentChecks == 0 {
|
||||
t.Fatalf("ліміти не заповнені: %+v", w)
|
||||
}
|
||||
|
||||
var (
|
||||
plan *npv1.TaskPlan
|
||||
creds *npv1.CredentialBundle
|
||||
mods *npv1.ModuleControl
|
||||
)
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for (plan == nil || creds == nil || mods == nil) && time.Now().Before(deadline) {
|
||||
msg, err := stream.Recv()
|
||||
if err != nil {
|
||||
t.Fatalf("Recv: %v", err)
|
||||
}
|
||||
switch p := msg.Payload.(type) {
|
||||
case *npv1.ControlDown_TaskPlan:
|
||||
plan = p.TaskPlan
|
||||
case *npv1.ControlDown_Credentials:
|
||||
creds = p.Credentials
|
||||
case *npv1.ControlDown_ModuleControl:
|
||||
mods = p.ModuleControl
|
||||
}
|
||||
}
|
||||
|
||||
if plan == nil {
|
||||
t.Fatal("план задач не надійшов")
|
||||
}
|
||||
if len(plan.Tasks) != 1 || plan.Tasks[0].CheckId != f.checkID {
|
||||
t.Fatalf("план зібрано неправильно: %+v", plan.Tasks)
|
||||
}
|
||||
task := plan.Tasks[0]
|
||||
if task.CheckType != "icmp.ping" {
|
||||
t.Fatalf("check_type = %q", task.CheckType)
|
||||
}
|
||||
if task.Interval.AsDuration() != time.Minute {
|
||||
t.Fatalf("interval = %v", task.Interval.AsDuration())
|
||||
}
|
||||
// Offset має бути в межах інтервалу — інакше задача або стартує
|
||||
// одразу разом з усіма, або з'їде в наступний цикл.
|
||||
off := task.ScheduleOffset.AsDuration()
|
||||
if off < 0 || off >= time.Minute {
|
||||
t.Fatalf("schedule_offset поза інтервалом: %v", off)
|
||||
}
|
||||
// І він має бути детермінованим: та сама функція на будь-якому вузлі.
|
||||
if want := store.ScheduleOffset(f.checkID, time.Minute); off != want {
|
||||
t.Fatalf("offset не детермінований: %v != %v", off, want)
|
||||
}
|
||||
|
||||
if len(plan.Devices) != 1 || plan.Devices[0].Address != "10.10.0.1" {
|
||||
t.Fatalf("пристрої в плані: %+v", plan.Devices)
|
||||
}
|
||||
if len(plan.PlanHash) == 0 {
|
||||
t.Fatal("план без хеша — агент не зможе уникнути перезаливки")
|
||||
}
|
||||
|
||||
if mods == nil || len(mods.Modules) == 0 {
|
||||
t.Fatal("сервер не сказав, які модулі вмикати")
|
||||
}
|
||||
|
||||
if creds == nil {
|
||||
t.Fatal("креденшели не надійшли")
|
||||
}
|
||||
list := creds.ByDevice[f.deviceID]
|
||||
if list == nil || len(list.Credentials) != 1 {
|
||||
t.Fatalf("креденшели пристрою: %+v", creds.ByDevice)
|
||||
}
|
||||
// Найважливіше: секрет розшифрувався тим самим ключем і AAD.
|
||||
if got := list.Credentials[0].GetCommunity(); got != "public-it" {
|
||||
t.Fatalf("community = %q, очікували public-it", got)
|
||||
}
|
||||
if creds.ExpiresAt == nil || creds.ExpiresAt.AsTime().Before(time.Now()) {
|
||||
t.Fatal("креденшели без дійсного TTL")
|
||||
}
|
||||
|
||||
// Зонд позначений онлайн у БД.
|
||||
var status string
|
||||
if err := f.pool.QueryRow(f.ctx,
|
||||
`SELECT status::text FROM core.agents WHERE id = $1`, f.agentID).Scan(&status); err != nil {
|
||||
t.Fatalf("статус зонда: %v", err)
|
||||
}
|
||||
if status != "online" {
|
||||
t.Fatalf("статус зонда = %q", status)
|
||||
}
|
||||
}
|
||||
|
||||
// Токен визначає зонда. Назватись чужим id не можна навіть із валідним
|
||||
// токеном — інакше один зонд отримав би план іншого.
|
||||
func TestControlRejectsMismatchedAgentID(t *testing.T) {
|
||||
f := setup(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(f.authCtx(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stream, err := f.client.Control(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Control: %v", err)
|
||||
}
|
||||
if err := stream.Send(&npv1.ControlUp{
|
||||
Payload: &npv1.ControlUp_Hello{Hello: &npv1.Hello{
|
||||
AgentId: "00000000-0000-0000-0000-0000000000ff",
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("Hello: %v", err)
|
||||
}
|
||||
if _, err := stream.Recv(); err == nil {
|
||||
t.Fatal("сервер прийняв чужий agent_id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnauthenticatedRejected(t *testing.T) {
|
||||
f := setup(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(f.ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stream, err := f.client.Control(ctx)
|
||||
if err != nil {
|
||||
return // клієнт міг відмовити одразу
|
||||
}
|
||||
_ = stream.Send(&npv1.ControlUp{Payload: &npv1.ControlUp_Hello{Hello: &npv1.Hello{}}})
|
||||
if _, err := stream.Recv(); err == nil {
|
||||
t.Fatal("сервер пустив сесію без токена")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 2. Телеметрія доходить до гіпертаблиць
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
func TestTelemetryPersisted(t *testing.T) {
|
||||
f := setup(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(f.authCtx(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stream, err := f.client.StreamTelemetry(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("StreamTelemetry: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().Truncate(time.Millisecond)
|
||||
|
||||
batch := &npv1.TelemetryBatch{
|
||||
BatchId: 1,
|
||||
AgentId: f.agentID,
|
||||
CreatedAt: timestamppb.New(now),
|
||||
NewSeries: []*npv1.SeriesDescriptor{{
|
||||
SeriesRef: 1,
|
||||
DeviceId: f.deviceID,
|
||||
PluginKey: "snmp",
|
||||
MetricKey: "cpu.util",
|
||||
Unit: "pct",
|
||||
Labels: map[string]string{"core": "0"},
|
||||
}},
|
||||
Samples: []*npv1.MetricSample{
|
||||
{SeriesRef: 1, Ts: timestamppb.New(now), Value: 37.5},
|
||||
},
|
||||
Icmp: []*npv1.IcmpResult{{
|
||||
DeviceId: f.deviceID, CheckId: f.checkID, Ts: timestamppb.New(now),
|
||||
RttAvgMs: 1.25, RttMinMs: 1.1, RttMaxMs: 1.5, JitterMs: 0.2,
|
||||
LossPct: 0, PacketsSent: 3, PacketsRecv: 3, Reachable: true,
|
||||
}},
|
||||
Interfaces: []*npv1.InterfaceCounters{{
|
||||
DeviceId: f.deviceID, InterfaceId: f.ifaceID, Ts: timestamppb.New(now),
|
||||
InOctets: 1 << 30, OutOctets: 1 << 29,
|
||||
InBps: 420e6, OutBps: 780e6,
|
||||
UtilInPct: 42, UtilOutPct: 78, OperUp: true,
|
||||
Interval: durationpb.New(time.Minute),
|
||||
}},
|
||||
}
|
||||
|
||||
if err := stream.Send(batch); err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
ack, err := stream.Recv()
|
||||
if err != nil {
|
||||
t.Fatalf("Recv ack: %v", err)
|
||||
}
|
||||
if ack.AckedThroughBatchId != 1 {
|
||||
t.Fatalf("acked = %d, помилка: %+v", ack.AckedThroughBatchId, ack.Error)
|
||||
}
|
||||
|
||||
// Семпл у ts.samples із розв'язаним series_id.
|
||||
var value float64
|
||||
var metricKey, unit string
|
||||
if err := f.pool.QueryRow(f.ctx, `
|
||||
SELECT s.value, se.metric_key, se.unit
|
||||
FROM ts.samples s JOIN ts.series se ON se.id = s.series_id
|
||||
WHERE se.tenant_id = $1 AND se.metric_key = 'cpu.util'
|
||||
ORDER BY s.ts DESC LIMIT 1`, f.tenantID).Scan(&value, &metricKey, &unit); err != nil {
|
||||
t.Fatalf("семпл не записався: %v", err)
|
||||
}
|
||||
if value != 37.5 || unit != "pct" {
|
||||
t.Fatalf("семпл спотворений: value=%v unit=%q", value, unit)
|
||||
}
|
||||
|
||||
// ICMP у широкій таблиці — те, що читає мапа.
|
||||
var rtt float32
|
||||
var reachable bool
|
||||
if err := f.pool.QueryRow(f.ctx, `
|
||||
SELECT rtt_avg_ms, reachable FROM ts.icmp_samples
|
||||
WHERE device_id = $1 ORDER BY ts DESC LIMIT 1`, f.deviceID).Scan(&rtt, &reachable); err != nil {
|
||||
t.Fatalf("ICMP не записався: %v", err)
|
||||
}
|
||||
if !reachable || rtt < 1.2 || rtt > 1.3 {
|
||||
t.Fatalf("ICMP спотворений: rtt=%v reachable=%v", rtt, reachable)
|
||||
}
|
||||
|
||||
// Завантаження інтерфейсу — джерело швидкості анімації на мапі.
|
||||
var utilOut float32
|
||||
if err := f.pool.QueryRow(f.ctx, `
|
||||
SELECT util_out_pct FROM ts.if_counters
|
||||
WHERE interface_id = $1 ORDER BY ts DESC LIMIT 1`, f.ifaceID).Scan(&utilOut); err != nil {
|
||||
t.Fatalf("лічильники не записались: %v", err)
|
||||
}
|
||||
if utilOut != 78 {
|
||||
t.Fatalf("util_out_pct = %v", utilOut)
|
||||
}
|
||||
|
||||
// Статус пристрою піднявся з ICMP і лишив слід в історії.
|
||||
var devStatus string
|
||||
if err := f.pool.QueryRow(f.ctx,
|
||||
`SELECT status::text FROM inv.devices WHERE id = $1`, f.deviceID).Scan(&devStatus); err != nil {
|
||||
t.Fatalf("статус пристрою: %v", err)
|
||||
}
|
||||
if devStatus != "up" {
|
||||
t.Fatalf("статус пристрою = %q, очікували up", devStatus)
|
||||
}
|
||||
|
||||
var transitions int
|
||||
if err := f.pool.QueryRow(f.ctx, `
|
||||
SELECT count(*) FROM ts.device_status_history
|
||||
WHERE device_id = $1 AND status = 'up'`, f.deviceID).Scan(&transitions); err != nil {
|
||||
t.Fatalf("історія станів: %v", err)
|
||||
}
|
||||
if transitions != 1 {
|
||||
t.Fatalf("переходів в історії: %d, очікували 1", transitions)
|
||||
}
|
||||
|
||||
// Повтор того самого батчу (at-least-once після реконекту) не має
|
||||
// ані падати, ані дублювати рядки.
|
||||
batch.IsRetransmit = true
|
||||
batch.BatchId = 2
|
||||
batch.NewSeries = nil
|
||||
if err := stream.Send(batch); err != nil {
|
||||
t.Fatalf("повторний Send: %v", err)
|
||||
}
|
||||
if _, err := stream.Recv(); err != nil {
|
||||
t.Fatalf("повторний ack: %v", err)
|
||||
}
|
||||
|
||||
var icmpRows int
|
||||
if err := f.pool.QueryRow(f.ctx,
|
||||
`SELECT count(*) FROM ts.icmp_samples WHERE device_id = $1`, f.deviceID).Scan(&icmpRows); err != nil {
|
||||
t.Fatalf("підрахунок: %v", err)
|
||||
}
|
||||
if icmpRows != 1 {
|
||||
t.Fatalf("повтор батчу продублював рядки: %d", icmpRows)
|
||||
}
|
||||
|
||||
// І жодного зайвого переходу в історії від повтору.
|
||||
if err := f.pool.QueryRow(f.ctx, `
|
||||
SELECT count(*) FROM ts.device_status_history WHERE device_id = $1`,
|
||||
f.deviceID).Scan(&transitions); err != nil {
|
||||
t.Fatalf("історія: %v", err)
|
||||
}
|
||||
if transitions != 1 {
|
||||
t.Fatalf("повтор додав перехід в історію: %d", transitions)
|
||||
}
|
||||
}
|
||||
|
||||
// Невідомий series_ref → чесний запит на перереєстрацію.
|
||||
func TestTelemetryUnknownSeriesRef(t *testing.T) {
|
||||
f := setup(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(f.authCtx(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stream, err := f.client.StreamTelemetry(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("StreamTelemetry: %v", err)
|
||||
}
|
||||
|
||||
if err := stream.Send(&npv1.TelemetryBatch{
|
||||
BatchId: 5, AgentId: f.agentID,
|
||||
Samples: []*npv1.MetricSample{{SeriesRef: 404, Ts: timestamppb.Now(), Value: 1}},
|
||||
}); err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
|
||||
ack, err := stream.Recv()
|
||||
if err != nil {
|
||||
t.Fatalf("Recv: %v", err)
|
||||
}
|
||||
if !ack.ResetSeriesTable {
|
||||
t.Fatal("сервер проковтнув невідомий series_ref")
|
||||
}
|
||||
if ack.Error == nil || ack.Error.Code != "unknown_series_ref" {
|
||||
t.Fatalf("немає діагностики: %+v", ack.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 3. Автовиявлення зводить сусідів у лінк
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
func TestDiscoveryResolvesLink(t *testing.T) {
|
||||
f := setup(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(f.authCtx(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
ack, err := f.client.ReportDiscovery(ctx, &npv1.DiscoveryReport{
|
||||
AgentId: f.agentID,
|
||||
Final: true,
|
||||
Neighbors: []*npv1.NeighborRecord{{
|
||||
DeviceId: f.deviceID,
|
||||
LocalInterfaceId: f.ifaceID,
|
||||
Proto: npv1.DiscoveryProto_DISCOVERY_PROTO_LLDP,
|
||||
RemoteChassisId: "0011.2233.6677",
|
||||
RemoteSystemName: "edge-rtr-01",
|
||||
RemotePortId: "ether1",
|
||||
RemoteCapabilities: []string{"bridge", "router"},
|
||||
SeenAt: timestamppb.Now(),
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ReportDiscovery: %v", err)
|
||||
}
|
||||
if !ack.Accepted {
|
||||
t.Fatalf("звіт відхилено: %+v", ack.Error)
|
||||
}
|
||||
if ack.NeighborsResolved != 1 {
|
||||
t.Fatalf("зіставлено сусідів: %d", ack.NeighborsResolved)
|
||||
}
|
||||
if ack.LinksCreated != 1 {
|
||||
t.Fatalf("створено лінків: %d", ack.LinksCreated)
|
||||
}
|
||||
|
||||
// Сусід зберігся з резолвленими посиланнями й високою впевненістю:
|
||||
// збіг за chassis-id надійніший за збіг за іменем.
|
||||
var resolvedDev, resolvedIf string
|
||||
var confidence int
|
||||
if err := f.pool.QueryRow(f.ctx, `
|
||||
SELECT resolved_device_id::text, resolved_interface_id::text, confidence
|
||||
FROM topo.neighbors WHERE device_id = $1`, f.deviceID).
|
||||
Scan(&resolvedDev, &resolvedIf, &confidence); err != nil {
|
||||
t.Fatalf("сусід не записався: %v", err)
|
||||
}
|
||||
if resolvedDev != f.peerID {
|
||||
t.Fatalf("сусід зіставлений не з тим пристроєм: %s", resolvedDev)
|
||||
}
|
||||
if resolvedIf != f.peerIfID {
|
||||
t.Fatalf("порт сусіда не зіставлений: %s", resolvedIf)
|
||||
}
|
||||
if confidence < 90 {
|
||||
t.Fatalf("впевненість збігу за chassis-id = %d, очікували >= 90", confidence)
|
||||
}
|
||||
|
||||
// Лінк створився з портами й пропускною здатністю з інтерфейсу.
|
||||
var capacity int64
|
||||
var discoveredBy string
|
||||
if err := f.pool.QueryRow(f.ctx, `
|
||||
SELECT COALESCE(capacity_bps, 0), discovered_by::text
|
||||
FROM topo.links WHERE tenant_id = $1`, f.tenantID).Scan(&capacity, &discoveredBy); err != nil {
|
||||
t.Fatalf("лінк не створився: %v", err)
|
||||
}
|
||||
if capacity != 1000000000 {
|
||||
t.Fatalf("capacity_bps = %d — анімація трафіку не матиме знаменника", capacity)
|
||||
}
|
||||
if discoveredBy != "lldp" {
|
||||
t.Fatalf("discovered_by = %q", discoveredBy)
|
||||
}
|
||||
|
||||
// Повторний звіт не має плодити другий лінк — навіть якщо сусід
|
||||
// прийшов з іншого боку (B→A).
|
||||
if _, err := f.client.ReportDiscovery(ctx, &npv1.DiscoveryReport{
|
||||
AgentId: f.agentID, Final: true,
|
||||
Neighbors: []*npv1.NeighborRecord{{
|
||||
DeviceId: f.peerID,
|
||||
LocalInterfaceId: f.peerIfID,
|
||||
Proto: npv1.DiscoveryProto_DISCOVERY_PROTO_LLDP,
|
||||
RemoteChassisId: "0011.2233.4455",
|
||||
RemotePortId: "GigabitEthernet0/1",
|
||||
SeenAt: timestamppb.Now(),
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("повторний звіт: %v", err)
|
||||
}
|
||||
|
||||
var links int
|
||||
if err := f.pool.QueryRow(f.ctx,
|
||||
`SELECT count(*) FROM topo.links WHERE tenant_id = $1`, f.tenantID).Scan(&links); err != nil {
|
||||
t.Fatalf("підрахунок лінків: %v", err)
|
||||
}
|
||||
if links != 1 {
|
||||
t.Fatalf("зустрічний звіт створив дублікат: лінків %d", links)
|
||||
}
|
||||
}
|
||||
|
||||
// Підтверджений людиною лінк не має перезаписуватись автовиявленням.
|
||||
func TestDiscoveryRespectsPinnedLink(t *testing.T) {
|
||||
f := setup(t)
|
||||
|
||||
if _, err := f.pool.Exec(f.ctx, `
|
||||
INSERT INTO topo.links
|
||||
(tenant_id, a_device_id, a_interface_id, b_device_id, b_interface_id,
|
||||
kind, discovered_by, confidence, is_pinned)
|
||||
VALUES ($1,$2,$3,$4,$5,'physical','manual',100,true)
|
||||
`, f.tenantID, f.deviceID, f.ifaceID, f.peerID, f.peerIfID); err != nil {
|
||||
t.Fatalf("seed лінка: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(f.authCtx(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if _, err := f.client.ReportDiscovery(ctx, &npv1.DiscoveryReport{
|
||||
AgentId: f.agentID, Final: true,
|
||||
Neighbors: []*npv1.NeighborRecord{{
|
||||
DeviceId: f.deviceID,
|
||||
LocalInterfaceId: f.ifaceID,
|
||||
Proto: npv1.DiscoveryProto_DISCOVERY_PROTO_CDP,
|
||||
RemoteChassisId: "0011.2233.6677",
|
||||
RemotePortId: "ether1",
|
||||
SeenAt: timestamppb.Now(),
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("ReportDiscovery: %v", err)
|
||||
}
|
||||
|
||||
var discoveredBy string
|
||||
var pinned bool
|
||||
if err := f.pool.QueryRow(f.ctx, `
|
||||
SELECT discovered_by::text, is_pinned FROM topo.links WHERE tenant_id = $1
|
||||
`, f.tenantID).Scan(&discoveredBy, &pinned); err != nil {
|
||||
t.Fatalf("лінк: %v", err)
|
||||
}
|
||||
if !pinned || discoveredBy != "manual" {
|
||||
t.Fatalf("автовиявлення затерло ручний лінк: discovered_by=%q pinned=%v",
|
||||
discoveredBy, pinned)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 4. NCM: збір конфігу та дедуплікація
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
func TestConfigUploadAndDedup(t *testing.T) {
|
||||
f := setup(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(f.authCtx(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
body := []byte("!\nhostname core-sw-01\ninterface Gi0/1\n description uplink\n!\n")
|
||||
sum := sha256.Sum256(body)
|
||||
|
||||
upload := func(jobID string) *npv1.ConfigReceipt {
|
||||
t.Helper()
|
||||
stream, err := f.client.UploadConfig(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("UploadConfig: %v", err)
|
||||
}
|
||||
if err := stream.Send(&npv1.ConfigUpload{Part: &npv1.ConfigUpload_Header{
|
||||
Header: &npv1.ConfigHeader{
|
||||
JobId: jobID, AgentId: f.agentID, DeviceId: f.deviceID,
|
||||
ConfigType: "running", CollectedAt: timestamppb.Now(), Encoding: "none",
|
||||
}}}); err != nil {
|
||||
t.Fatalf("header: %v", err)
|
||||
}
|
||||
// Два чанки, щоб перевірити склеювання.
|
||||
mid := len(body) / 2
|
||||
for i, part := range [][]byte{body[:mid], body[mid:]} {
|
||||
if err := stream.Send(&npv1.ConfigUpload{Part: &npv1.ConfigUpload_Chunk{
|
||||
Chunk: &npv1.ConfigChunk{Sequence: uint32(i), Data: part},
|
||||
}}); err != nil {
|
||||
t.Fatalf("chunk %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if err := stream.Send(&npv1.ConfigUpload{Part: &npv1.ConfigUpload_Trailer{
|
||||
Trailer: &npv1.ConfigTrailer{
|
||||
Success: true, ContentSha256: sum[:],
|
||||
SizeBytes: uint64(len(body)), ChunkCount: 2,
|
||||
}}}); err != nil {
|
||||
t.Fatalf("trailer: %v", err)
|
||||
}
|
||||
receipt, err := stream.CloseAndRecv()
|
||||
if err != nil {
|
||||
t.Fatalf("CloseAndRecv: %v", err)
|
||||
}
|
||||
return receipt
|
||||
}
|
||||
|
||||
first := upload("job-1")
|
||||
if !first.Accepted || first.Unchanged {
|
||||
t.Fatalf("перший конфіг: accepted=%v unchanged=%v err=%+v",
|
||||
first.Accepted, first.Unchanged, first.Error)
|
||||
}
|
||||
if first.ConfigId == "" {
|
||||
t.Fatal("немає config_id")
|
||||
}
|
||||
|
||||
// Другий збір того самого конфігу — не нова версія.
|
||||
second := upload("job-2")
|
||||
if !second.Accepted {
|
||||
t.Fatalf("другий конфіг відхилено: %+v", second.Error)
|
||||
}
|
||||
if !second.Unchanged {
|
||||
t.Fatal("незмінений конфіг створив нову версію — історія засмітиться")
|
||||
}
|
||||
|
||||
var versions int
|
||||
if err := f.pool.QueryRow(f.ctx,
|
||||
`SELECT count(*) FROM ncm.configs WHERE device_id = $1`, f.deviceID).Scan(&versions); err != nil {
|
||||
t.Fatalf("підрахунок версій: %v", err)
|
||||
}
|
||||
if versions != 1 {
|
||||
t.Fatalf("версій у ncm.configs: %d, очікували 1", versions)
|
||||
}
|
||||
|
||||
// Тіло збережене зашифрованим і читається назад тим самим ключем.
|
||||
var keyID, aad string
|
||||
var nonce, ct, tag []byte
|
||||
if err := f.pool.QueryRow(f.ctx, `
|
||||
SELECT s.key_id, s.nonce, s.ciphertext, s.auth_tag, COALESCE(s.aad,'')
|
||||
FROM ncm.configs c JOIN core.secrets s ON s.id = c.body_secret_id
|
||||
WHERE c.device_id = $1`, f.deviceID).Scan(&keyID, &nonce, &ct, &tag, &aad); err != nil {
|
||||
t.Fatalf("зашифроване тіло: %v", err)
|
||||
}
|
||||
plain, err := f.ring.Decrypt(&crypto.Secret{
|
||||
KeyID: keyID, Nonce: nonce, Ciphertext: ct, AuthTag: tag,
|
||||
}, aad)
|
||||
if err != nil {
|
||||
t.Fatalf("розшифрування тіла: %v", err)
|
||||
}
|
||||
if string(plain) != string(body) {
|
||||
t.Fatal("тіло конфігу спотворилось при збереженні")
|
||||
}
|
||||
|
||||
// І воно точно не лежить відкритим текстом.
|
||||
if string(ct) == string(body) {
|
||||
t.Fatal("конфіг збережено без шифрування")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigUploadRejectsBadChecksum(t *testing.T) {
|
||||
f := setup(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(f.authCtx(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stream, err := f.client.UploadConfig(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("UploadConfig: %v", err)
|
||||
}
|
||||
|
||||
body := []byte("hostname core-sw-01\n")
|
||||
bogus := sha256.Sum256([]byte("щось інше"))
|
||||
|
||||
_ = stream.Send(&npv1.ConfigUpload{Part: &npv1.ConfigUpload_Header{
|
||||
Header: &npv1.ConfigHeader{JobId: "job-x", DeviceId: f.deviceID, ConfigType: "running"}}})
|
||||
_ = stream.Send(&npv1.ConfigUpload{Part: &npv1.ConfigUpload_Chunk{
|
||||
Chunk: &npv1.ConfigChunk{Sequence: 0, Data: body}}})
|
||||
_ = stream.Send(&npv1.ConfigUpload{Part: &npv1.ConfigUpload_Trailer{
|
||||
Trailer: &npv1.ConfigTrailer{Success: true, ContentSha256: bogus[:],
|
||||
SizeBytes: uint64(len(body)), ChunkCount: 1}}})
|
||||
|
||||
receipt, err := stream.CloseAndRecv()
|
||||
if err != nil {
|
||||
t.Fatalf("CloseAndRecv: %v", err)
|
||||
}
|
||||
if receipt.Accepted {
|
||||
t.Fatal("прийнято конфіг із розбіжною сумою")
|
||||
}
|
||||
if receipt.Error == nil || receipt.Error.Code != "checksum_mismatch" {
|
||||
t.Fatalf("немає причини відмови: %+v", receipt.Error)
|
||||
}
|
||||
|
||||
var versions int
|
||||
_ = f.pool.QueryRow(f.ctx,
|
||||
`SELECT count(*) FROM ncm.configs WHERE device_id = $1`, f.deviceID).Scan(&versions)
|
||||
if versions != 0 {
|
||||
t.Fatalf("зіпсований конфіг усе одно збережено: версій %d", versions)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 5. Heartbeat лягає в самометрики
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
func TestHeartbeatPersisted(t *testing.T) {
|
||||
f := setup(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(f.authCtx(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stream, err := f.client.Control(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Control: %v", err)
|
||||
}
|
||||
if err := stream.Send(&npv1.ControlUp{
|
||||
Payload: &npv1.ControlUp_Hello{Hello: &npv1.Hello{AgentId: f.agentID}},
|
||||
}); err != nil {
|
||||
t.Fatalf("Hello: %v", err)
|
||||
}
|
||||
if _, err := stream.Recv(); err != nil {
|
||||
t.Fatalf("Welcome: %v", err)
|
||||
}
|
||||
|
||||
if err := stream.Send(&npv1.ControlUp{
|
||||
Payload: &npv1.ControlUp_Heartbeat{Heartbeat: &npv1.Heartbeat{
|
||||
Ts: timestamppb.Now(),
|
||||
Health: &npv1.AgentHealth{
|
||||
CpuPct: 2.5, RssBytes: 12 << 20, Goroutines: 48,
|
||||
QueueDepth: 3, DroppedSamples: 7,
|
||||
},
|
||||
TasksRunning: 1,
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("Heartbeat: %v", err)
|
||||
}
|
||||
|
||||
// Дочекаємось Ping у відповідь — він приходить після запису.
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
msg, err := stream.Recv()
|
||||
if err != nil {
|
||||
t.Fatalf("Recv: %v", err)
|
||||
}
|
||||
if msg.GetPing() != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var rss int64
|
||||
if err := f.pool.QueryRow(f.ctx, `
|
||||
SELECT rss_bytes FROM ts.agent_health WHERE agent_id = $1
|
||||
ORDER BY ts DESC LIMIT 1`, f.agentID).Scan(&rss); err != nil {
|
||||
t.Fatalf("самометрики не записались: %v", err)
|
||||
}
|
||||
if rss != 12<<20 {
|
||||
t.Fatalf("rss_bytes = %d", rss)
|
||||
}
|
||||
|
||||
// Втрачені семпли мають бути видимі оператору у зведенні зонда,
|
||||
// а не тільки в графіку.
|
||||
var health []byte
|
||||
if err := f.pool.QueryRow(f.ctx,
|
||||
`SELECT health::text FROM core.agents WHERE id = $1`, f.agentID).Scan(&health); err != nil {
|
||||
t.Fatalf("зведення: %v", err)
|
||||
}
|
||||
var h map[string]any
|
||||
if err := json.Unmarshal(health, &h); err != nil {
|
||||
t.Fatalf("health не JSON: %v", err)
|
||||
}
|
||||
if h["dropped_samples"] == nil || fmt.Sprint(h["dropped_samples"]) != "7" {
|
||||
t.Fatalf("dropped_samples не потрапило у зведення: %v", h)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 6. Хеш плану дозволяє не переливати задачі після реконекту
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
func TestPlanHashSkipsResend(t *testing.T) {
|
||||
f := setup(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(f.authCtx(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Перше підключення: дізнаємось хеш.
|
||||
plan, err := f.store.BuildPlan(f.ctx, &store.Agent{ID: f.agentID, TenantID: f.tenantID})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildPlan: %v", err)
|
||||
}
|
||||
if len(plan.PlanHash) == 0 {
|
||||
t.Fatal("план без хеша")
|
||||
}
|
||||
|
||||
stream, err := f.client.Control(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Control: %v", err)
|
||||
}
|
||||
if err := stream.Send(&npv1.ControlUp{
|
||||
Payload: &npv1.ControlUp_Hello{Hello: &npv1.Hello{
|
||||
AgentId: f.agentID, TaskPlanHash: plan.PlanHash,
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("Hello: %v", err)
|
||||
}
|
||||
|
||||
down, err := stream.Recv()
|
||||
if err != nil {
|
||||
t.Fatalf("Welcome: %v", err)
|
||||
}
|
||||
if down.GetWelcome().GetTaskPlanFollows() {
|
||||
t.Fatal("сервер збирається слати план, хоча хеш збігся")
|
||||
}
|
||||
|
||||
// Хеш детермінований: та сама база — той самий хеш.
|
||||
again, err := f.store.BuildPlan(f.ctx, &store.Agent{ID: f.agentID, TenantID: f.tenantID})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildPlan: %v", err)
|
||||
}
|
||||
if hex.EncodeToString(again.PlanHash) != hex.EncodeToString(plan.PlanHash) {
|
||||
t.Fatal("хеш плану не детермінований")
|
||||
}
|
||||
}
|
||||
363
server/internal/grpcapi/service.go
Normal file
363
server/internal/grpcapi/service.go
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
// Package grpcapi — серверна реалізація AgentService.
|
||||
//
|
||||
// Сервер ніколи не ініціює з'єднання до зонда: у мережі клієнта немає
|
||||
// ані відкритих портів, ані прокидання NAT. Тому все, що виглядає як
|
||||
// «команда з сервера», надсилається у зустрічному напрямку стріму
|
||||
// Control, який відкрив сам агент.
|
||||
package grpcapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/netpulse/netpulse/server/internal/crypto"
|
||||
"github.com/netpulse/netpulse/server/internal/store"
|
||||
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/durationpb"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
type ctxKey string
|
||||
|
||||
const agentCtxKey ctxKey = "netpulse.agent"
|
||||
|
||||
// Service реалізує npv1.AgentServiceServer.
|
||||
type Service struct {
|
||||
npv1.UnimplementedAgentServiceServer
|
||||
|
||||
store *store.Store
|
||||
ring *crypto.Keyring
|
||||
log *slog.Logger
|
||||
|
||||
// Живі сесії за agent_id. Потрібні, щоб штовхнути зонду
|
||||
// TaskDelta або ConfigJob, коли щось змінилось в UI.
|
||||
mu sync.RWMutex
|
||||
sessions map[string]*agentSession
|
||||
}
|
||||
|
||||
type agentSession struct {
|
||||
agent *store.Agent
|
||||
out chan *npv1.ControlDown
|
||||
series *store.SeriesTable
|
||||
opened time.Time
|
||||
}
|
||||
|
||||
func New(st *store.Store, ring *crypto.Keyring, log *slog.Logger) *Service {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &Service{
|
||||
store: st,
|
||||
ring: ring,
|
||||
log: log,
|
||||
sessions: make(map[string]*agentSession),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Автентифікація
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// StreamInterceptor і UnaryInterceptor автентифікують зонда за токеном
|
||||
// у метаданих. У проді поверх цього ще mTLS: токен визначає, ЯКИЙ це
|
||||
// зонд, сертифікат — що він узагалі має право говорити з сервером.
|
||||
func (s *Service) StreamInterceptor(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
|
||||
agent, err := s.authenticate(ss.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return handler(srv, &wrappedStream{ServerStream: ss,
|
||||
ctx: context.WithValue(ss.Context(), agentCtxKey, agent)})
|
||||
}
|
||||
|
||||
func (s *Service) UnaryInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
|
||||
agent, err := s.authenticate(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return handler(context.WithValue(ctx, agentCtxKey, agent), req)
|
||||
}
|
||||
|
||||
type wrappedStream struct {
|
||||
grpc.ServerStream
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func (w *wrappedStream) Context() context.Context { return w.ctx }
|
||||
|
||||
func (s *Service) authenticate(ctx context.Context) (*store.Agent, error) {
|
||||
md, ok := metadata.FromIncomingContext(ctx)
|
||||
if !ok {
|
||||
return nil, status.Error(codes.Unauthenticated, "немає метаданих")
|
||||
}
|
||||
|
||||
var token string
|
||||
for _, v := range md.Get("authorization") {
|
||||
if after, found := strings.CutPrefix(v, "Bearer "); found {
|
||||
token = after
|
||||
break
|
||||
}
|
||||
}
|
||||
if token == "" {
|
||||
return nil, status.Error(codes.Unauthenticated, "немає токена зонда")
|
||||
}
|
||||
|
||||
agent, err := s.store.AuthenticateAgent(ctx, token)
|
||||
if errors.Is(err, store.ErrAgentNotFound) {
|
||||
return nil, status.Error(codes.Unauthenticated, "зонда не знайдено")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.Internal, "помилка автентифікації")
|
||||
}
|
||||
return agent, nil
|
||||
}
|
||||
|
||||
func agentFrom(ctx context.Context) (*store.Agent, error) {
|
||||
a, ok := ctx.Value(agentCtxKey).(*store.Agent)
|
||||
if !ok || a == nil {
|
||||
return nil, status.Error(codes.Unauthenticated, "сесія без зонда")
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Control
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
func (s *Service) Control(stream npv1.AgentService_ControlServer) error {
|
||||
ctx := stream.Context()
|
||||
agent, err := agentFrom(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
first, err := stream.Recv()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hello := first.GetHello()
|
||||
if hello == nil {
|
||||
return status.Error(codes.InvalidArgument, "перше повідомлення має бути Hello")
|
||||
}
|
||||
if hello.GetAgentId() != "" && hello.GetAgentId() != agent.ID {
|
||||
// Токен належить одному зонду, а він назвався іншим.
|
||||
return status.Error(codes.PermissionDenied, "agent_id не збігається з токеном")
|
||||
}
|
||||
|
||||
if err := s.store.MarkAgentOnline(ctx, agent, hello); err != nil {
|
||||
s.log.Warn("не вдалося позначити зонд онлайн", "agent", agent.ID, "err", err)
|
||||
}
|
||||
|
||||
sess := &agentSession{
|
||||
agent: agent,
|
||||
out: make(chan *npv1.ControlDown, 64),
|
||||
series: store.NewSeriesTable(),
|
||||
opened: time.Now(),
|
||||
}
|
||||
s.register(sess)
|
||||
defer func() {
|
||||
s.unregister(agent.ID)
|
||||
if err := s.store.MarkAgentOffline(context.WithoutCancel(ctx), agent); err != nil {
|
||||
s.log.Warn("не вдалося позначити зонд офлайн", "agent", agent.ID, "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
plan, err := s.store.BuildPlan(ctx, agent)
|
||||
if err != nil {
|
||||
return status.Errorf(codes.Internal, "побудова плану: %v", err)
|
||||
}
|
||||
|
||||
// Якщо хеш збігся — план у агента вже правильний, і 50 000 задач
|
||||
// після кожного обриву зв'язку переливати не треба.
|
||||
planUnchanged := len(hello.GetTaskPlanHash()) > 0 &&
|
||||
equalBytes(hello.GetTaskPlanHash(), plan.GetPlanHash())
|
||||
|
||||
welcome := &npv1.Welcome{
|
||||
SessionId: agent.ID + "@" + time.Now().UTC().Format(time.RFC3339Nano),
|
||||
ServerTime: timestamppb.Now(),
|
||||
HeartbeatInterval: durationpb.New(agent.Limits.HeartbeatEvery),
|
||||
TelemetryMaxBatchSize: uint32(agent.Limits.BatchSize),
|
||||
TelemetryMaxBatchInterval: durationpb.New(agent.Limits.BatchInterval),
|
||||
TelemetryMaxInFlight: uint32(agent.Limits.MaxInFlight),
|
||||
MaxConcurrentChecks: uint32(agent.Limits.MaxConcurrency),
|
||||
IcmpRatePps: uint32(agent.Limits.IcmpRatePPS),
|
||||
TaskPlanFollows: !planUnchanged,
|
||||
}
|
||||
if err := stream.Send(&npv1.ControlDown{
|
||||
Seq: 1, Payload: &npv1.ControlDown_Welcome{Welcome: welcome},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Єдиний писар: gRPC не допускає паралельних Send на одному стрімі,
|
||||
// а слати треба і план, і креденшели, і пуші з UI.
|
||||
writerDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(writerDone)
|
||||
var seq uint64 = 1
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case msg, ok := <-sess.out:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
seq++
|
||||
msg.Seq = seq
|
||||
if err := stream.Send(msg); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
s.push(sess, &npv1.ControlDown{
|
||||
Payload: &npv1.ControlDown_ModuleControl{
|
||||
ModuleControl: store.ModulesForPlan(plan, agent.Modules)},
|
||||
})
|
||||
|
||||
bundle, err := s.store.BuildCredentialBundle(ctx, agent, s.ring)
|
||||
if err != nil {
|
||||
s.log.Warn("не вдалося зібрати креденшели", "agent", agent.ID, "err", err)
|
||||
} else if len(bundle.ByDevice) > 0 {
|
||||
s.push(sess, &npv1.ControlDown{
|
||||
Payload: &npv1.ControlDown_Credentials{Credentials: bundle},
|
||||
})
|
||||
}
|
||||
|
||||
if !planUnchanged {
|
||||
s.push(sess, &npv1.ControlDown{
|
||||
Payload: &npv1.ControlDown_TaskPlan{TaskPlan: plan},
|
||||
})
|
||||
}
|
||||
|
||||
s.log.Info("зонд підключився",
|
||||
"agent", agent.ID, "tenant", agent.TenantID,
|
||||
"tasks", len(plan.GetTasks()), "plan_unchanged", planUnchanged)
|
||||
|
||||
err = s.readControl(ctx, stream, agent, sess)
|
||||
close(sess.out)
|
||||
<-writerDone
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) readControl(ctx context.Context, stream npv1.AgentService_ControlServer, agent *store.Agent, sess *agentSession) error {
|
||||
for {
|
||||
msg, err := stream.Recv()
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch p := msg.Payload.(type) {
|
||||
case *npv1.ControlUp_Heartbeat:
|
||||
if err := s.store.RecordHeartbeat(ctx, agent, p.Heartbeat); err != nil {
|
||||
s.log.Warn("heartbeat не записався", "agent", agent.ID, "err", err)
|
||||
}
|
||||
// Ping у відповідь дає обом сторонам свіжий clock skew.
|
||||
s.push(sess, &npv1.ControlDown{
|
||||
Payload: &npv1.ControlDown_Ping{Ping: &npv1.Ping{
|
||||
PingId: uint64(time.Now().UnixNano()), ServerTime: timestamppb.Now(),
|
||||
}},
|
||||
})
|
||||
|
||||
case *npv1.ControlUp_TaskStatus:
|
||||
if err := s.store.RecordTaskStatus(ctx, agent, p.TaskStatus); err != nil {
|
||||
s.log.Warn("статус задачі не записався", "agent", agent.ID, "err", err)
|
||||
}
|
||||
|
||||
case *npv1.ControlUp_CredentialRequest:
|
||||
bundle, err := s.store.BuildCredentialBundle(ctx, agent, s.ring)
|
||||
if err != nil {
|
||||
s.log.Warn("повторна видача креденшелів", "agent", agent.ID, "err", err)
|
||||
continue
|
||||
}
|
||||
s.push(sess, &npv1.ControlDown{
|
||||
Payload: &npv1.ControlDown_Credentials{Credentials: bundle},
|
||||
})
|
||||
|
||||
case *npv1.ControlUp_Event:
|
||||
s.log.Info("подія зонда",
|
||||
"agent", agent.ID, "kind", p.Event.GetKind().String(),
|
||||
"message", p.Event.GetMessage())
|
||||
|
||||
case *npv1.ControlUp_Pong:
|
||||
// Нічого не робимо: сам факт відповіді підтверджує, що
|
||||
// канал живий, а це вже зафіксував транспорт.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// push кладе повідомлення в чергу писаря. Черга переповнилась —
|
||||
// зонд не встигає читати; краще втратити пуш, ніж заблокувати
|
||||
// обробку всіх інших зондів на цьому воркері.
|
||||
func (s *Service) push(sess *agentSession, msg *npv1.ControlDown) {
|
||||
select {
|
||||
case sess.out <- msg:
|
||||
default:
|
||||
s.log.Warn("черга контрольного каналу переповнена", "agent", sess.agent.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) register(sess *agentSession) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if old, ok := s.sessions[sess.agent.ID]; ok {
|
||||
// Той самий зонд відкрив другу сесію: стара мертва, просто
|
||||
// ще не помітила. Закривати її чергу тут не можна — це
|
||||
// зробить її власний defer.
|
||||
s.log.Warn("друга сесія того самого зонда", "agent", sess.agent.ID,
|
||||
"попередня_відкрита", old.opened)
|
||||
}
|
||||
s.sessions[sess.agent.ID] = sess
|
||||
}
|
||||
|
||||
func (s *Service) unregister(agentID string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.sessions, agentID)
|
||||
}
|
||||
|
||||
// SessionCount — скільки зондів онлайн просто зараз.
|
||||
func (s *Service) SessionCount() int {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return len(s.sessions)
|
||||
}
|
||||
|
||||
// PushToAgent надсилає команду живій сесії зонда. Використовується
|
||||
// з боку API, коли в UI щось змінили.
|
||||
func (s *Service) PushToAgent(agentID string, msg *npv1.ControlDown) bool {
|
||||
s.mu.RLock()
|
||||
sess, ok := s.sessions[agentID]
|
||||
s.mu.RUnlock()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
s.push(sess, msg)
|
||||
return true
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
268
server/internal/grpcapi/streams.go
Normal file
268
server/internal/grpcapi/streams.go
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
package grpcapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
|
||||
"github.com/netpulse/netpulse/server/internal/store"
|
||||
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Телеметрія
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
func (s *Service) StreamTelemetry(stream npv1.AgentService_StreamTelemetryServer) error {
|
||||
ctx := stream.Context()
|
||||
agent, err := agentFrom(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Таблиця series_ref живе рівно стільки, скільки цей стрім.
|
||||
// Агент знає про це й перереєстровує серії на початку кожної сесії.
|
||||
table := store.NewSeriesTable()
|
||||
|
||||
var acked uint64
|
||||
|
||||
for {
|
||||
batch, err := stream.Recv()
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
st, writeErr := s.store.WriteBatch(ctx, agent, batch, table)
|
||||
|
||||
var unknown *store.ErrUnknownSeriesRef
|
||||
if errors.As(writeErr, &unknown) {
|
||||
// Ми загубили відповідність — чесно просимо перереєстрацію
|
||||
// замість тихо викинути дані.
|
||||
s.log.Warn("невідомий series_ref", "agent", agent.ID, "ref", unknown.Ref)
|
||||
if err := stream.Send(&npv1.TelemetryAck{
|
||||
AckedThroughBatchId: acked,
|
||||
ResetSeriesTable: true,
|
||||
MaxInFlight: uint32(agent.Limits.MaxInFlight),
|
||||
Error: &npv1.Error{
|
||||
Code: "unknown_series_ref",
|
||||
Message: unknown.Error(),
|
||||
Retryable: true,
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if writeErr != nil {
|
||||
s.log.Error("батч не записався", "agent", agent.ID,
|
||||
"batch", batch.GetBatchId(), "err", writeErr)
|
||||
// Батч не підтверджуємо: агент перешле його після
|
||||
// реконекту, а ON CONFLICT DO NOTHING зробить це безпечним.
|
||||
if err := stream.Send(&npv1.TelemetryAck{
|
||||
AckedThroughBatchId: acked,
|
||||
NackBatchIds: []uint64{batch.GetBatchId()},
|
||||
MaxInFlight: uint32(agent.Limits.MaxInFlight),
|
||||
Error: &npv1.Error{
|
||||
Code: "write_failed", Message: writeErr.Error(), Retryable: true,
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if batch.GetBatchId() > acked {
|
||||
acked = batch.GetBatchId()
|
||||
}
|
||||
|
||||
s.log.Debug("батч записано", "agent", agent.ID, "batch", batch.GetBatchId(),
|
||||
"samples", st.Samples, "icmp", st.Icmp, "interfaces", st.Interfaces)
|
||||
|
||||
if err := stream.Send(&npv1.TelemetryAck{
|
||||
AckedThroughBatchId: acked,
|
||||
MaxInFlight: uint32(agent.Limits.MaxInFlight),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Журнали
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
func (s *Service) StreamLogs(stream npv1.AgentService_StreamLogsServer) error {
|
||||
ctx := stream.Context()
|
||||
agent, err := agentFrom(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var acked uint64
|
||||
for {
|
||||
batch, err := stream.Recv()
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := s.store.WriteLogs(ctx, agent, batch); err != nil {
|
||||
s.log.Error("журнали не записались", "agent", agent.ID, "err", err)
|
||||
if err := stream.Send(&npv1.LogAck{
|
||||
AckedThroughBatchId: acked,
|
||||
Error: &npv1.Error{Code: "write_failed", Message: err.Error(), Retryable: true},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if batch.GetDropped() > 0 {
|
||||
s.log.Warn("зонд відкинув події через ліміт",
|
||||
"agent", agent.ID, "dropped", batch.GetDropped())
|
||||
}
|
||||
if batch.GetBatchId() > acked {
|
||||
acked = batch.GetBatchId()
|
||||
}
|
||||
|
||||
if err := stream.Send(&npv1.LogAck{AckedThroughBatchId: acked}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Автовиявлення
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
func (s *Service) ReportDiscovery(ctx context.Context, rep *npv1.DiscoveryReport) (*npv1.DiscoveryAck, error) {
|
||||
agent, err := agentFrom(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
st, err := s.store.ApplyDiscovery(ctx, agent, rep)
|
||||
if err != nil {
|
||||
s.log.Error("звіт автовиявлення не застосувався", "agent", agent.ID, "err", err)
|
||||
return &npv1.DiscoveryAck{
|
||||
Accepted: false,
|
||||
Error: &npv1.Error{Code: "apply_failed", Message: err.Error(), Retryable: true},
|
||||
}, nil
|
||||
}
|
||||
|
||||
s.log.Info("автовиявлення застосовано",
|
||||
"agent", agent.ID,
|
||||
"сусідів", st.NeighborsSeen, "зіставлено", st.NeighborsResolved,
|
||||
"лінків_створено", st.LinksCreated, "лінків_оновлено", st.LinksUpdated)
|
||||
|
||||
return &npv1.DiscoveryAck{
|
||||
Accepted: true,
|
||||
NeighborsResolved: uint32(st.NeighborsResolved),
|
||||
LinksCreated: uint32(st.LinksCreated),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Конфігурації
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// UploadConfig збирає конфіг із чанків: header → chunk* → trailer.
|
||||
func (s *Service) UploadConfig(stream npv1.AgentService_UploadConfigServer) error {
|
||||
ctx := stream.Context()
|
||||
agent, err := agentFrom(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var (
|
||||
header *npv1.ConfigHeader
|
||||
body []byte
|
||||
wantSeq uint32
|
||||
)
|
||||
|
||||
for {
|
||||
msg, err := stream.Recv()
|
||||
if errors.Is(err, io.EOF) {
|
||||
return status.Error(codes.InvalidArgument, "стрім завершився без trailer")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch p := msg.Part.(type) {
|
||||
case *npv1.ConfigUpload_Header:
|
||||
header = p.Header
|
||||
|
||||
case *npv1.ConfigUpload_Chunk:
|
||||
if header == nil {
|
||||
return status.Error(codes.InvalidArgument, "chunk раніше за header")
|
||||
}
|
||||
// Порядок важливий: конфіг склеюється байт-у-байт, і
|
||||
// переставлені чанки дали б тихо зіпсований бекап.
|
||||
if p.Chunk.GetSequence() != wantSeq {
|
||||
return status.Errorf(codes.InvalidArgument,
|
||||
"чанки не по порядку: чекали %d, отримали %d", wantSeq, p.Chunk.GetSequence())
|
||||
}
|
||||
wantSeq++
|
||||
body = append(body, p.Chunk.GetData()...)
|
||||
|
||||
case *npv1.ConfigUpload_Trailer:
|
||||
if header == nil {
|
||||
return status.Error(codes.InvalidArgument, "trailer без header")
|
||||
}
|
||||
tr := p.Trailer
|
||||
|
||||
if !tr.GetSuccess() {
|
||||
s.log.Warn("зонд не зміг зібрати конфіг",
|
||||
"agent", agent.ID, "job", header.GetJobId(),
|
||||
"err", tr.GetError().GetMessage())
|
||||
return stream.SendAndClose(&npv1.ConfigReceipt{
|
||||
JobId: header.GetJobId(), Accepted: false, Error: tr.GetError(),
|
||||
})
|
||||
}
|
||||
|
||||
outcome, err := s.store.StoreConfig(ctx, agent, store.ConfigSubmission{
|
||||
JobID: header.GetJobId(),
|
||||
DeviceID: header.GetDeviceId(),
|
||||
ConfigType: header.GetConfigType(),
|
||||
Body: body,
|
||||
ClaimedHash: tr.GetContentSha256(),
|
||||
}, s.ring)
|
||||
if err != nil {
|
||||
return status.Errorf(codes.Internal, "збереження конфігу: %v", err)
|
||||
}
|
||||
if errors.Is(outcome.Err, store.ErrChecksumMismatch) {
|
||||
s.log.Warn("конфіг із розбіжною контрольною сумою відхилено",
|
||||
"agent", agent.ID, "device", header.GetDeviceId())
|
||||
return stream.SendAndClose(&npv1.ConfigReceipt{
|
||||
JobId: header.GetJobId(),
|
||||
Accepted: false,
|
||||
Error: &npv1.Error{
|
||||
Code: "checksum_mismatch", Retryable: true,
|
||||
Message: "тіло не відповідає заявленому sha256",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
s.log.Info("конфіг прийнято",
|
||||
"agent", agent.ID, "device", header.GetDeviceId(),
|
||||
"розмір", len(body), "без_змін", outcome.Unchanged)
|
||||
|
||||
return stream.SendAndClose(&npv1.ConfigReceipt{
|
||||
JobId: header.GetJobId(),
|
||||
Accepted: outcome.Accepted,
|
||||
Unchanged: outcome.Unchanged,
|
||||
ConfigId: outcome.ConfigID,
|
||||
CommitSha: outcome.CommitSHA,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
227
server/internal/store/agents.go
Normal file
227
server/internal/store/agents.go
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
|
||||
)
|
||||
|
||||
var ErrAgentNotFound = errors.New("зонд не знайдено або токен відкликано")
|
||||
|
||||
// Agent — ідентичність зонда після автентифікації.
|
||||
type Agent struct {
|
||||
ID string
|
||||
TenantID string
|
||||
Name string
|
||||
SiteID string
|
||||
Modules []string
|
||||
Limits Limits
|
||||
}
|
||||
|
||||
// Limits — ліміти опитування, які сервер диктує зонду в Welcome.
|
||||
type Limits struct {
|
||||
MaxConcurrency int
|
||||
IcmpRatePPS int
|
||||
BatchSize int
|
||||
BatchInterval time.Duration
|
||||
MaxInFlight int
|
||||
HeartbeatEvery time.Duration
|
||||
}
|
||||
|
||||
func defaultLimits() Limits {
|
||||
return Limits{
|
||||
MaxConcurrency: 256,
|
||||
IcmpRatePPS: 500,
|
||||
BatchSize: 500,
|
||||
BatchInterval: 5 * time.Second,
|
||||
MaxInFlight: 4,
|
||||
HeartbeatEvery: 30 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// AuthenticateAgent знаходить зонда за токеном.
|
||||
//
|
||||
// У БД лежить лише sha256 — токен у відкритому вигляді не зберігається
|
||||
// ніде. Порівняння хешів робиться в SQL за унікальним індексом; на
|
||||
// знайденому рядку додатково звіряємо constant-time, щоб форма запиту
|
||||
// не залежала від того, як індекс порівнював байти.
|
||||
func (s *Store) AuthenticateAgent(ctx context.Context, token string) (*Agent, error) {
|
||||
if token == "" {
|
||||
return nil, ErrAgentNotFound
|
||||
}
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
|
||||
var (
|
||||
a Agent
|
||||
siteID *string
|
||||
hash []byte
|
||||
status string
|
||||
limits map[string]any
|
||||
)
|
||||
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT id::text, tenant_id::text, name, site_id::text, token_hash,
|
||||
status::text, enabled_modules, limits
|
||||
FROM core.agents
|
||||
WHERE token_hash = $1
|
||||
`, sum[:]).Scan(&a.ID, &a.TenantID, &a.Name, &siteID, &hash, &status, &a.Modules, &limits)
|
||||
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrAgentNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if subtle.ConstantTimeCompare(hash, sum[:]) != 1 {
|
||||
return nil, ErrAgentNotFound
|
||||
}
|
||||
if status == "disabled" {
|
||||
return nil, fmt.Errorf("%w: зонд вимкнено", ErrAgentNotFound)
|
||||
}
|
||||
if siteID != nil {
|
||||
a.SiteID = *siteID
|
||||
}
|
||||
|
||||
a.Limits = defaultLimits()
|
||||
applyLimitOverrides(&a.Limits, limits)
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
func applyLimitOverrides(l *Limits, m map[string]any) {
|
||||
num := func(key string) (int, bool) {
|
||||
v, ok := m[key]
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int(n), true
|
||||
case int64:
|
||||
return int(n), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
if v, ok := num("max_concurrency"); ok && v > 0 {
|
||||
l.MaxConcurrency = v
|
||||
}
|
||||
if v, ok := num("icmp_rate_pps"); ok && v > 0 {
|
||||
l.IcmpRatePPS = v
|
||||
}
|
||||
if v, ok := num("batch_size"); ok && v > 0 {
|
||||
l.BatchSize = v
|
||||
}
|
||||
if v, ok := num("max_in_flight"); ok && v > 0 {
|
||||
l.MaxInFlight = v
|
||||
}
|
||||
}
|
||||
|
||||
// MarkAgentOnline фіксує підключення зонда та його версію.
|
||||
func (s *Store) MarkAgentOnline(ctx context.Context, a *Agent, hello *npv1.Hello) error {
|
||||
build := hello.GetBuild()
|
||||
return s.InTenantTx(ctx, a.TenantID, func(tx pgx.Tx) error {
|
||||
_, err := tx.Exec(ctx, `
|
||||
UPDATE core.agents
|
||||
SET status = 'online',
|
||||
last_heartbeat_at = now(),
|
||||
version = COALESCE(NULLIF($2,''), version),
|
||||
os = COALESCE(NULLIF($3,''), os),
|
||||
arch = COALESCE(NULLIF($4,''), arch),
|
||||
hostname= COALESCE(NULLIF($5,''), hostname),
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND tenant_id = $6
|
||||
`, a.ID, build.GetVersion(), build.GetOs(), build.GetArch(),
|
||||
hello.GetHostname(), a.TenantID)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// MarkAgentOffline викликається при розриві сесії.
|
||||
func (s *Store) MarkAgentOffline(ctx context.Context, a *Agent) error {
|
||||
return s.InTenantTx(ctx, a.TenantID, func(tx pgx.Tx) error {
|
||||
_, err := tx.Exec(ctx, `
|
||||
UPDATE core.agents SET status = 'offline', updated_at = now()
|
||||
WHERE id = $1 AND tenant_id = $2
|
||||
`, a.ID, a.TenantID)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// RecordHeartbeat пише самометрики зонда в ts.agent_health і оновлює
|
||||
// зведення в core.agents.health для швидкого показу в UI.
|
||||
func (s *Store) RecordHeartbeat(ctx context.Context, a *Agent, hb *npv1.Heartbeat) error {
|
||||
h := hb.GetHealth()
|
||||
ts := hb.GetTs().AsTime()
|
||||
if ts.IsZero() {
|
||||
ts = time.Now()
|
||||
}
|
||||
|
||||
batch := &pgx.Batch{}
|
||||
batch.Queue(`
|
||||
INSERT INTO ts.agent_health
|
||||
(ts, agent_id, tenant_id, cpu_pct, rss_bytes, goroutines,
|
||||
queue_depth, checks_per_sec, errors_per_min)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||||
ON CONFLICT (ts, agent_id) DO NOTHING
|
||||
`, ts, a.ID, a.TenantID, h.GetCpuPct(), int64(h.GetRssBytes()),
|
||||
int32(h.GetGoroutines()), int32(h.GetQueueDepth()),
|
||||
h.GetChecksPerSec(), h.GetErrorsPerMin())
|
||||
|
||||
// dropped_samples у зведенні — це видима діра в даних; вона має
|
||||
// бути помітна оператору, а не тільки в графіку самометрик.
|
||||
batch.Queue(`
|
||||
UPDATE core.agents
|
||||
SET last_heartbeat_at = now(),
|
||||
status = 'online',
|
||||
health = jsonb_build_object(
|
||||
'rss_bytes', $3::bigint,
|
||||
'queue_depth', $4::int,
|
||||
'dropped_samples', $5::bigint,
|
||||
'tasks_running', $6::int,
|
||||
'clock_skew_ms', $7::bigint
|
||||
)
|
||||
WHERE id = $1 AND tenant_id = $2
|
||||
`, a.ID, a.TenantID, int64(h.GetRssBytes()), int32(h.GetQueueDepth()),
|
||||
int64(h.GetDroppedSamples()), int32(hb.GetTasksRunning()),
|
||||
h.GetClockSkew().AsDuration().Milliseconds())
|
||||
|
||||
res := s.pool.SendBatch(ctx, batch)
|
||||
defer res.Close()
|
||||
for i := 0; i < batch.Len(); i++ {
|
||||
if _, err := res.Exec(); err != nil {
|
||||
return fmt.Errorf("heartbeat[%d]: %w", i, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecordTaskStatus оновлює core.checks за доповіддю агента.
|
||||
func (s *Store) RecordTaskStatus(ctx context.Context, a *Agent, u *npv1.TaskStatusUpdate) error {
|
||||
var errText any
|
||||
if u.GetError() != nil {
|
||||
errText = u.GetError().GetCode() + ": " + u.GetError().GetMessage()
|
||||
}
|
||||
|
||||
return s.InTenantTx(ctx, a.TenantID, func(tx pgx.Tx) error {
|
||||
_, err := tx.Exec(ctx, `
|
||||
UPDATE core.checks
|
||||
SET last_run_at = COALESCE($3, now()),
|
||||
last_error = $4,
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND tenant_id = $2
|
||||
`, u.GetCheckId(), a.TenantID, tsOrNil(u), errText)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func tsOrNil(u *npv1.TaskStatusUpdate) any {
|
||||
if u.GetTs() == nil {
|
||||
return nil
|
||||
}
|
||||
return u.GetTs().AsTime()
|
||||
}
|
||||
205
server/internal/store/credentials.go
Normal file
205
server/internal/store/credentials.go
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/netpulse/netpulse/server/internal/crypto"
|
||||
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
// CredentialTTL — скільки живе комплект на зонді.
|
||||
//
|
||||
// Короткий TTL — це не паранойя, а керованість: після відкликання
|
||||
// доступу зонд перестане ним користуватись за годину без жодних
|
||||
// додаткових дій, навіть якщо зв'язок із ним втрачено.
|
||||
const CredentialTTL = time.Hour
|
||||
|
||||
// snmpV3Payload — домовленість про формат відкритого тексту секрету
|
||||
// kind='snmp_v3': два паролі в одному секреті, бо вони завжди
|
||||
// використовуються разом і ротуються разом.
|
||||
type snmpV3Payload struct {
|
||||
AuthPassword string `json:"auth_password"`
|
||||
PrivPassword string `json:"priv_password"`
|
||||
}
|
||||
|
||||
type credOptions struct {
|
||||
SecLevel string `json:"sec_level"`
|
||||
AuthProto string `json:"auth_proto"`
|
||||
PrivProto string `json:"priv_proto"`
|
||||
Context string `json:"context"`
|
||||
SecurityName string `json:"security_name"`
|
||||
}
|
||||
|
||||
// BuildCredentialBundle збирає розшифровані креденшели для всіх
|
||||
// пристроїв цього зонда.
|
||||
//
|
||||
// Розшифровка відбувається саме тут, а не на агенті: DEK не покидає
|
||||
// сервер. Далі вони їдуть виключно всередині mTLS-каналу й живуть у
|
||||
// пам'яті агента до expires_at.
|
||||
func (s *Store) BuildCredentialBundle(ctx context.Context, a *Agent, ring *crypto.Keyring) (*npv1.CredentialBundle, error) {
|
||||
bundle := &npv1.CredentialBundle{
|
||||
ByDevice: make(map[string]*npv1.CredentialList),
|
||||
ExpiresAt: timestamppb.New(time.Now().Add(CredentialTTL)),
|
||||
}
|
||||
|
||||
err := s.InTenantTx(ctx, a.TenantID, func(tx pgx.Tx) error {
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT dc.device_id::text,
|
||||
c.id::text,
|
||||
c.proto::text,
|
||||
COALESCE(c.username, ''),
|
||||
COALESCE(c.port, 0),
|
||||
COALESCE(c.options::text, '{}'),
|
||||
COALESCE(s.kind::text, ''),
|
||||
s.key_id, s.nonce, s.ciphertext, s.auth_tag, COALESCE(s.aad, '')
|
||||
FROM inv.device_credentials dc
|
||||
JOIN inv.credentials c ON c.id = dc.credential_id
|
||||
JOIN inv.devices d ON d.id = dc.device_id
|
||||
LEFT JOIN core.secrets s ON s.id = c.secret_id
|
||||
WHERE c.tenant_id = $1
|
||||
AND d.agent_id = $2
|
||||
AND d.enabled
|
||||
AND d.deleted_at IS NULL
|
||||
ORDER BY dc.device_id, dc.priority
|
||||
`, a.TenantID, a.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
deviceID, credID, proto, username, optionsJSON string
|
||||
port int32
|
||||
kind, keyID, aad string
|
||||
nonce, ciphertext, authTag []byte
|
||||
)
|
||||
var (
|
||||
keyIDPtr *string
|
||||
aadPtr *string
|
||||
)
|
||||
if err := rows.Scan(&deviceID, &credID, &proto, &username, &port,
|
||||
&optionsJSON, &kind, &keyIDPtr, &nonce, &ciphertext, &authTag, &aadPtr); err != nil {
|
||||
return err
|
||||
}
|
||||
if keyIDPtr != nil {
|
||||
keyID = *keyIDPtr
|
||||
}
|
||||
if aadPtr != nil {
|
||||
aad = *aadPtr
|
||||
}
|
||||
|
||||
cred := &npv1.Credential{
|
||||
CredentialId: credID,
|
||||
Transport: transportFromProto(proto),
|
||||
Username: username,
|
||||
Port: uint32(port),
|
||||
ExpiresAt: bundle.ExpiresAt,
|
||||
}
|
||||
|
||||
var plaintext []byte
|
||||
if len(ciphertext) > 0 {
|
||||
pt, decErr := ring.Decrypt(&crypto.Secret{
|
||||
KeyID: keyID, Nonce: nonce, Ciphertext: ciphertext, AuthTag: authTag,
|
||||
}, aad)
|
||||
if decErr != nil {
|
||||
// Один нечитабельний секрет не має рушити весь
|
||||
// комплект: решта пристроїв мусить опитуватись.
|
||||
continue
|
||||
}
|
||||
plaintext = pt
|
||||
}
|
||||
|
||||
var opts credOptions
|
||||
_ = json.Unmarshal([]byte(optionsJSON), &opts)
|
||||
|
||||
if err := fillSecret(cred, kind, proto, plaintext, opts); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
list := bundle.ByDevice[deviceID]
|
||||
if list == nil {
|
||||
list = &npv1.CredentialList{}
|
||||
bundle.ByDevice[deviceID] = list
|
||||
}
|
||||
list.Credentials = append(list.Credentials, cred)
|
||||
}
|
||||
return rows.Err()
|
||||
})
|
||||
|
||||
return bundle, err
|
||||
}
|
||||
|
||||
func fillSecret(cred *npv1.Credential, kind, proto string, plaintext []byte, opts credOptions) error {
|
||||
switch {
|
||||
case proto == "snmp_v2c":
|
||||
cred.Secret = &npv1.Credential_Community{Community: string(plaintext)}
|
||||
|
||||
case proto == "snmp_v3":
|
||||
var p snmpV3Payload
|
||||
if len(plaintext) > 0 {
|
||||
if err := json.Unmarshal(plaintext, &p); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
cred.SnmpV3 = &npv1.SnmpV3Options{
|
||||
Level: secLevel(opts.SecLevel),
|
||||
AuthProtocol: opts.AuthProto,
|
||||
AuthPassword: p.AuthPassword,
|
||||
PrivProtocol: opts.PrivProto,
|
||||
PrivPassword: p.PrivPassword,
|
||||
ContextName: opts.Context,
|
||||
SecurityName: opts.SecurityName,
|
||||
}
|
||||
|
||||
case kind == "ssh_key":
|
||||
cred.Secret = &npv1.Credential_PrivateKey{PrivateKey: plaintext}
|
||||
|
||||
case proto == "http" || proto == "https" || proto == "api":
|
||||
cred.Secret = &npv1.Credential_Token{Token: string(plaintext)}
|
||||
|
||||
default:
|
||||
cred.Secret = &npv1.Credential_Password{Password: string(plaintext)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func transportFromProto(p string) npv1.Transport {
|
||||
switch p {
|
||||
case "ssh":
|
||||
return npv1.Transport_TRANSPORT_SSH
|
||||
case "telnet":
|
||||
return npv1.Transport_TRANSPORT_TELNET
|
||||
case "snmp_v2c":
|
||||
return npv1.Transport_TRANSPORT_SNMP_V2C
|
||||
case "snmp_v3":
|
||||
return npv1.Transport_TRANSPORT_SNMP_V3
|
||||
case "http":
|
||||
return npv1.Transport_TRANSPORT_HTTP
|
||||
case "https":
|
||||
return npv1.Transport_TRANSPORT_HTTPS
|
||||
case "api":
|
||||
return npv1.Transport_TRANSPORT_API
|
||||
case "modbus":
|
||||
return npv1.Transport_TRANSPORT_MODBUS
|
||||
default:
|
||||
return npv1.Transport_TRANSPORT_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func secLevel(s string) npv1.SnmpV3Options_SecurityLevel {
|
||||
switch s {
|
||||
case "authPriv":
|
||||
return npv1.SnmpV3Options_SECURITY_LEVEL_AUTH_PRIV
|
||||
case "authNoPriv":
|
||||
return npv1.SnmpV3Options_SECURITY_LEVEL_AUTH_NO_PRIV
|
||||
case "noAuthNoPriv":
|
||||
return npv1.SnmpV3Options_SECURITY_LEVEL_NO_AUTH_NO_PRIV
|
||||
default:
|
||||
return npv1.SnmpV3Options_SECURITY_LEVEL_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
415
server/internal/store/discovery.go
Normal file
415
server/internal/store/discovery.go
Normal file
|
|
@ -0,0 +1,415 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
|
||||
)
|
||||
|
||||
// Рівні впевненості зіставлення сусіда з інвентарем.
|
||||
//
|
||||
// Порядок не довільний: chassis-id унікальний за стандартом LLDP,
|
||||
// MAC — майже, IP керування може повторюватись у різних VRF, а
|
||||
// sysName взагалі вводить людина й на двох комутаторах цілком може
|
||||
// бути "switch". Тому лінк, зведений за іменем, отримує низьку
|
||||
// впевненість і не має мовчки заміщати те, що підтвердила людина.
|
||||
const (
|
||||
confChassis = 95
|
||||
confMAC = 90
|
||||
confMgmtIP = 80
|
||||
confSysName = 60
|
||||
)
|
||||
|
||||
// DiscoveryStats — підсумок обробки звіту, їде в DiscoveryAck.
|
||||
type DiscoveryStats struct {
|
||||
NeighborsSeen int
|
||||
NeighborsResolved int
|
||||
LinksCreated int
|
||||
LinksUpdated int
|
||||
InterfacesUpdated int
|
||||
}
|
||||
|
||||
// ApplyDiscovery приймає звіт агента: зберігає сирих сусідів,
|
||||
// зіставляє їх з інвентарем і зводить у topo.links.
|
||||
//
|
||||
// Агент не вирішує, хто з ким з'єднаний, — він доповідає лише
|
||||
// «на порту X бачу chassis Y, port Z». Уся інтерпретація тут, тому
|
||||
// правила зіставлення можна міняти без оновлення зондів у полі.
|
||||
func (s *Store) ApplyDiscovery(ctx context.Context, a *Agent, rep *npv1.DiscoveryReport) (DiscoveryStats, error) {
|
||||
var st DiscoveryStats
|
||||
|
||||
if err := s.upsertInterfaces(ctx, a, rep.GetInterfaces(), &st); err != nil {
|
||||
return st, err
|
||||
}
|
||||
|
||||
for _, n := range rep.GetNeighbors() {
|
||||
st.NeighborsSeen++
|
||||
if err := s.applyNeighbor(ctx, a, n, &st); err != nil {
|
||||
return st, err
|
||||
}
|
||||
}
|
||||
|
||||
return st, nil
|
||||
}
|
||||
|
||||
func (s *Store) upsertInterfaces(ctx context.Context, a *Agent, ifs []*npv1.InterfaceRecord, st *DiscoveryStats) error {
|
||||
if len(ifs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return s.InTenantTx(ctx, a.TenantID, func(tx pgx.Tx) error {
|
||||
for _, r := range ifs {
|
||||
_, err := tx.Exec(ctx, `
|
||||
INSERT INTO inv.interfaces
|
||||
(tenant_id, device_id, if_index, name, alias, mac, mtu, type,
|
||||
speed_bps, duplex, admin_status, oper_status)
|
||||
VALUES ($1,$2,$3,$4,$5,$6::macaddr,$7,$8,$9,$10,
|
||||
$11::inv.if_admin_status, $12::inv.if_oper_status)
|
||||
ON CONFLICT (device_id, if_index) WHERE if_index IS NOT NULL
|
||||
DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
alias = EXCLUDED.alias,
|
||||
mac = COALESCE(EXCLUDED.mac, inv.interfaces.mac),
|
||||
speed_bps = COALESCE(NULLIF(EXCLUDED.speed_bps, 0), inv.interfaces.speed_bps),
|
||||
admin_status = EXCLUDED.admin_status,
|
||||
oper_status = EXCLUDED.oper_status,
|
||||
updated_at = now()
|
||||
`, a.TenantID, r.GetDeviceId(), r.GetIfIndex(), r.GetName(),
|
||||
nullString(r.GetAlias()), nullString(r.GetMac()),
|
||||
nullInt(int(r.GetMtu())), nullString(r.GetType()),
|
||||
nullInt64(int64(r.GetSpeedBps())), nullString(r.GetDuplex()),
|
||||
statusOr(r.GetAdminStatus(), "unknown"),
|
||||
statusOr(r.GetOperStatus(), "unknown"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
st.InterfacesUpdated++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) applyNeighbor(ctx context.Context, a *Agent, n *npv1.NeighborRecord, st *DiscoveryStats) error {
|
||||
return s.InTenantTx(ctx, a.TenantID, func(tx pgx.Tx) error {
|
||||
localIfID, err := s.resolveLocalInterface(ctx, tx, a.TenantID, n)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
remoteDevID, conf, err := s.resolveRemoteDevice(ctx, tx, a.TenantID, n)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var remoteIfID any
|
||||
if remoteDevID != nil {
|
||||
remoteIfID, err = s.resolveRemoteInterface(ctx, tx, a.TenantID, *remoteDevID, n.GetRemotePortId(), n.GetRemotePortDescr())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
raw := n.GetRawJson()
|
||||
if len(raw) == 0 {
|
||||
raw = []byte("{}")
|
||||
} else if !json.Valid(raw) {
|
||||
raw = []byte("{}")
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO topo.neighbors
|
||||
(tenant_id, device_id, interface_id, proto,
|
||||
remote_chassis_id, remote_system_name, remote_port_id, remote_port_descr,
|
||||
remote_mgmt_ip, remote_mac, remote_platform, remote_capabilities,
|
||||
resolved_device_id, resolved_interface_id, confidence, last_seen_at, raw)
|
||||
VALUES ($1,$2,$3,$4::topo.discovery_proto,$5,$6,$7,$8,
|
||||
$9::inet,$10::macaddr,$11,$12,$13,$14,$15,now(),$16::jsonb)
|
||||
ON CONFLICT (device_id, COALESCE(interface_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
proto, COALESCE(remote_chassis_id,''), COALESCE(remote_port_id,''))
|
||||
DO UPDATE SET
|
||||
remote_system_name = EXCLUDED.remote_system_name,
|
||||
remote_port_descr = EXCLUDED.remote_port_descr,
|
||||
remote_mgmt_ip = EXCLUDED.remote_mgmt_ip,
|
||||
remote_mac = EXCLUDED.remote_mac,
|
||||
remote_platform = EXCLUDED.remote_platform,
|
||||
remote_capabilities = EXCLUDED.remote_capabilities,
|
||||
resolved_device_id = EXCLUDED.resolved_device_id,
|
||||
resolved_interface_id = EXCLUDED.resolved_interface_id,
|
||||
confidence = EXCLUDED.confidence,
|
||||
last_seen_at = now(),
|
||||
raw = EXCLUDED.raw
|
||||
`, a.TenantID, n.GetDeviceId(), localIfID, protoName(n.GetProto()),
|
||||
nullString(n.GetRemoteChassisId()), nullString(n.GetRemoteSystemName()),
|
||||
nullString(n.GetRemotePortId()), nullString(n.GetRemotePortDescr()),
|
||||
nullString(n.GetRemoteMgmtIp()), nullString(n.GetRemoteMac()),
|
||||
nullString(n.GetRemotePlatform()), n.GetRemoteCapabilities(),
|
||||
remoteDevID, remoteIfID, conf, string(raw))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if remoteDevID == nil {
|
||||
return nil
|
||||
}
|
||||
st.NeighborsResolved++
|
||||
|
||||
created, err := s.upsertLink(ctx, tx, a.TenantID, linkSpec{
|
||||
aDevice: n.GetDeviceId(),
|
||||
aInterface: localIfID,
|
||||
bDevice: *remoteDevID,
|
||||
bInterface: remoteIfID,
|
||||
proto: protoName(n.GetProto()),
|
||||
confidence: conf,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if created {
|
||||
st.LinksCreated++
|
||||
} else {
|
||||
st.LinksUpdated++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) resolveLocalInterface(ctx context.Context, tx pgx.Tx, tenantID string, n *npv1.NeighborRecord) (any, error) {
|
||||
if n.GetLocalInterfaceId() != "" {
|
||||
return n.GetLocalInterfaceId(), nil
|
||||
}
|
||||
|
||||
var id string
|
||||
var err error
|
||||
switch {
|
||||
case n.GetLocalIfIndex() > 0:
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT id::text FROM inv.interfaces
|
||||
WHERE tenant_id = $1 AND device_id = $2 AND if_index = $3
|
||||
`, tenantID, n.GetDeviceId(), n.GetLocalIfIndex()).Scan(&id)
|
||||
case n.GetLocalPortName() != "":
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT id::text FROM inv.interfaces
|
||||
WHERE tenant_id = $1 AND device_id = $2 AND lower(name) = lower($3)
|
||||
`, tenantID, n.GetDeviceId(), n.GetLocalPortName()).Scan(&id)
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// resolveRemoteDevice шукає пристрій за спаданням надійності ознаки.
|
||||
func (s *Store) resolveRemoteDevice(ctx context.Context, tx pgx.Tx, tenantID string, n *npv1.NeighborRecord) (*string, int, error) {
|
||||
type probe struct {
|
||||
query string
|
||||
arg string
|
||||
conf int
|
||||
}
|
||||
|
||||
probes := []probe{
|
||||
{`SELECT id::text FROM inv.devices
|
||||
WHERE tenant_id=$1 AND deleted_at IS NULL AND chassis_id = $2 LIMIT 1`,
|
||||
n.GetRemoteChassisId(), confChassis},
|
||||
|
||||
{`SELECT id::text FROM inv.devices
|
||||
WHERE tenant_id=$1 AND deleted_at IS NULL AND base_mac = $2::macaddr LIMIT 1`,
|
||||
n.GetRemoteMac(), confMAC},
|
||||
|
||||
// MAC сусіда може збігтися не з шасі, а з конкретним портом.
|
||||
{`SELECT i.device_id::text FROM inv.interfaces i
|
||||
WHERE i.tenant_id=$1 AND i.mac = $2::macaddr LIMIT 1`,
|
||||
n.GetRemoteMac(), confMAC},
|
||||
|
||||
{`SELECT id::text FROM inv.devices
|
||||
WHERE tenant_id=$1 AND deleted_at IS NULL AND host(address) = $2 LIMIT 1`,
|
||||
n.GetRemoteMgmtIp(), confMgmtIP},
|
||||
|
||||
{`SELECT id::text FROM inv.devices
|
||||
WHERE tenant_id=$1 AND deleted_at IS NULL
|
||||
AND (system_name = $2 OR lower(name) = lower($2)) LIMIT 1`,
|
||||
n.GetRemoteSystemName(), confSysName},
|
||||
}
|
||||
|
||||
for _, p := range probes {
|
||||
if p.arg == "" {
|
||||
continue
|
||||
}
|
||||
var id string
|
||||
err := tx.QueryRow(ctx, p.query, tenantID, p.arg).Scan(&id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
// Некоректний MAC/IP від пристрою не має валити весь звіт.
|
||||
continue
|
||||
}
|
||||
return &id, p.conf, nil
|
||||
}
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
// resolveRemoteInterface: port-id у LLDP буває чим завгодно —
|
||||
// ім'ям порту, описом, ifIndex або MAC. Пробуємо все.
|
||||
func (s *Store) resolveRemoteInterface(ctx context.Context, tx pgx.Tx, tenantID, deviceID, portID, portDescr string) (any, error) {
|
||||
candidates := []string{portID, portDescr}
|
||||
|
||||
for _, c := range candidates {
|
||||
if strings.TrimSpace(c) == "" {
|
||||
continue
|
||||
}
|
||||
var id string
|
||||
|
||||
err := tx.QueryRow(ctx, `
|
||||
SELECT id::text FROM inv.interfaces
|
||||
WHERE tenant_id=$1 AND device_id=$2
|
||||
AND (lower(name) = lower($3) OR lower(alias) = lower($3))
|
||||
LIMIT 1
|
||||
`, tenantID, deviceID, c).Scan(&id)
|
||||
if err == nil {
|
||||
return id, nil
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if idx, convErr := strconv.ParseInt(strings.TrimSpace(c), 10, 64); convErr == nil {
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT id::text FROM inv.interfaces
|
||||
WHERE tenant_id=$1 AND device_id=$2 AND if_index=$3 LIMIT 1
|
||||
`, tenantID, deviceID, idx).Scan(&id)
|
||||
if err == nil {
|
||||
return id, nil
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type linkSpec struct {
|
||||
aDevice string
|
||||
aInterface any
|
||||
bDevice string
|
||||
bInterface any
|
||||
proto string
|
||||
confidence int
|
||||
}
|
||||
|
||||
// upsertLink створює або оновлює лінк, нормалізуючи пару.
|
||||
//
|
||||
// Пошук іде за тим самим виразом LEAST/GREATEST, що й унікальний
|
||||
// індекс links_pair_uniq, тому A→B і B→A знаходять один рядок.
|
||||
// Лінк із is_pinned не перезаписується: підтверджене людиною має
|
||||
// пріоритет над автовиявленням, інакше кожен запуск discovery
|
||||
// затирав би ручні правки.
|
||||
func (s *Store) upsertLink(ctx context.Context, tx pgx.Tx, tenantID string, spec linkSpec) (created bool, err error) {
|
||||
const zero = "00000000-0000-0000-0000-000000000000"
|
||||
|
||||
var (
|
||||
linkID string
|
||||
isPinned bool
|
||||
)
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT id::text, is_pinned FROM topo.links
|
||||
WHERE tenant_id = $1
|
||||
AND LEAST(a_device_id, b_device_id) = LEAST($2::uuid, $3::uuid)
|
||||
AND GREATEST(a_device_id, b_device_id) = GREATEST($2::uuid, $3::uuid)
|
||||
AND LEAST(COALESCE(a_interface_id, $6::uuid), COALESCE(b_interface_id, $6::uuid))
|
||||
= LEAST(COALESCE($4::uuid, $6::uuid), COALESCE($5::uuid, $6::uuid))
|
||||
AND GREATEST(COALESCE(a_interface_id, $6::uuid), COALESCE(b_interface_id, $6::uuid))
|
||||
= GREATEST(COALESCE($4::uuid, $6::uuid), COALESCE($5::uuid, $6::uuid))
|
||||
`, tenantID, spec.aDevice, spec.bDevice, spec.aInterface, spec.bInterface, zero).
|
||||
Scan(&linkID, &isPinned)
|
||||
|
||||
switch {
|
||||
case err == nil:
|
||||
if isPinned {
|
||||
// Лише позначаємо, що лінк живий.
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE topo.links SET last_seen_at = now() WHERE id = $1
|
||||
`, linkID)
|
||||
return false, err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE topo.links
|
||||
SET last_seen_at = now(),
|
||||
confidence = GREATEST(confidence, $2),
|
||||
discovered_by = $3::topo.discovery_proto
|
||||
WHERE id = $1
|
||||
`, linkID, spec.confidence, spec.proto)
|
||||
return false, err
|
||||
|
||||
case errors.Is(err, pgx.ErrNoRows):
|
||||
// Швидкість каналу беремо з порту: вона стане знаменником
|
||||
// для util_pct і, зрештою, швидкістю анімації на мапі.
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO topo.links
|
||||
(tenant_id, a_device_id, a_interface_id, b_device_id, b_interface_id,
|
||||
kind, capacity_bps, discovered_by, confidence, status, last_seen_at)
|
||||
VALUES ($1,$2,$3,$4,$5,'physical',
|
||||
(SELECT speed_bps FROM inv.interfaces WHERE id = $3::uuid),
|
||||
$6::topo.discovery_proto, $7, 'unknown', now())
|
||||
ON CONFLICT DO NOTHING
|
||||
`, tenantID, spec.aDevice, spec.aInterface, spec.bDevice, spec.bInterface,
|
||||
spec.proto, spec.confidence)
|
||||
return err == nil, err
|
||||
|
||||
default:
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
func protoName(p npv1.DiscoveryProto) string {
|
||||
switch p {
|
||||
case npv1.DiscoveryProto_DISCOVERY_PROTO_LLDP:
|
||||
return "lldp"
|
||||
case npv1.DiscoveryProto_DISCOVERY_PROTO_CDP:
|
||||
return "cdp"
|
||||
case npv1.DiscoveryProto_DISCOVERY_PROTO_ARP:
|
||||
return "arp"
|
||||
case npv1.DiscoveryProto_DISCOVERY_PROTO_FDB:
|
||||
return "fdb"
|
||||
case npv1.DiscoveryProto_DISCOVERY_PROTO_STP:
|
||||
return "stp"
|
||||
case npv1.DiscoveryProto_DISCOVERY_PROTO_ROUTING:
|
||||
return "routing"
|
||||
case npv1.DiscoveryProto_DISCOVERY_PROTO_SNMP_TOPO:
|
||||
return "snmp_topo"
|
||||
default:
|
||||
return "manual"
|
||||
}
|
||||
}
|
||||
|
||||
func statusOr(s, def string) string {
|
||||
if s == "" {
|
||||
return def
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func nullInt(v int) any {
|
||||
if v == 0 {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func nullInt64(v int64) any {
|
||||
if v == 0 {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
182
server/internal/store/ncm.go
Normal file
182
server/internal/store/ncm.go
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/netpulse/netpulse/server/internal/crypto"
|
||||
)
|
||||
|
||||
// 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-двигун (libgit2) ще не підключено. Тіло
|
||||
// зберігається зашифрованим у core.secrets, а commit_sha тимчасово
|
||||
// містить hex контентного хеша. Усе інше — дедуплікація, підрахунок
|
||||
// рядків, ланцюжок prev_config_id — працює вже зараз, тому diff між
|
||||
// версіями будується без Git. Коли двигун з'явиться, зміниться лише
|
||||
// джерело commit_sha.
|
||||
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", deviceName, configType)
|
||||
|
||||
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, hexSum, hexSum,
|
||||
"device/"+deviceName, 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 = hexSum
|
||||
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
|
||||
}
|
||||
155
server/internal/store/plan.go
Normal file
155
server/internal/store/plan.go
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
|
||||
"google.golang.org/protobuf/types/known/durationpb"
|
||||
)
|
||||
|
||||
// ScheduleOffset розводить задачі всередині інтервалу.
|
||||
//
|
||||
// Рахує СЕРВЕР, і рахує детерміновано від check_id: інакше після
|
||||
// кожного перезапуску агента 5000 чеків з інтервалом 60 с з'їжджали б
|
||||
// у нову випадкову фазу, а раз на хвилину мережа отримувала б сплеск.
|
||||
// Функція чиста — те саме значення на будь-якому вузлі сервера.
|
||||
func ScheduleOffset(checkID string, interval time.Duration) time.Duration {
|
||||
if interval <= 0 {
|
||||
return 0
|
||||
}
|
||||
h := fnv.New64a()
|
||||
_, _ = h.Write([]byte(checkID))
|
||||
return time.Duration(h.Sum64() % uint64(interval))
|
||||
}
|
||||
|
||||
// BuildPlan збирає повний план задач для зонда.
|
||||
func (s *Store) BuildPlan(ctx context.Context, a *Agent) (*npv1.TaskPlan, error) {
|
||||
var plan *npv1.TaskPlan
|
||||
|
||||
err := s.InTenantTx(ctx, a.TenantID, func(tx pgx.Tx) error {
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT c.id::text,
|
||||
c.device_id::text,
|
||||
COALESCE(c.interface_id::text, ''),
|
||||
c.check_type,
|
||||
c.params::text,
|
||||
c.interval_sec,
|
||||
c.timeout_ms,
|
||||
c.retries,
|
||||
d.name,
|
||||
COALESCE(host(d.address), COALESCE(d.fqdn, '')),
|
||||
COALESCE(d.chassis_id, ''),
|
||||
COALESCE(d.system_name, '')
|
||||
FROM core.checks c
|
||||
JOIN inv.devices d ON d.id = c.device_id
|
||||
WHERE c.tenant_id = $1
|
||||
AND d.agent_id = $2
|
||||
AND c.enabled
|
||||
AND d.enabled
|
||||
AND d.deleted_at IS NULL
|
||||
ORDER BY c.id
|
||||
`, a.TenantID, a.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
p := &npv1.TaskPlan{Final: true}
|
||||
devices := make(map[string]*npv1.DeviceTarget)
|
||||
hasher := sha256.New()
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
checkID, deviceID, ifaceID, checkType, params string
|
||||
intervalSec, timeoutMs, retries int32
|
||||
devName, devAddr, chassisID, sysName string
|
||||
)
|
||||
if err := rows.Scan(&checkID, &deviceID, &ifaceID, &checkType, ¶ms,
|
||||
&intervalSec, &timeoutMs, &retries,
|
||||
&devName, &devAddr, &chassisID, &sysName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
interval := time.Duration(intervalSec) * time.Second
|
||||
task := &npv1.Task{
|
||||
CheckId: checkID,
|
||||
DeviceId: deviceID,
|
||||
InterfaceId: ifaceID,
|
||||
CheckType: checkType,
|
||||
ParamsJson: []byte(params),
|
||||
Interval: durationpb.New(interval),
|
||||
Timeout: durationpb.New(time.Duration(timeoutMs) * time.Millisecond),
|
||||
Retries: uint32(retries),
|
||||
Enabled: true,
|
||||
ScheduleOffset: durationpb.New(ScheduleOffset(checkID, interval)),
|
||||
}
|
||||
p.Tasks = append(p.Tasks, task)
|
||||
|
||||
// Хеш плану рахуємо з полів, які реально впливають на
|
||||
// поведінку агента. Зміна опису пристрою не має змушувати
|
||||
// переливати 50 000 задач.
|
||||
fmt.Fprintf(hasher, "%s|%s|%s|%s|%d|%d|%d|%s\n",
|
||||
checkID, deviceID, ifaceID, checkType,
|
||||
intervalSec, timeoutMs, retries, params)
|
||||
|
||||
if _, ok := devices[deviceID]; !ok {
|
||||
devices[deviceID] = &npv1.DeviceTarget{
|
||||
DeviceId: deviceID,
|
||||
Name: devName,
|
||||
Address: devAddr,
|
||||
ChassisId: chassisID,
|
||||
SystemName: sysName,
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, d := range devices {
|
||||
p.Devices = append(p.Devices, d)
|
||||
}
|
||||
p.PlanHash = hasher.Sum(nil)
|
||||
plan = p
|
||||
return nil
|
||||
})
|
||||
|
||||
return plan, err
|
||||
}
|
||||
|
||||
// ModulesForPlan визначає, які модулі треба активувати на зонді.
|
||||
//
|
||||
// Виводиться з типів чеків у плані плюс явно дозволених у
|
||||
// core.agents.enabled_modules: зонд не має вмикати нічого, що йому
|
||||
// не знадобиться, — це і пам'ять, і зайва поверхня атаки.
|
||||
func ModulesForPlan(plan *npv1.TaskPlan, allowed []string) *npv1.ModuleControl {
|
||||
needed := make(map[string]bool)
|
||||
for _, t := range plan.GetTasks() {
|
||||
if key, _, ok := cutDot(t.GetCheckType()); ok {
|
||||
needed[key] = true
|
||||
}
|
||||
}
|
||||
for _, m := range allowed {
|
||||
needed[m] = true
|
||||
}
|
||||
|
||||
mc := &npv1.ModuleControl{Exclusive: true}
|
||||
for key := range needed {
|
||||
mc.Modules = append(mc.Modules, &npv1.ModuleSpec{Key: key, Enabled: true})
|
||||
}
|
||||
return mc
|
||||
}
|
||||
|
||||
func cutDot(s string) (before, after string, found bool) {
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '.' {
|
||||
return s[:i], s[i+1:], true
|
||||
}
|
||||
}
|
||||
return s, "", false
|
||||
}
|
||||
89
server/internal/store/store.go
Normal file
89
server/internal/store/store.go
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
// Package store — доступ до PostgreSQL/TimescaleDB.
|
||||
//
|
||||
// Головне правило шару: жоден запит не виконується без tenant_id.
|
||||
// Ізоляція забезпечується двома незалежними механізмами, і це навмисно:
|
||||
//
|
||||
// 1. RLS у БД — політика tenant_isolation на кожній звичайній таблиці
|
||||
// з колонкою tenant_id. Вмикається через SET LOCAL app.tenant_id.
|
||||
// 2. Явний предикат tenant_id у кожному запиті цього пакета.
|
||||
//
|
||||
// Дублювання потрібне, бо RLS НЕ працює на гіпертаблях: TimescaleDB
|
||||
// не поєднує row level security зі стисненням. Саме туди йде вся
|
||||
// телеметрія, тому для неї другий механізм — єдиний.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func New(ctx context.Context, dsn string) (*Store, error) {
|
||||
cfg, err := pgxpool.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("розбір DSN: %w", err)
|
||||
}
|
||||
cfg.MaxConns = 16
|
||||
cfg.MinConns = 2
|
||||
cfg.MaxConnLifetime = time.Hour
|
||||
cfg.MaxConnIdleTime = 10 * time.Minute
|
||||
|
||||
pool, err := pgxpool.NewWithConfig(ctx, cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("ping: %w", err)
|
||||
}
|
||||
return &Store{pool: pool}, nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() { s.pool.Close() }
|
||||
|
||||
func (s *Store) Pool() *pgxpool.Pool { return s.pool }
|
||||
|
||||
// InTenantTx виконує fn у транзакції з виставленим app.tenant_id.
|
||||
//
|
||||
// SET LOCAL, а не SET: значення живе рівно до кінця транзакції й не
|
||||
// протікає на наступний запит, який візьме те саме з'єднання з пулу.
|
||||
// Протікання тут означало б показ чужих даних, тому це не оптимізація,
|
||||
// а вимога.
|
||||
func (s *Store) InTenantTx(ctx context.Context, tenantID string, fn func(pgx.Tx) error) error {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
if _, err := tx.Exec(ctx, "SELECT set_config('app.tenant_id', $1, true)", tenantID); err != nil {
|
||||
return fmt.Errorf("встановити app.tenant_id: %w", err)
|
||||
}
|
||||
if err := fn(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// nullUUID перетворює порожній рядок на NULL — protobuf не має
|
||||
// nullable-рядків, тому "" з дроту означає «не задано».
|
||||
func nullUUID(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func nullString(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
319
server/internal/store/telemetry.go
Normal file
319
server/internal/store/telemetry.go
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
|
||||
)
|
||||
|
||||
// SeriesTable — мапа series_ref → ts.series.id у межах ОДНІЄЇ сесії.
|
||||
//
|
||||
// Агент нумерує серії локально й після реконекту починає з 1, тому
|
||||
// таблиця не переживає сесію. Якщо сервер отримав семпл із номером,
|
||||
// якого не знає, — це не привід мовчки викинути дані: він відповідає
|
||||
// reset_series_table і агент реєструє все заново.
|
||||
type SeriesTable struct {
|
||||
mu sync.RWMutex
|
||||
byRef map[uint32]int64
|
||||
}
|
||||
|
||||
func NewSeriesTable() *SeriesTable {
|
||||
return &SeriesTable{byRef: make(map[uint32]int64)}
|
||||
}
|
||||
|
||||
func (t *SeriesTable) get(ref uint32) (int64, bool) {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
id, ok := t.byRef[ref]
|
||||
return id, ok
|
||||
}
|
||||
|
||||
func (t *SeriesTable) put(ref uint32, id int64) {
|
||||
t.mu.Lock()
|
||||
t.byRef[ref] = id
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
func (t *SeriesTable) Len() int {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
return len(t.byRef)
|
||||
}
|
||||
|
||||
// ErrUnknownSeriesRef — семпл посилається на незареєстровану серію.
|
||||
type ErrUnknownSeriesRef struct{ Ref uint32 }
|
||||
|
||||
func (e *ErrUnknownSeriesRef) Error() string {
|
||||
return fmt.Sprintf("series_ref %d не зареєстровано в цій сесії", e.Ref)
|
||||
}
|
||||
|
||||
// RegisterSeries створює або знаходить серії й наповнює таблицю сесії.
|
||||
func (s *Store) RegisterSeries(ctx context.Context, tenantID string, descs []*npv1.SeriesDescriptor, table *SeriesTable) error {
|
||||
if len(descs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
for _, d := range descs {
|
||||
labels, err := json.Marshal(nonNilLabels(d.GetLabels()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var id int64
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO ts.series
|
||||
(tenant_id, device_id, interface_id, plugin_key, metric_key, unit, labels)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)
|
||||
ON CONFLICT (tenant_id, device_id, metric_key, labels_hash)
|
||||
DO UPDATE SET
|
||||
unit = COALESCE(NULLIF(EXCLUDED.unit, ''), ts.series.unit),
|
||||
interface_id = COALESCE(EXCLUDED.interface_id, ts.series.interface_id)
|
||||
RETURNING id
|
||||
`, tenantID,
|
||||
nullUUID(d.GetDeviceId()),
|
||||
nullUUID(d.GetInterfaceId()),
|
||||
nullString(d.GetPluginKey()),
|
||||
d.GetMetricKey(),
|
||||
d.GetUnit(),
|
||||
string(labels),
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("реєстрація серії %s: %w", d.GetMetricKey(), err)
|
||||
}
|
||||
table.put(d.GetSeriesRef(), id)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func nonNilLabels(m map[string]string) map[string]string {
|
||||
if m == nil {
|
||||
return map[string]string{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// BatchStats — що саме записалось; іде в журнал і метрики сервера.
|
||||
type BatchStats struct {
|
||||
Samples int
|
||||
Icmp int
|
||||
Interfaces int
|
||||
Statuses int
|
||||
Checks int
|
||||
}
|
||||
|
||||
// WriteBatch записує телеметрію.
|
||||
//
|
||||
// Семантика доставки — at-least-once, тому кожен запис іде з
|
||||
// ON CONFLICT DO NOTHING: повторний батч після реконекту не має ані
|
||||
// падати, ані дублювати рядки. Первинні ключі (ts, device_id),
|
||||
// (ts, interface_id) і (ts, series_id) роблять це безпечним.
|
||||
func (s *Store) WriteBatch(ctx context.Context, a *Agent, b *npv1.TelemetryBatch, table *SeriesTable) (BatchStats, error) {
|
||||
var st BatchStats
|
||||
|
||||
// Спершу — нові серії, бо семпли в цьому ж батчі на них посилаються.
|
||||
if err := s.RegisterSeries(ctx, a.TenantID, b.GetNewSeries(), table); err != nil {
|
||||
return st, err
|
||||
}
|
||||
|
||||
batch := &pgx.Batch{}
|
||||
|
||||
for _, r := range b.GetIcmp() {
|
||||
batch.Queue(`
|
||||
INSERT INTO ts.icmp_samples
|
||||
(ts, device_id, tenant_id, agent_id, rtt_avg_ms, rtt_min_ms, rtt_max_ms,
|
||||
jitter_ms, loss_pct, packets_sent, packets_recv, reachable)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
|
||||
ON CONFLICT (ts, device_id) DO NOTHING
|
||||
`, r.GetTs().AsTime(), r.GetDeviceId(), a.TenantID, a.ID,
|
||||
r.GetRttAvgMs(), r.GetRttMinMs(), r.GetRttMaxMs(), r.GetJitterMs(),
|
||||
r.GetLossPct(), int16(r.GetPacketsSent()), int16(r.GetPacketsRecv()),
|
||||
r.GetReachable())
|
||||
st.Icmp++
|
||||
}
|
||||
|
||||
for _, r := range b.GetInterfaces() {
|
||||
batch.Queue(`
|
||||
INSERT INTO ts.if_counters
|
||||
(ts, interface_id, device_id, tenant_id,
|
||||
in_octets, out_octets, in_ucast_pkts, out_ucast_pkts,
|
||||
in_errors, out_errors, in_discards, out_discards,
|
||||
in_bps, out_bps, in_pps, out_pps,
|
||||
util_in_pct, util_out_pct, oper_up)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19)
|
||||
ON CONFLICT (ts, interface_id) DO NOTHING
|
||||
`, r.GetTs().AsTime(), r.GetInterfaceId(), r.GetDeviceId(), a.TenantID,
|
||||
int64(r.GetInOctets()), int64(r.GetOutOctets()),
|
||||
int64(r.GetInUcastPkts()), int64(r.GetOutUcastPkts()),
|
||||
int64(r.GetInErrors()), int64(r.GetOutErrors()),
|
||||
int64(r.GetInDiscards()), int64(r.GetOutDiscards()),
|
||||
r.GetInBps(), r.GetOutBps(), r.GetInPps(), r.GetOutPps(),
|
||||
r.GetUtilInPct(), r.GetUtilOutPct(), r.GetOperUp())
|
||||
st.Interfaces++
|
||||
}
|
||||
|
||||
for _, smp := range b.GetSamples() {
|
||||
seriesID, ok := table.get(smp.GetSeriesRef())
|
||||
if !ok {
|
||||
return st, &ErrUnknownSeriesRef{Ref: smp.GetSeriesRef()}
|
||||
}
|
||||
batch.Queue(`
|
||||
INSERT INTO ts.samples (ts, series_id, value)
|
||||
VALUES ($1,$2,$3)
|
||||
ON CONFLICT (ts, series_id) DO NOTHING
|
||||
`, smp.GetTs().AsTime(), seriesID, smp.GetValue())
|
||||
st.Samples++
|
||||
}
|
||||
|
||||
if batch.Len() > 0 {
|
||||
res := s.pool.SendBatch(ctx, batch)
|
||||
for i := 0; i < batch.Len(); i++ {
|
||||
if _, err := res.Exec(); err != nil {
|
||||
res.Close()
|
||||
return st, fmt.Errorf("запис телеметрії[%d]: %w", i, err)
|
||||
}
|
||||
}
|
||||
if err := res.Close(); err != nil {
|
||||
return st, err
|
||||
}
|
||||
}
|
||||
|
||||
// Стан пристроїв оновлюємо окремо: там є умовний запис в історію,
|
||||
// який не виражається одним INSERT.
|
||||
for _, sc := range b.GetStatusChanges() {
|
||||
if err := s.applyDeviceStatus(ctx, a.TenantID, sc.GetDeviceId(),
|
||||
statusName(sc.GetStatus()), sc.GetTs().AsTime(), sc.GetReason()); err != nil {
|
||||
return st, err
|
||||
}
|
||||
st.Statuses++
|
||||
}
|
||||
|
||||
// Стан із ICMP — головне джерело кольору вузла на мапі.
|
||||
for _, r := range b.GetIcmp() {
|
||||
want := "down"
|
||||
if r.GetReachable() {
|
||||
want = "up"
|
||||
}
|
||||
if err := s.applyDeviceStatus(ctx, a.TenantID, r.GetDeviceId(), want,
|
||||
r.GetTs().AsTime(), "icmp"); err != nil {
|
||||
return st, err
|
||||
}
|
||||
}
|
||||
|
||||
st.Checks = len(b.GetCheckResults())
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// applyDeviceStatus змінює стан пристрою й пише в історію ЛИШЕ при
|
||||
// фактичному переході.
|
||||
//
|
||||
// Один запит замість read-modify-write: інакше два воркери, що
|
||||
// обробляють сусідні батчі, наввипередки писали б у історію
|
||||
// неіснуючі переходи up→up.
|
||||
func (s *Store) applyDeviceStatus(ctx context.Context, tenantID, deviceID, status string, ts time.Time, reason string) error {
|
||||
if deviceID == "" || status == "" {
|
||||
return nil
|
||||
}
|
||||
if ts.IsZero() {
|
||||
ts = time.Now()
|
||||
}
|
||||
|
||||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
_, err := tx.Exec(ctx, `
|
||||
WITH prev AS (
|
||||
SELECT id, status
|
||||
FROM inv.devices
|
||||
WHERE id = $1 AND tenant_id = $2
|
||||
FOR UPDATE
|
||||
), upd AS (
|
||||
UPDATE inv.devices d
|
||||
SET status = $3::inv.device_status,
|
||||
status_changed_at = CASE
|
||||
WHEN d.status IS DISTINCT FROM $3::inv.device_status THEN $4
|
||||
ELSE d.status_changed_at END,
|
||||
last_seen_at = GREATEST(COALESCE(d.last_seen_at, $4), $4)
|
||||
FROM prev
|
||||
WHERE d.id = prev.id
|
||||
RETURNING prev.status AS old_status, d.status AS new_status
|
||||
)
|
||||
INSERT INTO ts.device_status_history (ts, device_id, tenant_id, status, prev_status, reason)
|
||||
SELECT $4, $1, $2, new_status, old_status, $5
|
||||
FROM upd
|
||||
WHERE old_status IS DISTINCT FROM new_status
|
||||
ON CONFLICT (ts, device_id) DO NOTHING
|
||||
`, deviceID, tenantID, status, ts, nullString(reason))
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func statusName(s npv1.Status) string {
|
||||
switch s {
|
||||
case npv1.Status_STATUS_UP:
|
||||
return "up"
|
||||
case npv1.Status_STATUS_DOWN:
|
||||
return "down"
|
||||
case npv1.Status_STATUS_WARNING:
|
||||
return "warning"
|
||||
case npv1.Status_STATUS_MAINTENANCE:
|
||||
return "maintenance"
|
||||
case npv1.Status_STATUS_UNKNOWN:
|
||||
return "unknown"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// WriteLogs зберігає syslog і трапи.
|
||||
func (s *Store) WriteLogs(ctx context.Context, a *Agent, b *npv1.LogBatch) (int, error) {
|
||||
batch := &pgx.Batch{}
|
||||
|
||||
for _, e := range b.GetSyslog() {
|
||||
parsed, _ := json.Marshal(nonNilLabels(e.GetParsed()))
|
||||
batch.Queue(`
|
||||
INSERT INTO ts.syslog
|
||||
(ts, tenant_id, device_id, source_ip, facility, severity, hostname, tag, message, parsed)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10::jsonb)
|
||||
`, tsOrNow(e.GetTs().AsTime()), a.TenantID, nullUUID(e.GetDeviceId()),
|
||||
nullString(e.GetSourceIp()), int16(e.GetFacility()), int16(e.GetSeverity()),
|
||||
nullString(e.GetHostname()), nullString(e.GetTag()), e.GetMessage(), string(parsed))
|
||||
}
|
||||
|
||||
for _, t := range b.GetTraps() {
|
||||
vb := make(map[string]string, len(t.GetVarbinds()))
|
||||
for _, v := range t.GetVarbinds() {
|
||||
vb[v.GetOid()] = v.GetValue()
|
||||
}
|
||||
payload, _ := json.Marshal(vb)
|
||||
batch.Queue(`
|
||||
INSERT INTO ts.snmp_traps (ts, tenant_id, device_id, source_ip, trap_oid, varbinds)
|
||||
VALUES ($1,$2,$3,$4,$5,$6::jsonb)
|
||||
`, tsOrNow(t.GetTs().AsTime()), a.TenantID, nullUUID(t.GetDeviceId()),
|
||||
nullString(t.GetSourceIp()), nullString(t.GetTrapOid()), string(payload))
|
||||
}
|
||||
|
||||
if batch.Len() == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
res := s.pool.SendBatch(ctx, batch)
|
||||
defer res.Close()
|
||||
for i := 0; i < batch.Len(); i++ {
|
||||
if _, err := res.Exec(); err != nil {
|
||||
return i, fmt.Errorf("запис журналу[%d]: %w", i, err)
|
||||
}
|
||||
}
|
||||
return batch.Len(), nil
|
||||
}
|
||||
|
||||
func tsOrNow(t time.Time) time.Time {
|
||||
if t.IsZero() {
|
||||
return time.Now()
|
||||
}
|
||||
return t
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue