Етап 1-2: схема БД та protobuf-контракт агент-сервер

Схема PostgreSQL 16+/TimescaleDB: 11 міграцій, 7 схем, топологія
(neighbors -> links -> maps -> nodes/edges), time-series з CAGG,
NCM, alerting, білінг з entitlements, RLS.

Контракт agent<->server: 6 proto-файлів, gRPC, інтернування серій,
at-least-once з ack, чанкування конфігів.

Перевірено на стенді Debian 13 / PG 17.11 / TimescaleDB 2.29.1:
міграції + 8 функціональних перевірок схеми, buf lint + 5 наскрізних
gRPC-тестів контракту.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
zotac 2026-08-14 03:28:40 +03:00
commit 15d22a8d8a
38 changed files with 12997 additions and 0 deletions

29
.gitignore vendored Normal file
View file

@ -0,0 +1,29 @@
# Збірки
/bin/
/dist/
*.exe
*.test
netpulse-agent
netpulse-server
# Секрети й локальні налаштування
.env
.env.*
!.env.example
*.pem
*.key
!testdata/**/*.key
# Згенерований TypeScript — регенерується з proto, на відміну від gen/go,
# який комітиться, щоб збірка агента не залежала від наявності buf.
/gen/ts/
# Інструменти
.idea/
.vscode/
*.swp
.DS_Store
# Дані локального стенду
/data/
/var/

135
HISTORY.md Normal file
View file

@ -0,0 +1,135 @@
# NetPulse — журнал розробки
Стислий лог: що зроблено, які рішення прийняті, що далі.
Мета — щоб наступна сесія не перечитувала весь код.
---
## 2026-08-14 — Етап 1: схема БД
### Створено
```
netpulse/
├── HISTORY.md ← цей файл
├── docker-compose.yml TimescaleDB 2.17/pg16 + DragonflyDB
└── db/
├── README.md ERD, ключові рішення, ізоляція тенантів
├── migrate.ps1 накат міграцій + schema_migrations
└── migrations/
├── 0001_core.sql tenants, users, RBAC, secrets, audit
├── 0002_inventory.sql sites, devices, interfaces, credentials
├── 0003_agents_plugins.sql plugins, agents, check_types, checks, outbox
├── 0004_topology.sql neighbors, links, maps, nodes, edges, backgrounds
├── 0005_telemetry_timescale.sql hypertables, CAGG, compression, retention
├── 0006_ncm.sql repos, profiles, jobs, configs, diffs, rollback
├── 0007_alerting.sql rules, alerts, channels, routes, maintenance
├── 0008_dashboards.sql dashboards, widgets, SLA
├── 0009_billing_licensing.sql plans, subscriptions, entitlements, invoices, licenses
├── 0010_seed.sql довідники
└── 0011_rls.sql Row Level Security
```
### Прийняті архітектурні рішення
1. **Фізична топологія ≠ візуальна.** `topo.links` (що є в мережі) окремо від `topo.map_edges` (як намальовано). Один лінк — на багатьох мапах.
2. **Зв'язки port→port через FK** на `inv.interfaces`, не текстом. Пара нормалізована через `LEAST/GREATEST`, щоб A→B і B→A не дублювались.
3. **Автовиявлення в два кроки:** сирі `topo.neighbors` (LLDP/CDP/ARP/FDB + `confidence`) → резолвер → `topo.links`. Прапорець `is_pinned` захищає ручні лінки.
4. **Дві моделі метрик:** узагальнена `ts.series`+`ts.samples` (плагіни реєструють `metric_key` без DDL) і широкі `ts.icmp_samples`/`ts.if_counters` для гарячих шляхів мапи.
5. **Анімація трафіку має ланцюг даних:** `if_counters.util_out_pct` → view `topo.link_live``map_edges.animation`.
6. **Ліміти тарифу перевіряються двічі:** `bill.entitlements` в API + тригери в БД.
7. **Секрети лише шифровані** (`core.secrets`: ciphertext/nonce/auth_tag/key_id, AES-GCM-256, DEK у KMS).
8. **Тіло конфігів у Git, метадані в БД** (`ncm.configs.commit_sha` + `content_hash`).
9. **RLS за замовчуванням** на кожній таблиці з `tenant_id`; порожній `app.tenant_id` → порожній результат.
### Проблеми, на які натрапив (щоб не повторювати)
- **Порядок seed ↔ RLS.** Спершу RLS був 0010, seed 0011 — це ламається: після `FORCE ROW LEVEL SECURITY` навіть власник схеми не вставить довідники з `tenant_id IS NULL`. Файли переставлені місцями.
- **CAGG не працюють у транзакції.** `CREATE MATERIALIZED VIEW ... WITH (timescaledb.continuous)` падає всередині транзакційного блоку. `migrate.ps1` детектить це по вмісту файлу й вимикає `--single-transaction` для 0005.
### Помилки, знайдені прогоном на живому Postgres
1. `window` — зарезервоване слово. `core.sla_targets.window``period_kind`.
2. `core.check_types.key` був доменом `core.slug`, але ключі мають вигляд `icmp.ping` — крапка не проходить. Замінено на `text` з власним CHECK `^[a-z0-9]+(\.[a-z0-9_]+)+$`; так само `core.checks.check_type`.
3. Домен `core.slug` не пропускав підкреслення, а ключі фіч — `http_checks`, `auto_discovery`. Регекс розширено до `[a-z0-9_-]`.
4. **RLS блокує не CAGG, а стиснення.** Початкове припущення «CAGG + RLS несумісні» виявилось хибним. Реальна відмова TimescaleDB 2.29: `operation not supported on hypertables that have columnstore enabled` — тобто конфлікт саме з compression. Тому в 0011 виключено **всі** hypertables (через `timescaledb_information.hypertables`), а не три захардкоджені.
### Перевірено на живому стенді
Debian 13 (LXC, 192.168.1.203) / PostgreSQL 17.11 / TimescaleDB 2.29.1.
Усі 11 міграцій — на чисту БД без помилок. Створено: 81 таблиця, 12 hypertables,
6 continuous aggregates, 25 фонових job-ів, 63 RLS-політики, 220 індексів.
`db/tests/smoke.sql` — 8 функціональних перевірок, усі PASS:
нормалізація пари лінків, CHECK на device-ноду, дедуплікація алертів,
ліміт пристроїв тарифу Free, стан полотна мапи одним запитом,
ребро port→port з живим `util_pct`, роллап CAGG, ізоляція тенантів RLS.
Бази на сервері: `netpulse` (схема + smoke-дані). Перестворити:
`sudo -u postgres dropdb netpulse && createdb -O netpulse netpulse`, далі міграції.
---
## 2026-08-14 — Етап 2 (частина 1): protobuf-контракт агент↔сервер
### Створено
```
netpulse/
├── buf.yaml, buf.gen.yaml
├── proto/
│ ├── README.md контракт: сервіси, потоки, семантика, безпека
│ └── netpulse/v1/
│ ├── common.proto Status, Transport, Error, DeviceTarget, Credential, AgentHealth
│ ├── agent.proto EnrollmentService, AgentService, ControlUp/ControlDown
│ ├── telemetry.proto SeriesDescriptor, MetricSample, IcmpResult, InterfaceCounters
│ ├── discovery.proto NeighborRecord, InterfaceRecord, DiscoveredDevice
│ ├── ncm.proto ConfigJob, ConfigUpload (header/chunk/trailer), ConfigApplyJob
│ └── logs.proto SyslogEntry, SnmpTrap, LogBatch
├── gen/go/ згенерований код (комітиться)
└── test/contract/ наскрізні gRPC-тести на bufconn
```
### Прийняті рішення
1. **Форма контракту випливає з одного обмеження:** усі з'єднання ініціює агент.
«Команда з сервера» — це повідомлення у зустрічному напрямку вже відкритого
агентом bidi-стріму `Control`, а не RPC у бік агента.
2. **Чотири окремі стріми** (Control / Telemetry / Logs / Config), а не один:
пачка семплів не має блокувати heartbeat, сплеск syslog під час аварії
не має топити телеметрію.
3. **Інтернування серій.** Агент реєструє серію раз під `series_ref`, далі шле
лише номер. Заміряно: 66 → 25 байт на семпл. `series_ref` живе в межах сесії.
4. **Швидкості рахує агент** (лише він знає точний інтервал опитування), але шле
й сирі лічильники — щоб сервер міг перерахувати заднім числом.
5. **Scrub/redact конфігів — на сервері.** Агент віддає сирий текст; правила
живуть у `ncm.profiles` і змінюються без оновлення агентів у полі.
6. **Топологію зводить сервер.** Агент доповідає лише «на порту X бачу chassis Y».
7. **`Task.params_json` — непрозорі байти.** Новий плагін не потребує зміни .proto.
Модуль-виконавець виводиться з префікса `check_type` до крапки.
8. **At-least-once + upsert.** Дедуплікацію дають PK схеми БД `(ts, device_id)` тощо.
9. **Зворотний тиск диктує сервер** у `Welcome` і кожному `TelemetryAck`.
10. **Самооновлення підписане Ed25519** — інакше компрометація CDN = RCE в мережі
кожного клієнта.
### Перевірено на стенді
Debian 13 (192.168.1.203): protoc 3.21.12, Go 1.24.4, buf 1.72.0.
- `protoc`усі 6 файлів валідні;
- `buf lint` (STANDARD) — без зауважень;
- генерація Go+gRPC, `go build`, `go vet` — чисто;
- `go test ./test/contract/...`**5/5 PASS**: рукостискання й push плану задач,
інтернування серій, реакція на невідомий `series_ref`, чанкування конфігу зі
звіркою sha256, відмова при пошкодженій контрольній сумі.
Дрібниця для наступного разу: `buf.yaml` довелось звільнити від
`RPC_REQUEST_STANDARD_NAME` та сусідніх правил — вони припускають пари
запит-відповідь, а `ControlUp`/`ControlDown` це незалежні потоки подій.
### Далі (Етап 2, частина 2)
Сам Go-агент: скелет із gRPC-клієнтом і реконектом, планувальник задач
зі `schedule_offset`, модулі ICMP і SNMP, збір сусідів LLDP/CDP/ARP,
буфер телеметрії з обмеженням пам'яті (бюджет RSS < 30 МБ).
Паралельно — серверна сторона `AgentService` поверх схеми з Етапу 1.

25
buf.gen.yaml Normal file
View file

@ -0,0 +1,25 @@
version: v2
managed:
enabled: true
override:
- file_option: go_package_prefix
value: github.com/netpulse/netpulse/gen/go
plugins:
# Go-структури
- remote: buf.build/protocolbuffers/go
out: gen/go
opt: paths=source_relative
# gRPC-клієнт (агент) і сервер
- remote: buf.build/grpc/go
out: gen/go
opt:
- paths=source_relative
- require_unimplemented_servers=false
# TypeScript для адмін-інструментів і e2e-тестів фронтенду
- remote: buf.build/bufbuild/es
out: gen/ts
opt: target=ts

19
buf.yaml Normal file
View file

@ -0,0 +1,19 @@
version: v2
modules:
- path: proto
lint:
use:
- STANDARD
except:
# Ми свідомо не додаємо суфікс Request/Response до повідомлень
# bidi-стріму: ControlUp/ControlDown — не пари запит-відповідь,
# а незалежні потоки подій у двох напрямках.
- RPC_REQUEST_STANDARD_NAME
- RPC_RESPONSE_STANDARD_NAME
- RPC_REQUEST_RESPONSE_UNIQUE
breaking:
use:
- FILE

141
db/README.md Normal file
View file

@ -0,0 +1,141 @@
# NetPulse — схема бази даних (Етап 1)
PostgreSQL 16+ / TimescaleDB 2.14+. Міграції в `migrations/`, накочуються по порядку номерів.
```bash
docker compose up -d db
```
```bash
pwsh ./db/migrate.ps1
```
## Схеми (namespaces)
| Схема | Призначення |
|--------|-------------|
| `core` | Ядро: тенанти, users, RBAC, секрети, агенти, плагіни, чеки, дашборди, аудит |
| `inv` | Інвентар: сайти, групи, пристрої, інтерфейси, IP, підмережі, креденшели |
| `topo` | Топологія: сирі сусіди (LLDP/CDP/ARP), зведені лінки, мапи, вузли, ребра, підкладки |
| `ts` | Time-series: hypertables + continuous aggregates |
| `ncm` | Config management: репо, профілі, задачі, версії конфігів, diff, compliance |
| `alr` | Alerting: правила, алерти, канали, ескалації, вікна обслуговування |
| `bill` | Тарифи, підписки, entitlements, usage, інвойси, ліцензійні ключі |
## Порядок міграцій
| # | Файл | Що містить |
|---|------|-----------|
| 0001 | `0001_core.sql` | Розширення, домени, tenants, users, RBAC, секрети (AES-GCM), audit_log |
| 0002 | `0002_inventory.sql` | Sites, groups, devices, interfaces, addresses, subnets, credentials |
| 0003 | `0003_agents_plugins.sql` | Реєстр плагінів, агенти, типи чеків, checks, event outbox |
| 0004 | `0004_topology.sql` | **Neighbors → links → maps → nodes/edges → backgrounds → revisions** |
| 0005 | `0005_telemetry_timescale.sql` | Hypertables, CAGG, compression, retention, view'и для мапи |
| 0006 | `0006_ncm.sql` | Git-репо, профілі збору, jobs, configs, diffs, compliance, rollback |
| 0007 | `0007_alerting.sql` | Rules, alerts, channels, push, routes, maintenance, mutes |
| 0008 | `0008_dashboards.sql` | Дашборди, віджети (в т.ч. `map`), saved views, SLA |
| 0009 | `0009_billing_licensing.sql` | Plans, subscriptions, entitlements, usage, invoices, license keys |
| 0010 | `0010_seed.sql` | Довідники: права, ролі, плагіни, чеки, тарифи, NCM-профілі |
| 0011 | `0011_rls.sql` | Row Level Security + ролі БД |
> 0010 навмисно йде **перед** 0011: після `FORCE ROW LEVEL SECURITY` навіть власник схеми не зможе вставити довідникові рядки з `tenant_id IS NULL`.
## ERD: ядро топології
```mermaid
erDiagram
TENANTS ||--o{ DEVICES : ""
TENANTS ||--o{ MAPS : ""
SITES ||--o{ DEVICES : ""
DEVICES ||--o{ INTERFACES : ""
DEVICES ||--o{ NEIGHBORS : "виявлено з"
NEIGHBORS }o--|| LINKS : "резолвиться в"
INTERFACES ||--o{ LINKS : "a_if / b_if"
MAPS ||--o{ MAP_NODES : ""
MAPS ||--o{ MAP_EDGES : ""
MAPS ||--o{ MAP_BACKGROUNDS : "floor plan / OSM / rack"
MAPS ||--o{ MAP_REVISIONS : "undo/redo"
DEVICES ||--o{ MAP_NODES : "kind='device'"
MAP_NODES ||--o{ MAP_EDGES : "source/target"
LINKS ||--o{ MAP_EDGES : "статус і трафік"
INTERFACES ||--o{ IF_COUNTERS : "трафік → анімація"
DEVICES ||--o{ ICMP_SAMPLES : "колір вузла"
```
## Ключові рішення
**1. Фізична топологія відокремлена від візуальної.**
`topo.links` — те, що існує в мережі (виявлено LLDP/CDP або створено вручну).
`topo.map_edges` — як це намальовано на конкретній мапі. Один лінк може бути показаний на N мапах з різними стилями; видалення мапи не чіпає топологію. `map_edges.link_id` — джерело статусу й трафіку для лінії.
**2. Порт-у-порт зв'язки.**
І `topo.links`, і `topo.map_edges` посилаються на `inv.interfaces`, тому `Switch1:Port1 → Router1:eth0` — це FK, а не текст. Унікальний індекс `links_pair_uniq` нормалізує пару через `LEAST/GREATEST`, щоб A→B і B→A не дублювались.
**3. Автовиявлення не затирає ручну роботу.**
`topo.neighbors` зберігає сире, як його віддав агент, плюс `resolved_*` і `confidence`. Резолвер зводить це в `topo.links`. Прапорець `is_pinned` захищає підтверджені людиною лінки від перезапису.
**4. Дві моделі метрик.**
- `ts.series` + `ts.samples` — узагальнена (Prometheus-подібна): будь-який плагін реєструє свій `metric_key` без зміни DDL.
- `ts.icmp_samples` і `ts.if_counters` — широкі таблиці для гарячих шляхів. Це те, що читає мапа на кожному тику WebSocket, і денормалізація тут окупається.
**5. Анімація трафіку має конкретне джерело.**
`if_counters.util_out_pct` (% від `interfaces.speed_bps`) → `topo.link_live` (гірша з двох сторін) → `map_edges.animation.speed_source='utilization'`. Кольори порогів — у `map_edges.thresholds`.
**6. Продуктивність мапи на 1000+ вузлів.**
`map_nodes` має GiST-індекс по `point(x,y)` для viewport-culling, `map_edges` — індекси по source/target. Повний стан полотна тягнеться одним запитом (nodes + edges + backgrounds + останні статуси через `ts.device_last_icmp` і `topo.link_live`), далі дельти йдуть по WebSocket.
**7. Ліміти тарифу перевіряються двічі.**
`bill.entitlements` — матеріалізовані права тенанта (читає API на кожному запиті, кешується в Redis). Плюс тригери `bill.assert_device_limit()` і `bill.assert_map_node_limit()` як друга лінія оборони на рівні БД.
**8. Секрети ніколи не лежать відкрито.**
`core.secrets` зберігає лише `ciphertext` + `nonce` + `auth_tag` + `key_id` (AES-GCM-256, шифрування на боці застосунку, DEK у KMS/Vault). Паролі SSH/SNMPv3, приватні ключі, токени каналів і тіла конфігів — усе через цю таблицю.
**9. Тіло конфігів — у Git, метадані — у БД.**
`ncm.configs` тримає `commit_sha`/`blob_sha`/`path` та `content_hash` для швидкої відповіді «змінилось?». Diff-и кешуються в `ncm.diffs`, щоб Telegram Mini App не рахував їх щоразу.
## Ізоляція тенантів
API виставляє на кожній транзакції:
```sql
SET LOCAL app.tenant_id = '<uuid>';
```
Політика `tenant_isolation` (USING + WITH CHECK) вмикається автоматично на кожній таблиці з колонкою `tenant_id`. Якщо змінна не виставлена — `core.current_tenant()` повертає NULL і запит дає порожній результат: це навмисно безпечніше за «усі тенанти».
**Свідомі винятки, які треба тримати в голові:**
- **Усі 12 hypertables** — RLS вимкнено. TimescaleDB відмовляє: `operation not supported on hypertables that have columnstore enabled`, тобто RLS несумісний зі стисненням, а стиснення увімкнене на всіх гарячих time-series таблицях. Виключено гіпертаблі цілком, а не вибірково — інакше набір захищених таблиць мовчки залежав би від того, на які з них уже накотили compression policy. Ізоляція для них: доступ лише через JOIN з `inv.devices` / `inv.interfaces` / `ts.series` (усі під RLS) плюс обов'язковий предикат `tenant_id` у репозиторному шарі API.
- Зв'язкові таблиці без власного `tenant_id` (`core.role_permissions`, `inv.device_group_members`, `inv.device_tags`, `inv.device_credentials`, `bill.invoice_lines`, `topo.map_shares`) покладаються на FK-каскад від захищеного батька.
- `netpulse_worker` має `BYPASSRLS` — воркери агрегації, білінгу й retention працюють поверх тенантів.
## Стан перевірки
Схема прогнана на живому стенді: **Debian 13 / PostgreSQL 17.11 / TimescaleDB 2.29.1**.
Усі 11 міграцій накотились на чисту БД без помилок.
| Об'єкт | Кількість |
|--------|-----------|
| Таблиць | 81 |
| Hypertables | 12 |
| Continuous aggregates | 6 |
| Фонових job-ів (compression/retention/refresh) | 25 |
| RLS-політик | 63 |
| Індексів | 220 |
`tests/smoke.sql` перевіряє не лише синтаксис, а й поведінку моделі:
```bash
psql -d netpulse -f db/tests/smoke.sql
```
| Перевірка | Результат |
|-----------|-----------|
| Дзеркальний лінк B→A відхилено (`links_pair_uniq`) | PASS |
| `kind='device'` без `device_id` відхилено (`map_nodes_kind_ref_chk`) | PASS |
| Дубль активного алерту відхилено (`alerts_active_dedup_uniq`) | PASS |
| 16-й пристрій на тарифі Free відхилено тригером | PASS |
| Стан полотна мапи одним запитом (вузли + статуси + RTT) | PASS |
| Ребро з портами `Gi0/1 → ether1` і живим `util_pct` = 78% | PASS |
| CAGG `if_counters_5m` рахує роллапи | PASS |
| RLS: ACME бачить 15 пристроїв, Globex — 0, без `app.tenant_id` — 0 | PASS |

63
db/migrate.ps1 Normal file
View file

@ -0,0 +1,63 @@
<#
Накочує міграції по порядку номерів у транзакції (кожен файл окремо).
Використання:
.\migrate.ps1 # локальний стенд з docker-compose
.\migrate.ps1 -DbUrl "postgres://user:pw@host:5432/db"
.\migrate.ps1 -DryRun # лише показати порядок
#>
param(
[string]$DbUrl = "postgres://netpulse:netpulse@localhost:5432/netpulse",
[switch]$DryRun
)
$ErrorActionPreference = "Stop"
$dir = Join-Path $PSScriptRoot "migrations"
$files = Get-ChildItem -Path $dir -Filter "*.sql" | Sort-Object Name
if ($DryRun) {
$files | ForEach-Object { $_.Name }
return
}
if (-not (Get-Command psql -ErrorAction SilentlyContinue)) {
throw "psql не знайдено в PATH. Встанови PostgreSQL client tools або запусти через контейнер: docker compose exec -T db psql ..."
}
# Таблиця обліку застосованих міграцій
$bootstrap = @'
CREATE TABLE IF NOT EXISTS public.schema_migrations (
version text PRIMARY KEY,
checksum text NOT NULL,
applied_at timestamptz NOT NULL DEFAULT now()
);
'@
$bootstrap | psql $DbUrl -v ON_ERROR_STOP=1 -q
foreach ($f in $files) {
$version = $f.BaseName
$applied = (psql $DbUrl -tA -c "SELECT 1 FROM public.schema_migrations WHERE version = '$version'").Trim()
if ($applied -eq "1") {
Write-Host "skip $version" -ForegroundColor DarkGray
continue
}
$sum = (Get-FileHash $f.FullName -Algorithm SHA256).Hash
Write-Host "apply $version" -ForegroundColor Cyan
# Зазвичай — одна транзакція на файл, щоб не було часткових міграцій.
# Виняток: TimescaleDB забороняє CREATE MATERIALIZED VIEW WITH
# (timescaledb.continuous) всередині транзакційного блоку.
$needsAutocommit = (Select-String -Path $f.FullName -Pattern "timescaledb\.continuous" -Quiet)
if ($needsAutocommit) {
Write-Host " (autocommit: continuous aggregates)" -ForegroundColor DarkYellow
psql $DbUrl -v ON_ERROR_STOP=1 -q -f $f.FullName
} else {
psql $DbUrl -v ON_ERROR_STOP=1 --single-transaction -q -f $f.FullName
}
if ($LASTEXITCODE -ne 0) { throw "Міграція $version впала" }
psql $DbUrl -v ON_ERROR_STOP=1 -q -c `
"INSERT INTO public.schema_migrations (version, checksum) VALUES ('$version','$sum')"
}
Write-Host "OK: усі міграції застосовано" -ForegroundColor Green

229
db/migrations/0001_core.sql Normal file
View file

@ -0,0 +1,229 @@
-- =====================================================================
-- NetPulse :: 0001_core.sql
-- Розширення, домени, мультитенантність, автентифікація, RBAC, аудит.
-- PostgreSQL 16+ / TimescaleDB 2.14+
-- =====================================================================
CREATE EXTENSION IF NOT EXISTS timescaledb;
CREATE EXTENSION IF NOT EXISTS pgcrypto; -- digest(), gen_random_bytes()
CREATE EXTENSION IF NOT EXISTS citext; -- регістронезалежні email/hostname
CREATE EXTENSION IF NOT EXISTS pg_trgm; -- пошук по інвентарю
CREATE EXTENSION IF NOT EXISTS btree_gist; -- EXCLUDE-обмеження (maintenance windows)
CREATE SCHEMA IF NOT EXISTS core;
CREATE SCHEMA IF NOT EXISTS inv; -- inventory
CREATE SCHEMA IF NOT EXISTS topo; -- топологія / мапи
CREATE SCHEMA IF NOT EXISTS ts; -- time-series (hypertables)
CREATE SCHEMA IF NOT EXISTS ncm; -- network config management
CREATE SCHEMA IF NOT EXISTS alr; -- alerting
CREATE SCHEMA IF NOT EXISTS bill; -- billing / licensing
-- ---------------------------------------------------------------------
-- Спільні домени й хелпери
-- ---------------------------------------------------------------------
-- Slug: [a-z0-9_-], 1..64, починається й закінчується літерою/цифрою.
-- Підкреслення дозволене — ключі фіч (http_checks, auto_discovery) його використовують.
CREATE DOMAIN core.slug AS text
CHECK (VALUE ~ '^[a-z0-9]([a-z0-9_-]{0,62}[a-z0-9])?$');
-- Обгортка для UUIDv7-подібних ключів. Поки що v4 (gen_random_uuid),
-- заміна на pg_uuidv7 не потребує зміни DDL.
CREATE OR REPLACE FUNCTION core.new_id() RETURNS uuid
LANGUAGE sql VOLATILE AS $$ SELECT gen_random_uuid() $$;
-- Поточний tenant із сесійної змінної (використовується в RLS).
CREATE OR REPLACE FUNCTION core.current_tenant() RETURNS uuid
LANGUAGE sql STABLE AS $$
SELECT NULLIF(current_setting('app.tenant_id', true), '')::uuid
$$;
CREATE OR REPLACE FUNCTION core.touch_updated_at() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END $$;
-- ---------------------------------------------------------------------
-- Tenants (організації)
-- ---------------------------------------------------------------------
CREATE TYPE core.tenant_status AS ENUM ('trial','active','past_due','suspended','cancelled');
CREATE TABLE core.tenants (
id uuid PRIMARY KEY DEFAULT core.new_id(),
slug core.slug NOT NULL UNIQUE,
name text NOT NULL,
status core.tenant_status NOT NULL DEFAULT 'trial',
-- White-label (Enterprise): logo_url, primary_color, custom_domain, favicon
branding jsonb NOT NULL DEFAULT '{}'::jsonb,
settings jsonb NOT NULL DEFAULT '{}'::jsonb,
timezone text NOT NULL DEFAULT 'UTC',
-- Ідентифікатор інсталяції для self-hosted (прив'язка ліцензійного ключа)
install_id uuid NOT NULL DEFAULT core.new_id(),
trial_ends_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
CREATE TRIGGER trg_tenants_touch BEFORE UPDATE ON core.tenants
FOR EACH ROW EXECUTE FUNCTION core.touch_updated_at();
-- ---------------------------------------------------------------------
-- Users / Membership / RBAC
-- ---------------------------------------------------------------------
CREATE TABLE core.users (
id uuid PRIMARY KEY DEFAULT core.new_id(),
email citext NOT NULL UNIQUE,
email_verified_at timestamptz,
password_hash text, -- argon2id; NULL для SSO-only
full_name text,
avatar_url text,
locale text NOT NULL DEFAULT 'uk',
timezone text NOT NULL DEFAULT 'UTC',
totp_secret_enc bytea, -- AES-GCM-256, див. core.secrets
mfa_enabled boolean NOT NULL DEFAULT false,
-- Прив'язка Telegram для Mini App / сповіщень
telegram_user_id bigint UNIQUE,
last_login_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
CREATE TRIGGER trg_users_touch BEFORE UPDATE ON core.users
FOR EACH ROW EXECUTE FUNCTION core.touch_updated_at();
-- Ролі: системні (tenant_id IS NULL) + кастомні на рівні тенанта (Enterprise)
CREATE TABLE core.roles (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid REFERENCES core.tenants(id) ON DELETE CASCADE,
key core.slug NOT NULL, -- owner, admin, engineer, viewer, ...
name text NOT NULL,
description text,
is_system boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now()
);
-- Один і той самий key унікальний в межах тенанта; системні — глобально.
CREATE UNIQUE INDEX roles_tenant_key_uniq
ON core.roles (COALESCE(tenant_id, '00000000-0000-0000-0000-000000000000'::uuid), key);
-- Права у вигляді "resource:action" (devices:read, maps:write, ncm:diff, billing:manage)
CREATE TABLE core.permissions (
key text PRIMARY KEY CHECK (key ~ '^[a-z_]+:[a-z_]+$'),
description text NOT NULL,
-- Плагін, який зареєстрував право (NULL = ядро)
plugin_key core.slug
);
CREATE TABLE core.role_permissions (
role_id uuid NOT NULL REFERENCES core.roles(id) ON DELETE CASCADE,
permission_key text NOT NULL REFERENCES core.permissions(key) ON DELETE CASCADE,
PRIMARY KEY (role_id, permission_key)
);
CREATE TABLE core.memberships (
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES core.users(id) ON DELETE CASCADE,
role_id uuid NOT NULL REFERENCES core.roles(id) ON DELETE RESTRICT,
-- Обмеження видимості: NULL = усі пристрої тенанта, інакше — перелік груп
scope_group_ids uuid[],
invited_by uuid REFERENCES core.users(id) ON DELETE SET NULL,
accepted_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, user_id)
);
CREATE INDEX memberships_user_idx ON core.memberships (user_id);
CREATE TABLE core.invitations (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
email citext NOT NULL,
role_id uuid NOT NULL REFERENCES core.roles(id) ON DELETE CASCADE,
token_hash bytea NOT NULL,
expires_at timestamptz NOT NULL,
accepted_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, email)
);
-- Сесії (refresh-токени) + API-ключі
CREATE TABLE core.sessions (
id uuid PRIMARY KEY DEFAULT core.new_id(),
user_id uuid NOT NULL REFERENCES core.users(id) ON DELETE CASCADE,
tenant_id uuid REFERENCES core.tenants(id) ON DELETE CASCADE,
token_hash bytea NOT NULL UNIQUE, -- sha256(refresh_token)
user_agent text,
ip inet,
expires_at timestamptz NOT NULL,
revoked_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX sessions_user_idx ON core.sessions (user_id) WHERE revoked_at IS NULL;
CREATE TABLE core.api_tokens (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
created_by uuid REFERENCES core.users(id) ON DELETE SET NULL,
name text NOT NULL,
prefix text NOT NULL, -- перші 8 символів для показу в UI
token_hash bytea NOT NULL UNIQUE,
scopes text[] NOT NULL DEFAULT '{}',
last_used_at timestamptz,
expires_at timestamptz,
revoked_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX api_tokens_tenant_idx ON core.api_tokens (tenant_id);
-- ---------------------------------------------------------------------
-- Сховище секретів (AES-GCM-256; шифрування на боці застосунку)
-- Жоден секрет не потрапляє в БД у відкритому вигляді.
-- ---------------------------------------------------------------------
CREATE TYPE core.secret_kind AS ENUM (
'ssh_password','ssh_key','snmp_v3','api_token','telnet','enable_password',
'webhook_secret','totp','generic'
);
CREATE TABLE core.secrets (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
kind core.secret_kind NOT NULL,
alg text NOT NULL DEFAULT 'AES-256-GCM',
key_id text NOT NULL, -- ідентифікатор DEK у KMS/Vault (ротація)
nonce bytea NOT NULL, -- 96-bit IV
ciphertext bytea NOT NULL,
auth_tag bytea NOT NULL, -- 128-bit GCM tag
aad text, -- зазвичай tenant_id||kind||object_id
version int NOT NULL DEFAULT 1,
created_at timestamptz NOT NULL DEFAULT now(),
rotated_at timestamptz
);
CREATE INDEX secrets_tenant_idx ON core.secrets (tenant_id, kind);
-- ---------------------------------------------------------------------
-- Аудит (hypertable — журнал росте лінійно)
-- ---------------------------------------------------------------------
CREATE TABLE core.audit_log (
ts timestamptz NOT NULL DEFAULT now(),
id uuid NOT NULL DEFAULT core.new_id(),
tenant_id uuid,
actor_user_id uuid,
actor_token_id uuid,
actor_ip inet,
action text NOT NULL, -- device.create, map.update, ncm.rollback
object_type text,
object_id uuid,
before jsonb,
after jsonb,
meta jsonb NOT NULL DEFAULT '{}'::jsonb,
PRIMARY KEY (ts, id)
);
SELECT create_hypertable('core.audit_log', 'ts',
chunk_time_interval => INTERVAL '7 days',
if_not_exists => TRUE);
CREATE INDEX audit_tenant_ts_idx ON core.audit_log (tenant_id, ts DESC);
CREATE INDEX audit_object_idx ON core.audit_log (object_type, object_id, ts DESC);

View file

@ -0,0 +1,226 @@
-- =====================================================================
-- NetPulse :: 0002_inventory.sql
-- Локації, групи, пристрої, інтерфейси, облікові дані, теги.
-- =====================================================================
-- ---------------------------------------------------------------------
-- Локації (сайти) та ієрархічні групи
-- ---------------------------------------------------------------------
CREATE TABLE inv.sites (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
parent_id uuid REFERENCES inv.sites(id) ON DELETE SET NULL,
name text NOT NULL,
code text, -- KYV-DC1
address text,
-- Геокоординати для OSM-підкладки мапи
lat double precision CHECK (lat BETWEEN -90 AND 90),
lon double precision CHECK (lon BETWEEN -180 AND 180),
timezone text,
meta jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, name)
);
CREATE INDEX sites_tenant_idx ON inv.sites (tenant_id);
CREATE TRIGGER trg_sites_touch BEFORE UPDATE ON inv.sites
FOR EACH ROW EXECUTE FUNCTION core.touch_updated_at();
-- Групи для RBAC-скоупів, масових операцій та авто-групування на мапі
CREATE TYPE inv.group_kind AS ENUM ('static','dynamic');
CREATE TABLE inv.device_groups (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
parent_id uuid REFERENCES inv.device_groups(id) ON DELETE CASCADE,
name text NOT NULL,
kind inv.group_kind NOT NULL DEFAULT 'static',
-- Для dynamic: правило відбору {"all":[{"field":"vendor","op":"eq","value":"mikrotik"}]}
filter jsonb,
color text,
icon text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, parent_id, name)
);
CREATE INDEX device_groups_tenant_idx ON inv.device_groups (tenant_id);
-- ---------------------------------------------------------------------
-- Пристрої
-- ---------------------------------------------------------------------
CREATE TYPE inv.device_kind AS ENUM (
'router','switch','firewall','ap','server','vm','printer','ups','pdu',
'inverter','bms','sensor','camera','olt','onu','cloud','website','other'
);
CREATE TYPE inv.device_status AS ENUM ('up','down','warning','unknown','maintenance','disabled');
CREATE TABLE inv.devices (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
site_id uuid REFERENCES inv.sites(id) ON DELETE SET NULL,
agent_id uuid, -- FK додається в 0003 (зонд-опитувач)
name text NOT NULL,
hostname citext,
-- Основна адреса опитування
address inet,
fqdn text,
kind inv.device_kind NOT NULL DEFAULT 'other',
vendor text,
model text,
os_version text,
serial_number text,
-- Ідентифікатори для кореляції LLDP/CDP/ARP-виявлення
chassis_id text, -- LLDP chassis ID
system_name text, -- sysName / CDP device-id
base_mac macaddr,
mgmt_vlan int CHECK (mgmt_vlan BETWEEN 1 AND 4094),
status inv.device_status NOT NULL DEFAULT 'unknown',
status_changed_at timestamptz,
last_seen_at timestamptz,
-- Профілі опитування (які плагіни активні для цього пристрою)
monitoring jsonb NOT NULL DEFAULT '{}'::jsonb,
-- Джерело появи: manual | discovery | import | api
source text NOT NULL DEFAULT 'manual',
-- Чи тарифікується (Free-план рахує лише enabled)
is_billable boolean NOT NULL DEFAULT true,
enabled boolean NOT NULL DEFAULT true,
notes text,
meta jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
CREATE UNIQUE INDEX devices_tenant_name_uniq ON inv.devices (tenant_id, lower(name))
WHERE deleted_at IS NULL;
CREATE INDEX devices_tenant_status_idx ON inv.devices (tenant_id, status) WHERE deleted_at IS NULL;
CREATE INDEX devices_site_idx ON inv.devices (site_id);
CREATE INDEX devices_agent_idx ON inv.devices (agent_id);
CREATE INDEX devices_address_idx ON inv.devices (tenant_id, address);
CREATE INDEX devices_chassis_idx ON inv.devices (tenant_id, chassis_id) WHERE chassis_id IS NOT NULL;
CREATE INDEX devices_basemac_idx ON inv.devices (tenant_id, base_mac) WHERE base_mac IS NOT NULL;
CREATE INDEX devices_name_trgm ON inv.devices USING gin (name gin_trgm_ops);
CREATE TRIGGER trg_devices_touch BEFORE UPDATE ON inv.devices
FOR EACH ROW EXECUTE FUNCTION core.touch_updated_at();
CREATE TABLE inv.device_group_members (
group_id uuid NOT NULL REFERENCES inv.device_groups(id) ON DELETE CASCADE,
device_id uuid NOT NULL REFERENCES inv.devices(id) ON DELETE CASCADE,
PRIMARY KEY (group_id, device_id)
);
CREATE INDEX dgm_device_idx ON inv.device_group_members (device_id);
CREATE TABLE inv.tags (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
key text NOT NULL,
value text,
color text,
UNIQUE (tenant_id, key, value)
);
CREATE TABLE inv.device_tags (
device_id uuid NOT NULL REFERENCES inv.devices(id) ON DELETE CASCADE,
tag_id uuid NOT NULL REFERENCES inv.tags(id) ON DELETE CASCADE,
PRIMARY KEY (device_id, tag_id)
);
-- ---------------------------------------------------------------------
-- Інтерфейси (потрібні для зв'язків на мапі "port -> port")
-- ---------------------------------------------------------------------
CREATE TYPE inv.if_admin_status AS ENUM ('up','down','testing','unknown');
CREATE TYPE inv.if_oper_status AS ENUM ('up','down','testing','unknown','dormant','notPresent','lowerLayerDown');
CREATE TABLE inv.interfaces (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
device_id uuid NOT NULL REFERENCES inv.devices(id) ON DELETE CASCADE,
if_index bigint, -- SNMP ifIndex
name text NOT NULL, -- GigabitEthernet0/1, ether1, eth0
alias text, -- ifAlias / description
mac macaddr,
mtu int,
type text, -- ethernetCsmacd, ieee8023adLag, l3ipvlan...
speed_bps bigint, -- номінальна швидкість (для % завантаження)
duplex text,
admin_status inv.if_admin_status NOT NULL DEFAULT 'unknown',
oper_status inv.if_oper_status NOT NULL DEFAULT 'unknown',
last_change_at timestamptz,
-- LAG/стек: посилання на батьківський агрегат
parent_if_id uuid REFERENCES inv.interfaces(id) ON DELETE SET NULL,
is_uplink boolean NOT NULL DEFAULT false,
monitored boolean NOT NULL DEFAULT true,
meta jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX interfaces_device_ifindex_uniq
ON inv.interfaces (device_id, if_index) WHERE if_index IS NOT NULL;
CREATE UNIQUE INDEX interfaces_device_name_uniq ON inv.interfaces (device_id, lower(name));
CREATE INDEX interfaces_tenant_idx ON inv.interfaces (tenant_id);
CREATE INDEX interfaces_mac_idx ON inv.interfaces (tenant_id, mac) WHERE mac IS NOT NULL;
CREATE TRIGGER trg_interfaces_touch BEFORE UPDATE ON inv.interfaces
FOR EACH ROW EXECUTE FUNCTION core.touch_updated_at();
-- IP-адреси інтерфейсів (для ARP-кореляції та L3-топології)
CREATE TABLE inv.interface_addresses (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
interface_id uuid NOT NULL REFERENCES inv.interfaces(id) ON DELETE CASCADE,
address inet NOT NULL,
is_primary boolean NOT NULL DEFAULT false,
vrf text,
UNIQUE (interface_id, address)
);
CREATE INDEX if_addr_lookup_idx ON inv.interface_addresses (tenant_id, address);
-- Підмережі — для авто-групування пристроїв на мапі
CREATE TABLE inv.subnets (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
site_id uuid REFERENCES inv.sites(id) ON DELETE SET NULL,
cidr cidr NOT NULL,
vlan_id int CHECK (vlan_id BETWEEN 1 AND 4094),
name text,
description text,
UNIQUE (tenant_id, cidr)
);
-- ---------------------------------------------------------------------
-- Облікові дані для опитування / NCM (посилання на core.secrets)
-- ---------------------------------------------------------------------
CREATE TYPE inv.credential_proto AS ENUM ('ssh','telnet','snmp_v2c','snmp_v3','http','https','api','modbus');
CREATE TABLE inv.credentials (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
name text NOT NULL,
proto inv.credential_proto NOT NULL,
username text,
port int CHECK (port BETWEEN 1 AND 65535),
-- Секрети (пароль, приватний ключ, snmp v3 auth/priv) — лише шифровані
secret_id uuid REFERENCES core.secrets(id) ON DELETE SET NULL,
enable_secret_id uuid REFERENCES core.secrets(id) ON DELETE SET NULL,
-- SNMPv3: {"sec_level":"authPriv","auth_proto":"SHA256","priv_proto":"AES128","context":""}
options jsonb NOT NULL DEFAULT '{}'::jsonb,
is_default boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, name)
);
CREATE INDEX credentials_tenant_idx ON inv.credentials (tenant_id, proto);
-- Прив'язка кредів до пристроїв (пристрій може мати SSH + SNMP одночасно)
CREATE TABLE inv.device_credentials (
device_id uuid NOT NULL REFERENCES inv.devices(id) ON DELETE CASCADE,
credential_id uuid NOT NULL REFERENCES inv.credentials(id) ON DELETE CASCADE,
priority int NOT NULL DEFAULT 100,
PRIMARY KEY (device_id, credential_id)
);

View file

@ -0,0 +1,129 @@
-- =====================================================================
-- NetPulse :: 0003_agents_plugins.sql
-- Зонди (Go-агенти), реєстр плагінів, завдання опитування, черги.
-- =====================================================================
-- ---------------------------------------------------------------------
-- Реєстр плагінів (Plugin-First Architecture)
-- Ядро знає лише про Auth/RBAC/EventBus/TSDB/API — решта тут.
-- ---------------------------------------------------------------------
CREATE TYPE core.plugin_scope AS ENUM ('agent','server','ui','both');
CREATE TABLE core.plugins (
key core.slug PRIMARY KEY, -- icmp, snmp, ncm, topology, netflow, modbus
name text NOT NULL,
version text NOT NULL,
scope core.plugin_scope NOT NULL,
publisher text NOT NULL DEFAULT 'netpulse',
description text,
-- Оголошення можливостей: метрики, типи чеків, UI-панелі, права доступу
manifest jsonb NOT NULL,
-- Мінімальний тариф, з якого плагін доступний (NULL = усі)
min_plan_key core.slug,
checksum bytea, -- sha256 бінарника/бандла
is_core boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE core.plugin_installs (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
plugin_key core.slug NOT NULL REFERENCES core.plugins(key) ON DELETE CASCADE,
enabled boolean NOT NULL DEFAULT true,
config jsonb NOT NULL DEFAULT '{}'::jsonb,
installed_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, plugin_key)
);
-- ---------------------------------------------------------------------
-- Агенти / зонди
-- ---------------------------------------------------------------------
CREATE TYPE core.agent_status AS ENUM ('pending','online','degraded','offline','disabled');
CREATE TABLE core.agents (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
site_id uuid REFERENCES inv.sites(id) ON DELETE SET NULL,
name text NOT NULL,
-- Автентифікація агента: sha256(enrollment/agent token). Тільки вихідний gRPC/TLS.
token_hash bytea NOT NULL UNIQUE,
fingerprint text, -- TLS client cert SPKI pin
version text,
os text,
arch text,
hostname text,
public_ip inet,
status core.agent_status NOT NULL DEFAULT 'pending',
last_heartbeat_at timestamptz,
-- Які модулі активовані сервером на цьому агенті
enabled_modules core.slug[] NOT NULL DEFAULT '{icmp}',
-- Ліміти опитування: {"max_concurrency":256,"icmp_rate_pps":500}
limits jsonb NOT NULL DEFAULT '{}'::jsonb,
-- Останні самометрики агента (RAM/CPU/черга) для швидкого показу в UI
health jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, name)
);
CREATE INDEX agents_tenant_status_idx ON core.agents (tenant_id, status);
CREATE TRIGGER trg_agents_touch BEFORE UPDATE ON core.agents
FOR EACH ROW EXECUTE FUNCTION core.touch_updated_at();
-- Відкладений FK з 0002 (devices.agent_id)
ALTER TABLE inv.devices
ADD CONSTRAINT devices_agent_fk
FOREIGN KEY (agent_id) REFERENCES core.agents(id) ON DELETE SET NULL;
-- ---------------------------------------------------------------------
-- Завдання опитування (те, що агент реально виконує)
-- ---------------------------------------------------------------------
CREATE TABLE core.check_types (
-- Ключ у формі "<plugin>.<check>" — крапка не проходить core.slug, тому власний CHECK
key text PRIMARY KEY CHECK (key ~ '^[a-z0-9]+(\.[a-z0-9_]+)+$'),
-- icmp.ping, snmp.get, snmp.walk, http.status,
plugin_key core.slug NOT NULL REFERENCES core.plugins(key) ON DELETE CASCADE,
name text NOT NULL,
-- JSON Schema параметрів + перелік метрик, які повертає чек
params_schema jsonb NOT NULL DEFAULT '{}'::jsonb,
metrics jsonb NOT NULL DEFAULT '[]'::jsonb
);
CREATE TABLE core.checks (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
device_id uuid NOT NULL REFERENCES inv.devices(id) ON DELETE CASCADE,
interface_id uuid REFERENCES inv.interfaces(id) ON DELETE CASCADE,
check_type text NOT NULL REFERENCES core.check_types(key) ON DELETE RESTRICT,
params jsonb NOT NULL DEFAULT '{}'::jsonb,
interval_sec int NOT NULL DEFAULT 60 CHECK (interval_sec BETWEEN 5 AND 86400),
timeout_ms int NOT NULL DEFAULT 3000,
retries int NOT NULL DEFAULT 2,
enabled boolean NOT NULL DEFAULT true,
-- Стан планувальника
last_run_at timestamptz,
next_run_at timestamptz,
last_error text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX checks_device_idx ON core.checks (device_id);
CREATE INDEX checks_schedule_idx ON core.checks (next_run_at) WHERE enabled;
CREATE UNIQUE INDEX checks_uniq
ON core.checks (device_id, check_type, COALESCE(interface_id, '00000000-0000-0000-0000-000000000000'::uuid), md5(params::text));
-- ---------------------------------------------------------------------
-- Event Bus (outbox): усе, що йде у Redis/WebSocket, спершу лягає сюди
-- ---------------------------------------------------------------------
CREATE TABLE core.event_outbox (
id bigserial PRIMARY KEY,
tenant_id uuid NOT NULL,
topic text NOT NULL, -- device.status, link.status, alert.fired, ncm.diff
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
published_at timestamptz
);
CREATE INDEX event_outbox_pending_idx ON core.event_outbox (id) WHERE published_at IS NULL;

View file

@ -0,0 +1,348 @@
-- =====================================================================
-- NetPulse :: 0004_topology.sql
-- Мапи, вузли з координатами, зв'язки port->port, підкладки (floor plans),
-- сире автовиявлення (LLDP/CDP/ARP/FDB) та зведені фізичні лінки.
-- =====================================================================
-- =====================================================================
-- ЧАСТИНА A. ФІЗИЧНА ТОПОЛОГІЯ (те, що існує в мережі)
-- Не залежить від мап: один лінк може бути показаний на N мапах.
-- =====================================================================
CREATE TYPE topo.discovery_proto AS ENUM ('lldp','cdp','arp','fdb','stp','routing','manual','snmp_topo');
-- Сирі сусіди, як їх повідомив агент. Історія перезаписується per (interface, proto).
CREATE TABLE topo.neighbors (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
device_id uuid NOT NULL REFERENCES inv.devices(id) ON DELETE CASCADE,
interface_id uuid REFERENCES inv.interfaces(id) ON DELETE CASCADE,
proto topo.discovery_proto NOT NULL,
-- Те, що бачимо "з іншого боку" (може ще не бути в інвентарі)
remote_chassis_id text,
remote_system_name text,
remote_port_id text,
remote_port_descr text,
remote_mgmt_ip inet,
remote_mac macaddr,
remote_platform text,
remote_capabilities text[], -- ['bridge','router','wlan-ap']
-- Резолвлений збіг з інвентарем (заповнює сервер-резолвер)
resolved_device_id uuid REFERENCES inv.devices(id) ON DELETE SET NULL,
resolved_interface_id uuid REFERENCES inv.interfaces(id) ON DELETE SET NULL,
confidence smallint NOT NULL DEFAULT 0 CHECK (confidence BETWEEN 0 AND 100),
first_seen_at timestamptz NOT NULL DEFAULT now(),
last_seen_at timestamptz NOT NULL DEFAULT now(),
raw jsonb NOT NULL DEFAULT '{}'::jsonb
);
CREATE UNIQUE INDEX neighbors_uniq
ON topo.neighbors (device_id, COALESCE(interface_id, '00000000-0000-0000-0000-000000000000'::uuid),
proto, COALESCE(remote_chassis_id,''), COALESCE(remote_port_id,''));
CREATE INDEX neighbors_tenant_idx ON topo.neighbors (tenant_id, last_seen_at DESC);
CREATE INDEX neighbors_resolved_idx ON topo.neighbors (resolved_device_id);
-- Зведений фізичний/логічний лінк між двома портами.
CREATE TYPE topo.link_kind AS ENUM ('physical','lag','wireless','vpn','stack','logical','virtual','power','serial');
CREATE TABLE topo.links (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
a_device_id uuid NOT NULL REFERENCES inv.devices(id) ON DELETE CASCADE,
a_interface_id uuid REFERENCES inv.interfaces(id) ON DELETE SET NULL,
b_device_id uuid NOT NULL REFERENCES inv.devices(id) ON DELETE CASCADE,
b_interface_id uuid REFERENCES inv.interfaces(id) ON DELETE SET NULL,
kind topo.link_kind NOT NULL DEFAULT 'physical',
-- Пропускна здатність каналу (для % завантаження та швидкості анімації)
capacity_bps bigint,
discovered_by topo.discovery_proto NOT NULL DEFAULT 'manual',
confidence smallint NOT NULL DEFAULT 100 CHECK (confidence BETWEEN 0 AND 100),
-- Підтверджений людиною лінк не перезаписується автовиявленням
is_pinned boolean NOT NULL DEFAULT false,
status inv.device_status NOT NULL DEFAULT 'unknown',
status_changed_at timestamptz,
first_seen_at timestamptz NOT NULL DEFAULT now(),
last_seen_at timestamptz NOT NULL DEFAULT now(),
meta jsonb NOT NULL DEFAULT '{}'::jsonb,
CHECK (a_device_id <> b_device_id OR a_interface_id IS DISTINCT FROM b_interface_id)
);
-- Нормалізований ключ, щоб A->B і B->A не дублювались.
CREATE UNIQUE INDEX links_pair_uniq ON topo.links (
tenant_id,
LEAST(a_device_id, b_device_id),
GREATEST(a_device_id, b_device_id),
LEAST(COALESCE(a_interface_id,'00000000-0000-0000-0000-000000000000'::uuid),
COALESCE(b_interface_id,'00000000-0000-0000-0000-000000000000'::uuid)),
GREATEST(COALESCE(a_interface_id,'00000000-0000-0000-0000-000000000000'::uuid),
COALESCE(b_interface_id,'00000000-0000-0000-0000-000000000000'::uuid))
);
CREATE INDEX links_a_idx ON topo.links (a_device_id);
CREATE INDEX links_b_idx ON topo.links (b_device_id);
CREATE INDEX links_tenant_status_idx ON topo.links (tenant_id, status);
-- Запуски автовиявлення (для UI "Discovery in progress / знайдено N лінків")
CREATE TYPE topo.discovery_status AS ENUM ('queued','running','completed','failed','cancelled');
CREATE TABLE topo.discovery_runs (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
agent_id uuid REFERENCES core.agents(id) ON DELETE SET NULL,
started_by uuid REFERENCES core.users(id) ON DELETE SET NULL,
scope jsonb NOT NULL DEFAULT '{}'::jsonb, -- {"subnets":["10.0.0.0/24"],"protos":["lldp","cdp"]}
status topo.discovery_status NOT NULL DEFAULT 'queued',
devices_found int NOT NULL DEFAULT 0,
links_found int NOT NULL DEFAULT 0,
error text,
started_at timestamptz,
finished_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX discovery_runs_tenant_idx ON topo.discovery_runs (tenant_id, created_at DESC);
-- =====================================================================
-- ЧАСТИНА B. ВІЗУАЛЬНІ МАПИ (те, що бачить користувач)
-- =====================================================================
CREATE TYPE topo.map_kind AS ENUM ('logical','floor_plan','rack','geo','auto');
CREATE TYPE topo.layout_algo AS ENUM ('manual','grid','tree','circular','force','hierarchical','dagre');
CREATE TABLE topo.maps (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
site_id uuid REFERENCES inv.sites(id) ON DELETE SET NULL,
parent_map_id uuid REFERENCES topo.maps(id) ON DELETE SET NULL, -- drill-down підмапи
name text NOT NULL,
slug core.slug NOT NULL,
kind topo.map_kind NOT NULL DEFAULT 'logical',
description text,
-- Стан полотна
layout_algo topo.layout_algo NOT NULL DEFAULT 'manual',
viewport jsonb NOT NULL DEFAULT '{"x":0,"y":0,"zoom":1}'::jsonb,
grid jsonb NOT NULL DEFAULT '{"enabled":true,"size":16,"snap":true}'::jsonb,
-- Кластеризація при віддаленні: {"enabled":true,"zoom_threshold":0.4,"by":"site"}
clustering jsonb NOT NULL DEFAULT '{"enabled":true,"zoom_threshold":0.4}'::jsonb,
-- Правила автододавання пристроїв (kind='auto'): фільтр як у dynamic-групах
auto_rule jsonb,
theme jsonb NOT NULL DEFAULT '{}'::jsonb,
is_default boolean NOT NULL DEFAULT false,
-- Публічний read-only лінк (status page)
public_token text UNIQUE,
-- Оптимістичне блокування при спільному редагуванні
revision bigint NOT NULL DEFAULT 1,
created_by uuid REFERENCES core.users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
UNIQUE (tenant_id, slug)
);
CREATE INDEX maps_tenant_idx ON topo.maps (tenant_id) WHERE deleted_at IS NULL;
CREATE TRIGGER trg_maps_touch BEFORE UPDATE ON topo.maps
FOR EACH ROW EXECUTE FUNCTION core.touch_updated_at();
-- ---------------------------------------------------------------------
-- Підкладки: план офісу / серверної (PNG/SVG), схема стійки, OSM
-- ---------------------------------------------------------------------
CREATE TYPE topo.background_kind AS ENUM ('image','svg','rack','osm','tile','none');
CREATE TABLE topo.map_backgrounds (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
map_id uuid NOT NULL REFERENCES topo.maps(id) ON DELETE CASCADE,
kind topo.background_kind NOT NULL DEFAULT 'image',
z_index int NOT NULL DEFAULT 0,
-- Для image/svg: об'єкт у S3/MinIO
storage_key text,
mime_type text,
file_size bigint,
natural_width int,
natural_height int,
-- Розміщення підкладки на полотні
x double precision NOT NULL DEFAULT 0,
y double precision NOT NULL DEFAULT 0,
width double precision,
height double precision,
rotation double precision NOT NULL DEFAULT 0,
opacity double precision NOT NULL DEFAULT 1 CHECK (opacity BETWEEN 0 AND 1),
locked boolean NOT NULL DEFAULT true,
-- Для kind='osm'/'tile': географічна прив'язка полотна
-- {"center":[50.45,30.52],"zoom":12,"bounds":[[..],[..]],"tile_url":"..."}
geo jsonb,
-- Для kind='rack': {"units":42,"orientation":"front"}
rack jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX map_backgrounds_map_idx ON topo.map_backgrounds (map_id, z_index);
-- ---------------------------------------------------------------------
-- Вузли мапи
-- ---------------------------------------------------------------------
CREATE TYPE topo.node_kind AS ENUM (
'device', -- прив'язаний до inv.devices
'group', -- згорнутий кластер / контейнер
'cloud', -- Internet / провайдер
'text', -- анотація
'shape', -- прямокутник/еліпс/лінія-роздільник
'image', -- іконка/логотип
'link_map', -- перехід на іншу мапу (drill-down)
'metric' -- міні-віджет з графіком/значенням
);
CREATE TABLE topo.map_nodes (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
map_id uuid NOT NULL REFERENCES topo.maps(id) ON DELETE CASCADE,
kind topo.node_kind NOT NULL DEFAULT 'device',
-- Прив'язки (за kind)
device_id uuid REFERENCES inv.devices(id) ON DELETE CASCADE,
group_id uuid REFERENCES inv.device_groups(id) ON DELETE SET NULL,
target_map_id uuid REFERENCES topo.maps(id) ON DELETE SET NULL,
-- Вкладеність у контейнер/кластер на цій же мапі
parent_node_id uuid REFERENCES topo.map_nodes(id) ON DELETE CASCADE,
label text,
-- Геометрія полотна (React Flow / Cytoscape): координати у світових одиницях
x double precision NOT NULL DEFAULT 0,
y double precision NOT NULL DEFAULT 0,
width double precision,
height double precision,
rotation double precision NOT NULL DEFAULT 0,
z_index int NOT NULL DEFAULT 0,
-- Гео-координати для kind='geo' мап (мають пріоритет над x/y при рендері OSM)
lat double precision CHECK (lat BETWEEN -90 AND 90),
lon double precision CHECK (lon BETWEEN -180 AND 180),
-- Позиція в стійці для kind='rack'
rack_unit int,
-- Вигляд: {"icon":"switch","color":"#22c55e","shape":"rounded","showMetrics":["cpu"]}
style jsonb NOT NULL DEFAULT '{}'::jsonb,
-- Довільні дані ноди (текст анотації, конфіг міні-віджета)
data jsonb NOT NULL DEFAULT '{}'::jsonb,
collapsed boolean NOT NULL DEFAULT false,
locked boolean NOT NULL DEFAULT false,
hidden boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
-- Цілісність: device-нода зобов'язана мати device_id, link_map — target_map_id
CONSTRAINT map_nodes_kind_ref_chk CHECK (
(kind = 'device' AND device_id IS NOT NULL) OR
(kind = 'link_map' AND target_map_id IS NOT NULL) OR
(kind NOT IN ('device','link_map'))
)
);
-- Один пристрій — одна нода на мапі
CREATE UNIQUE INDEX map_nodes_device_uniq ON topo.map_nodes (map_id, device_id)
WHERE device_id IS NOT NULL;
CREATE INDEX map_nodes_map_idx ON topo.map_nodes (map_id);
CREATE INDEX map_nodes_device_idx ON topo.map_nodes (device_id);
CREATE INDEX map_nodes_parent_idx ON topo.map_nodes (parent_node_id);
-- Просторовий індекс для viewport-culling при 1000+ вузлах
CREATE INDEX map_nodes_bbox_idx ON topo.map_nodes USING gist (point(x, y));
-- ---------------------------------------------------------------------
-- Ребра мапи (візуальне представлення лінка)
-- ---------------------------------------------------------------------
CREATE TYPE topo.edge_style AS ENUM ('straight','bezier','smoothstep','step','orthogonal','arc');
CREATE TYPE topo.edge_dash AS ENUM ('solid','dashed','dotted','dashdot');
CREATE TABLE topo.map_edges (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
map_id uuid NOT NULL REFERENCES topo.maps(id) ON DELETE CASCADE,
source_node_id uuid NOT NULL REFERENCES topo.map_nodes(id) ON DELETE CASCADE,
target_node_id uuid NOT NULL REFERENCES topo.map_nodes(id) ON DELETE CASCADE,
-- Конкретні порти: Switch1:Port1 -> Router1:eth0
source_interface_id uuid REFERENCES inv.interfaces(id) ON DELETE SET NULL,
target_interface_id uuid REFERENCES inv.interfaces(id) ON DELETE SET NULL,
-- Прив'язка до фізичного лінка (звідки беруться статус і трафік)
link_id uuid REFERENCES topo.links(id) ON DELETE SET NULL,
label text,
-- Точки прив'язки на ноді: top|right|bottom|left|auto або якір "port:<if_id>"
source_handle text,
target_handle text,
style topo.edge_style NOT NULL DEFAULT 'smoothstep',
dash topo.edge_dash NOT NULL DEFAULT 'solid',
color text,
width_px double precision NOT NULL DEFAULT 2,
-- Проміжні точки для ручного розведення ліній: [{"x":10,"y":20},...]
waypoints jsonb NOT NULL DEFAULT '[]'::jsonb,
-- Анімація трафіку. speed_source: 'utilization' | 'fixed' | 'off'
-- {"enabled":true,"speed_source":"utilization","direction":"a_to_b","max_speed":3}
animation jsonb NOT NULL DEFAULT '{"enabled":true,"speed_source":"utilization"}'::jsonb,
-- Пороги фарбування: {"warn_pct":70,"crit_pct":90,"loss_warn":1,"loss_crit":5}
thresholds jsonb NOT NULL DEFAULT '{}'::jsonb,
-- Показувати підпис зі швидкістю/втратами на лінії
show_metrics boolean NOT NULL DEFAULT true,
z_index int NOT NULL DEFAULT 0,
locked boolean NOT NULL DEFAULT false,
hidden boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CHECK (source_node_id <> target_node_id)
);
CREATE INDEX map_edges_map_idx ON topo.map_edges (map_id);
CREATE INDEX map_edges_source_idx ON topo.map_edges (source_node_id);
CREATE INDEX map_edges_target_idx ON topo.map_edges (target_node_id);
CREATE INDEX map_edges_link_idx ON topo.map_edges (link_id);
-- Одне ребро на пару портів у межах мапи
CREATE UNIQUE INDEX map_edges_ports_uniq ON topo.map_edges (
map_id, source_node_id, target_node_id,
COALESCE(source_interface_id,'00000000-0000-0000-0000-000000000000'::uuid),
COALESCE(target_interface_id,'00000000-0000-0000-0000-000000000000'::uuid)
);
-- ---------------------------------------------------------------------
-- Версії мап (undo/redo, відкат після невдалого автолейауту)
-- ---------------------------------------------------------------------
CREATE TABLE topo.map_revisions (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
map_id uuid NOT NULL REFERENCES topo.maps(id) ON DELETE CASCADE,
revision bigint NOT NULL,
author_id uuid REFERENCES core.users(id) ON DELETE SET NULL,
comment text,
-- Повний знімок {nodes:[...], edges:[...], backgrounds:[...]} (стиснутий на боці API)
snapshot jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (map_id, revision)
);
CREATE INDEX map_revisions_map_idx ON topo.map_revisions (map_id, revision DESC);
-- Доступ до мапи окремим користувачам/ролям (понад загальний RBAC)
CREATE TABLE topo.map_shares (
map_id uuid NOT NULL REFERENCES topo.maps(id) ON DELETE CASCADE,
user_id uuid REFERENCES core.users(id) ON DELETE CASCADE,
role_id uuid REFERENCES core.roles(id) ON DELETE CASCADE,
can_edit boolean NOT NULL DEFAULT false,
CHECK (user_id IS NOT NULL OR role_id IS NOT NULL)
);
CREATE UNIQUE INDEX map_shares_uniq ON topo.map_shares (
map_id,
COALESCE(user_id,'00000000-0000-0000-0000-000000000000'::uuid),
COALESCE(role_id,'00000000-0000-0000-0000-000000000000'::uuid)
);

View file

@ -0,0 +1,392 @@
-- =====================================================================
-- NetPulse :: 0005_telemetry_timescale.sql
-- Time-series: узагальнені метрики (series/samples), гарячі шляхи
-- (ICMP, лічильники інтерфейсів, статус лінків), CAGG, стиснення, retention.
--
-- УВАГА: цей файл НЕ можна виконувати в одній транзакції —
-- CREATE MATERIALIZED VIEW ... WITH (timescaledb.continuous) заборонено
-- всередині транзакційного блоку. migrate.ps1 визначає це автоматично.
-- =====================================================================
-- ---------------------------------------------------------------------
-- A. Узагальнена модель метрик (Prometheus-подібна): series + samples.
-- Будь-який плагін реєструє власний metric_key без зміни DDL.
-- ---------------------------------------------------------------------
CREATE TABLE ts.series (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
device_id uuid REFERENCES inv.devices(id) ON DELETE CASCADE,
interface_id uuid REFERENCES inv.interfaces(id) ON DELETE CASCADE,
plugin_key core.slug,
metric_key text NOT NULL, -- cpu.util, mem.used, ups.battery.pct, sensor.temp
unit text, -- pct, bps, C, V, ms
-- Додаткові виміри: {"core":"0","oid":"...","phase":"L1"}
labels jsonb NOT NULL DEFAULT '{}'::jsonb,
labels_hash uuid GENERATED ALWAYS AS (md5(labels::text)::uuid) STORED,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, device_id, metric_key, labels_hash)
);
CREATE INDEX series_device_metric_idx ON ts.series (device_id, metric_key);
CREATE INDEX series_iface_idx ON ts.series (interface_id) WHERE interface_id IS NOT NULL;
CREATE TABLE ts.samples (
ts timestamptz NOT NULL,
series_id bigint NOT NULL REFERENCES ts.series(id) ON DELETE CASCADE,
value double precision NOT NULL,
PRIMARY KEY (ts, series_id)
);
SELECT create_hypertable('ts.samples', 'ts',
chunk_time_interval => INTERVAL '1 day',
if_not_exists => TRUE);
CREATE INDEX samples_series_ts_idx ON ts.samples (series_id, ts DESC);
-- ---------------------------------------------------------------------
-- B. ICMP — гарячий шлях: колір ноди й лінії на мапі
-- ---------------------------------------------------------------------
CREATE TABLE ts.icmp_samples (
ts timestamptz NOT NULL,
device_id uuid NOT NULL,
tenant_id uuid NOT NULL,
agent_id uuid,
rtt_avg_ms real,
rtt_min_ms real,
rtt_max_ms real,
jitter_ms real,
loss_pct real NOT NULL DEFAULT 0,
packets_sent smallint NOT NULL DEFAULT 0,
packets_recv smallint NOT NULL DEFAULT 0,
reachable boolean NOT NULL,
PRIMARY KEY (ts, device_id)
);
SELECT create_hypertable('ts.icmp_samples', 'ts',
chunk_time_interval => INTERVAL '1 day', if_not_exists => TRUE);
CREATE INDEX icmp_device_ts_idx ON ts.icmp_samples (device_id, ts DESC);
CREATE INDEX icmp_tenant_ts_idx ON ts.icmp_samples (tenant_id, ts DESC);
-- ---------------------------------------------------------------------
-- C. Лічильники інтерфейсів — джерело швидкості анімації трафіку
-- ---------------------------------------------------------------------
CREATE TABLE ts.if_counters (
ts timestamptz NOT NULL,
interface_id uuid NOT NULL,
device_id uuid NOT NULL,
tenant_id uuid NOT NULL,
-- Сирі 64-бітні лічильники (для точного перерахунку після рестарту пристрою)
in_octets bigint,
out_octets bigint,
in_ucast_pkts bigint,
out_ucast_pkts bigint,
in_errors bigint,
out_errors bigint,
in_discards bigint,
out_discards bigint,
-- Похідні швидкості, пораховані агентом (уже з урахуванням wrap/reset)
in_bps double precision,
out_bps double precision,
in_pps double precision,
out_pps double precision,
-- % завантаження від speed_bps — саме це керує анімацією та кольором лінії
util_in_pct real,
util_out_pct real,
oper_up boolean,
PRIMARY KEY (ts, interface_id)
);
SELECT create_hypertable('ts.if_counters', 'ts',
chunk_time_interval => INTERVAL '1 day', if_not_exists => TRUE);
CREATE INDEX ifc_iface_ts_idx ON ts.if_counters (interface_id, ts DESC);
CREATE INDEX ifc_device_ts_idx ON ts.if_counters (device_id, ts DESC);
-- ---------------------------------------------------------------------
-- D. Статус лінків — те, що WebSocket транслює на мапу
-- ---------------------------------------------------------------------
CREATE TABLE ts.link_status (
ts timestamptz NOT NULL,
link_id uuid NOT NULL,
tenant_id uuid NOT NULL,
status inv.device_status NOT NULL,
latency_ms real,
loss_pct real,
util_pct real,
PRIMARY KEY (ts, link_id)
);
SELECT create_hypertable('ts.link_status', 'ts',
chunk_time_interval => INTERVAL '7 days', if_not_exists => TRUE);
CREATE INDEX link_status_link_ts_idx ON ts.link_status (link_id, ts DESC);
-- Історія станів пристроїв (SLA / uptime-звіти)
CREATE TABLE ts.device_status_history (
ts timestamptz NOT NULL,
device_id uuid NOT NULL,
tenant_id uuid NOT NULL,
status inv.device_status NOT NULL,
prev_status inv.device_status,
reason text,
PRIMARY KEY (ts, device_id)
);
SELECT create_hypertable('ts.device_status_history', 'ts',
chunk_time_interval => INTERVAL '30 days', if_not_exists => TRUE);
CREATE INDEX dsh_device_ts_idx ON ts.device_status_history (device_id, ts DESC);
-- ---------------------------------------------------------------------
-- E. Syslog / SNMP-трапи (тригер бекапу конфігу в NCM)
-- ---------------------------------------------------------------------
CREATE TABLE ts.syslog (
ts timestamptz NOT NULL,
id uuid NOT NULL DEFAULT core.new_id(),
tenant_id uuid NOT NULL,
device_id uuid,
source_ip inet,
facility smallint,
severity smallint,
hostname text,
tag text,
message text NOT NULL,
parsed jsonb,
PRIMARY KEY (ts, id)
);
SELECT create_hypertable('ts.syslog', 'ts',
chunk_time_interval => INTERVAL '1 day', if_not_exists => TRUE);
CREATE INDEX syslog_device_ts_idx ON ts.syslog (device_id, ts DESC);
CREATE INDEX syslog_msg_trgm_idx ON ts.syslog USING gin (message gin_trgm_ops);
CREATE TABLE ts.snmp_traps (
ts timestamptz NOT NULL,
id uuid NOT NULL DEFAULT core.new_id(),
tenant_id uuid NOT NULL,
device_id uuid,
source_ip inet,
trap_oid text,
varbinds jsonb NOT NULL DEFAULT '{}'::jsonb,
PRIMARY KEY (ts, id)
);
SELECT create_hypertable('ts.snmp_traps', 'ts',
chunk_time_interval => INTERVAL '7 days', if_not_exists => TRUE);
CREATE INDEX traps_device_ts_idx ON ts.snmp_traps (device_id, ts DESC);
-- Самометрики агентів (RAM < 30 МБ — контролюємо фактом, не обіцянкою)
CREATE TABLE ts.agent_health (
ts timestamptz NOT NULL,
agent_id uuid NOT NULL,
tenant_id uuid NOT NULL,
cpu_pct real,
rss_bytes bigint,
goroutines int,
queue_depth int,
checks_per_sec real,
errors_per_min real,
PRIMARY KEY (ts, agent_id)
);
SELECT create_hypertable('ts.agent_health', 'ts',
chunk_time_interval => INTERVAL '7 days', if_not_exists => TRUE);
-- =====================================================================
-- F. CONTINUOUS AGGREGATES
-- Графіки за 30 днів не читають сирі дані — лише роллапи.
-- =====================================================================
CREATE MATERIALIZED VIEW ts.icmp_5m
WITH (timescaledb.continuous) AS
SELECT time_bucket(INTERVAL '5 minutes', ts) AS bucket,
device_id,
tenant_id,
avg(rtt_avg_ms)::real AS rtt_avg_ms,
min(rtt_min_ms)::real AS rtt_min_ms,
max(rtt_max_ms)::real AS rtt_max_ms,
avg(jitter_ms)::real AS jitter_ms,
avg(loss_pct)::real AS loss_pct,
sum(packets_sent) AS packets_sent,
sum(packets_recv) AS packets_recv,
count(*) FILTER (WHERE NOT reachable) AS down_samples,
count(*) AS samples
FROM ts.icmp_samples
GROUP BY bucket, device_id, tenant_id
WITH NO DATA;
CREATE MATERIALIZED VIEW ts.icmp_1h
WITH (timescaledb.continuous) AS
SELECT time_bucket(INTERVAL '1 hour', bucket) AS bucket,
device_id,
tenant_id,
avg(rtt_avg_ms)::real AS rtt_avg_ms,
min(rtt_min_ms)::real AS rtt_min_ms,
max(rtt_max_ms)::real AS rtt_max_ms,
avg(loss_pct)::real AS loss_pct,
sum(down_samples) AS down_samples,
sum(samples) AS samples
FROM ts.icmp_5m
GROUP BY 1, 2, 3
WITH NO DATA;
CREATE MATERIALIZED VIEW ts.if_counters_5m
WITH (timescaledb.continuous) AS
SELECT time_bucket(INTERVAL '5 minutes', ts) AS bucket,
interface_id,
device_id,
tenant_id,
avg(in_bps) AS in_bps_avg,
max(in_bps) AS in_bps_max,
avg(out_bps) AS out_bps_avg,
max(out_bps) AS out_bps_max,
avg(util_in_pct)::real AS util_in_avg,
max(util_in_pct)::real AS util_in_max,
avg(util_out_pct)::real AS util_out_avg,
max(util_out_pct)::real AS util_out_max,
sum(in_errors) AS in_errors,
sum(out_errors) AS out_errors,
sum(in_discards) AS in_discards,
sum(out_discards) AS out_discards
FROM ts.if_counters
GROUP BY bucket, interface_id, device_id, tenant_id
WITH NO DATA;
CREATE MATERIALIZED VIEW ts.if_counters_1h
WITH (timescaledb.continuous) AS
SELECT time_bucket(INTERVAL '1 hour', bucket) AS bucket,
interface_id, device_id, tenant_id,
avg(in_bps_avg) AS in_bps_avg,
max(in_bps_max) AS in_bps_max,
avg(out_bps_avg) AS out_bps_avg,
max(out_bps_max) AS out_bps_max,
avg(util_in_avg)::real AS util_in_avg,
max(util_in_max)::real AS util_in_max,
avg(util_out_avg)::real AS util_out_avg,
max(util_out_max)::real AS util_out_max
FROM ts.if_counters_5m
GROUP BY 1, 2, 3, 4
WITH NO DATA;
CREATE MATERIALIZED VIEW ts.samples_5m
WITH (timescaledb.continuous) AS
SELECT time_bucket(INTERVAL '5 minutes', ts) AS bucket,
series_id,
avg(value) AS avg_value,
min(value) AS min_value,
max(value) AS max_value,
last(value, ts) AS last_value,
count(*) AS samples
FROM ts.samples
GROUP BY bucket, series_id
WITH NO DATA;
CREATE MATERIALIZED VIEW ts.samples_1h
WITH (timescaledb.continuous) AS
SELECT time_bucket(INTERVAL '1 hour', bucket) AS bucket,
series_id,
avg(avg_value) AS avg_value,
min(min_value) AS min_value,
max(max_value) AS max_value,
sum(samples) AS samples
FROM ts.samples_5m
GROUP BY 1, 2
WITH NO DATA;
-- Політики оновлення роллапів
SELECT add_continuous_aggregate_policy('ts.icmp_5m',
start_offset => INTERVAL '3 hours', end_offset => INTERVAL '5 minutes',
schedule_interval => INTERVAL '5 minutes', if_not_exists => TRUE);
SELECT add_continuous_aggregate_policy('ts.icmp_1h',
start_offset => INTERVAL '2 days', end_offset => INTERVAL '1 hour',
schedule_interval => INTERVAL '1 hour', if_not_exists => TRUE);
SELECT add_continuous_aggregate_policy('ts.if_counters_5m',
start_offset => INTERVAL '3 hours', end_offset => INTERVAL '5 minutes',
schedule_interval => INTERVAL '5 minutes', if_not_exists => TRUE);
SELECT add_continuous_aggregate_policy('ts.if_counters_1h',
start_offset => INTERVAL '2 days', end_offset => INTERVAL '1 hour',
schedule_interval => INTERVAL '1 hour', if_not_exists => TRUE);
SELECT add_continuous_aggregate_policy('ts.samples_5m',
start_offset => INTERVAL '3 hours', end_offset => INTERVAL '5 minutes',
schedule_interval => INTERVAL '5 minutes', if_not_exists => TRUE);
SELECT add_continuous_aggregate_policy('ts.samples_1h',
start_offset => INTERVAL '2 days', end_offset => INTERVAL '1 hour',
schedule_interval => INTERVAL '1 hour', if_not_exists => TRUE);
-- =====================================================================
-- G. СТИСНЕННЯ ТА RETENTION
-- =====================================================================
ALTER TABLE ts.samples SET (timescaledb.compress,
timescaledb.compress_segmentby = 'series_id',
timescaledb.compress_orderby = 'ts DESC');
ALTER TABLE ts.icmp_samples SET (timescaledb.compress,
timescaledb.compress_segmentby = 'device_id',
timescaledb.compress_orderby = 'ts DESC');
ALTER TABLE ts.if_counters SET (timescaledb.compress,
timescaledb.compress_segmentby = 'interface_id',
timescaledb.compress_orderby = 'ts DESC');
ALTER TABLE ts.link_status SET (timescaledb.compress,
timescaledb.compress_segmentby = 'link_id',
timescaledb.compress_orderby = 'ts DESC');
ALTER TABLE ts.syslog SET (timescaledb.compress,
timescaledb.compress_segmentby = 'tenant_id',
timescaledb.compress_orderby = 'ts DESC');
ALTER TABLE ts.snmp_traps SET (timescaledb.compress,
timescaledb.compress_segmentby = 'tenant_id',
timescaledb.compress_orderby = 'ts DESC');
ALTER TABLE ts.agent_health SET (timescaledb.compress,
timescaledb.compress_segmentby = 'agent_id',
timescaledb.compress_orderby = 'ts DESC');
ALTER TABLE core.audit_log SET (timescaledb.compress,
timescaledb.compress_segmentby = 'tenant_id',
timescaledb.compress_orderby = 'ts DESC');
SELECT add_compression_policy('ts.samples', INTERVAL '2 days', if_not_exists => TRUE);
SELECT add_compression_policy('ts.icmp_samples', INTERVAL '2 days', if_not_exists => TRUE);
SELECT add_compression_policy('ts.if_counters', INTERVAL '2 days', if_not_exists => TRUE);
SELECT add_compression_policy('ts.link_status', INTERVAL '7 days', if_not_exists => TRUE);
SELECT add_compression_policy('ts.syslog', INTERVAL '3 days', if_not_exists => TRUE);
SELECT add_compression_policy('ts.snmp_traps', INTERVAL '7 days', if_not_exists => TRUE);
SELECT add_compression_policy('ts.agent_health', INTERVAL '3 days', if_not_exists => TRUE);
SELECT add_compression_policy('core.audit_log', INTERVAL '30 days', if_not_exists => TRUE);
-- Retention: значення за замовчуванням для Pro. Для Enterprise
-- застосунок перевизначає політику через remove_retention_policy/add_retention_policy.
SELECT add_retention_policy('ts.samples', INTERVAL '35 days', if_not_exists => TRUE);
SELECT add_retention_policy('ts.icmp_samples', INTERVAL '35 days', if_not_exists => TRUE);
SELECT add_retention_policy('ts.if_counters', INTERVAL '35 days', if_not_exists => TRUE);
SELECT add_retention_policy('ts.syslog', INTERVAL '90 days', if_not_exists => TRUE);
SELECT add_retention_policy('ts.snmp_traps', INTERVAL '90 days', if_not_exists => TRUE);
SELECT add_retention_policy('ts.agent_health', INTERVAL '14 days', if_not_exists => TRUE);
SELECT add_retention_policy('ts.samples_5m', INTERVAL '400 days', if_not_exists => TRUE);
SELECT add_retention_policy('ts.if_counters_5m', INTERVAL '400 days', if_not_exists => TRUE);
SELECT add_retention_policy('ts.icmp_5m', INTERVAL '400 days', if_not_exists => TRUE);
-- 1h-роллапи не видаляємо: це база для SLA-звітів і білінгових графіків.
-- =====================================================================
-- H. ХЕЛПЕРИ ДЛЯ РЕНДЕРУ МАПИ
-- Один запит на весь стан полотна замість N+1.
-- =====================================================================
-- Останній ICMP-семпл кожного пристрою (SkipScan по (device_id, ts DESC))
CREATE OR REPLACE VIEW ts.device_last_icmp AS
SELECT DISTINCT ON (device_id)
device_id, ts, rtt_avg_ms, loss_pct, jitter_ms, reachable
FROM ts.icmp_samples
WHERE ts > now() - INTERVAL '15 minutes'
ORDER BY device_id, ts DESC;
-- Поточне завантаження лінка: беремо гіршу з двох сторін
CREATE OR REPLACE VIEW topo.link_live AS
SELECT l.id AS link_id,
l.tenant_id,
l.status,
l.capacity_bps,
GREATEST(COALESCE(a.util_out_pct, 0), COALESCE(b.util_out_pct, 0)) AS util_pct,
COALESCE(a.out_bps, 0) AS a_out_bps,
COALESCE(b.out_bps, 0) AS b_out_bps,
GREATEST(a.ts, b.ts) AS ts
FROM topo.links l
LEFT JOIN LATERAL (
SELECT * FROM ts.if_counters c
WHERE c.interface_id = l.a_interface_id AND c.ts > now() - INTERVAL '15 minutes'
ORDER BY c.ts DESC LIMIT 1
) a ON true
LEFT JOIN LATERAL (
SELECT * FROM ts.if_counters c
WHERE c.interface_id = l.b_interface_id AND c.ts > now() - INTERVAL '15 minutes'
ORDER BY c.ts DESC LIMIT 1
) b ON true;

205
db/migrations/0006_ncm.sql Normal file
View file

@ -0,0 +1,205 @@
-- =====================================================================
-- NetPulse :: 0006_ncm.sql
-- Network Config Management: репозиторії, бекапи конфігів, Git-версії,
-- diff, шаблони відповідності (compliance), відкат.
-- =====================================================================
-- Один Git-репозиторій на тенанта (bare repo під керуванням libgit2 на сервері).
-- Гілка на пристрій: refs/heads/device/<device_id>; файл: <site>/<device>.cfg
CREATE TABLE ncm.repos (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
name text NOT NULL,
storage_path text NOT NULL, -- /var/lib/netpulse/git/<tenant>.git
default_branch text NOT NULL DEFAULT 'main',
-- Опційне дзеркалювання на зовнішній Git (GitHub/GitLab/Gitea)
remote_url text,
remote_secret_id uuid REFERENCES core.secrets(id) ON DELETE SET NULL,
mirror_enabled boolean NOT NULL DEFAULT false,
size_bytes bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, name)
);
-- Профіль збору для конкретної моделі/вендора:
-- які команди виконати, як розпізнати prompt, що вирізати перед diff.
CREATE TABLE ncm.profiles (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid REFERENCES core.tenants(id) ON DELETE CASCADE, -- NULL = вбудований
key core.slug NOT NULL,
name text NOT NULL,
vendor text,
transport inv.credential_proto NOT NULL DEFAULT 'ssh',
-- ["terminal length 0","show running-config"]
commands jsonb NOT NULL DEFAULT '[]'::jsonb,
prompt_regex text,
enable_required boolean NOT NULL DEFAULT false,
-- Рядки, що змінюються щоразу (uptime, timestamps, хеші паролів) — не шумимо в diff
scrub_patterns jsonb NOT NULL DEFAULT '[]'::jsonb,
-- Маскування секретів перед збереженням у Git
redact_patterns jsonb NOT NULL DEFAULT '[]'::jsonb,
is_builtin boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX ncm_profiles_key_uniq
ON ncm.profiles (COALESCE(tenant_id,'00000000-0000-0000-0000-000000000000'::uuid), key);
-- Політика бекапу для пристрою (розклад + тригери)
CREATE TABLE ncm.device_policies (
device_id uuid PRIMARY KEY REFERENCES inv.devices(id) ON DELETE CASCADE,
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
profile_id uuid REFERENCES ncm.profiles(id) ON DELETE SET NULL,
credential_id uuid REFERENCES inv.credentials(id) ON DELETE SET NULL,
enabled boolean NOT NULL DEFAULT true,
cron text NOT NULL DEFAULT '0 3 * * *',
-- Бекап за Syslog-подією зміни конфігу (%SYS-5-CONFIG_I)
on_syslog boolean NOT NULL DEFAULT true,
syslog_match text,
on_trap boolean NOT NULL DEFAULT false,
retention_versions int NOT NULL DEFAULT 100,
last_backup_at timestamptz,
next_backup_at timestamptz,
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ncm_policies_schedule_idx ON ncm.device_policies (next_backup_at) WHERE enabled;
-- ---------------------------------------------------------------------
-- Задачі збору та їх результати
-- ---------------------------------------------------------------------
CREATE TYPE ncm.job_trigger AS ENUM ('schedule','manual','syslog','trap','api','discovery');
CREATE TYPE ncm.job_status AS ENUM ('queued','running','success','failed','unchanged','timeout');
CREATE TABLE ncm.jobs (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
device_id uuid NOT NULL REFERENCES inv.devices(id) ON DELETE CASCADE,
agent_id uuid REFERENCES core.agents(id) ON DELETE SET NULL,
trigger ncm.job_trigger NOT NULL,
status ncm.job_status NOT NULL DEFAULT 'queued',
requested_by uuid REFERENCES core.users(id) ON DELETE SET NULL,
started_at timestamptz,
finished_at timestamptz,
duration_ms int,
error text,
log text, -- транскрипт сесії (для діагностики)
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ncm_jobs_device_idx ON ncm.jobs (device_id, created_at DESC);
CREATE INDEX ncm_jobs_pending_idx ON ncm.jobs (status, created_at) WHERE status IN ('queued','running');
-- ---------------------------------------------------------------------
-- Версії конфігів. Тіло живе в Git; тут — метадані та індекс для пошуку.
-- ---------------------------------------------------------------------
CREATE TABLE ncm.configs (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
device_id uuid NOT NULL REFERENCES inv.devices(id) ON DELETE CASCADE,
repo_id uuid NOT NULL REFERENCES ncm.repos(id) ON DELETE CASCADE,
job_id uuid REFERENCES ncm.jobs(id) ON DELETE SET NULL,
-- Git-координати
commit_sha text NOT NULL, -- 40 hex
blob_sha text NOT NULL,
branch text NOT NULL,
path text NOT NULL, -- kyiv-dc1/core-sw-01/running.cfg
-- Тип зрізу: running | startup | vlan | license | inventory
config_type text NOT NULL DEFAULT 'running',
size_bytes int NOT NULL,
line_count int,
-- sha256 нормалізованого (після scrub) тексту — швидка перевірка "змінилось?"
content_hash bytea NOT NULL,
-- Зашифрований AES-GCM-256 кеш тіла для миттєвого diff без читання Git
body_secret_id uuid REFERENCES core.secrets(id) ON DELETE SET NULL,
-- Порівняння з попередньою версією
prev_config_id uuid REFERENCES ncm.configs(id) ON DELETE SET NULL,
lines_added int NOT NULL DEFAULT 0,
lines_removed int NOT NULL DEFAULT 0,
is_change boolean NOT NULL DEFAULT false,
collected_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (device_id, config_type, commit_sha)
);
CREATE INDEX ncm_configs_device_time_idx ON ncm.configs (device_id, config_type, collected_at DESC);
CREATE INDEX ncm_configs_changes_idx ON ncm.configs (tenant_id, collected_at DESC) WHERE is_change;
CREATE INDEX ncm_configs_hash_idx ON ncm.configs (device_id, content_hash);
-- Кешовані diff-и для UI (щоб не рахувати щоразу при відкритті Telegram Mini App)
CREATE TABLE ncm.diffs (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
from_config_id uuid NOT NULL REFERENCES ncm.configs(id) ON DELETE CASCADE,
to_config_id uuid NOT NULL REFERENCES ncm.configs(id) ON DELETE CASCADE,
format text NOT NULL DEFAULT 'unified', -- unified | side_by_side | json_hunks
-- [{"old_start":12,"old_lines":3,"new_start":12,"new_lines":4,"lines":[...]}]
hunks jsonb NOT NULL,
lines_added int NOT NULL DEFAULT 0,
lines_removed int NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (from_config_id, to_config_id, format)
);
-- ---------------------------------------------------------------------
-- Compliance: правила відповідності конфігів (Enterprise)
-- ---------------------------------------------------------------------
CREATE TYPE ncm.rule_kind AS ENUM ('must_contain','must_not_contain','regex_match','regex_absent','jsonpath');
CREATE TYPE ncm.rule_severity AS ENUM ('info','low','medium','high','critical');
CREATE TABLE ncm.compliance_rules (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
name text NOT NULL,
description text,
kind ncm.rule_kind NOT NULL,
pattern text NOT NULL,
severity ncm.rule_severity NOT NULL DEFAULT 'medium',
-- До яких пристроїв застосовувати (фільтр як у dynamic-групах)
selector jsonb NOT NULL DEFAULT '{}'::jsonb,
remediation text, -- підказка "як виправити"
enabled boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE ncm.compliance_results (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
rule_id uuid NOT NULL REFERENCES ncm.compliance_rules(id) ON DELETE CASCADE,
device_id uuid NOT NULL REFERENCES inv.devices(id) ON DELETE CASCADE,
config_id uuid REFERENCES ncm.configs(id) ON DELETE SET NULL,
passed boolean NOT NULL,
details jsonb NOT NULL DEFAULT '{}'::jsonb,
checked_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (rule_id, device_id)
);
CREATE INDEX ncm_compliance_failed_idx ON ncm.compliance_results (tenant_id, checked_at DESC)
WHERE NOT passed;
-- ---------------------------------------------------------------------
-- Відкат конфігурації (push назад на пристрій) — небезпечна операція,
-- тому окрема сутність із двоетапним підтвердженням.
-- ---------------------------------------------------------------------
CREATE TYPE ncm.rollback_status AS ENUM ('draft','awaiting_approval','approved','applying','applied','failed','rejected');
CREATE TABLE ncm.rollbacks (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
device_id uuid NOT NULL REFERENCES inv.devices(id) ON DELETE CASCADE,
target_config_id uuid NOT NULL REFERENCES ncm.configs(id) ON DELETE RESTRICT,
status ncm.rollback_status NOT NULL DEFAULT 'draft',
-- Команди, які реально підуть на пристрій (мінімальний набір змін)
commands jsonb NOT NULL DEFAULT '[]'::jsonb,
requested_by uuid REFERENCES core.users(id) ON DELETE SET NULL,
approved_by uuid REFERENCES core.users(id) ON DELETE SET NULL,
approved_at timestamptz,
applied_at timestamptz,
result_log text,
error text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ncm_rollbacks_device_idx ON ncm.rollbacks (device_id, created_at DESC);

View file

@ -0,0 +1,233 @@
-- =====================================================================
-- NetPulse :: 0007_alerting.sql
-- Правила тригерів, алерти, ескалації, канали доставки (Telegram/PWA Push),
-- вікна обслуговування, придушення шуму.
-- =====================================================================
CREATE TYPE alr.severity AS ENUM ('info','warning','average','high','disaster');
CREATE TYPE alr.alert_state AS ENUM ('firing','acknowledged','suppressed','resolved','expired');
-- ---------------------------------------------------------------------
-- Правила
-- ---------------------------------------------------------------------
CREATE TYPE alr.rule_source AS ENUM ('metric','icmp','interface','link','syslog','trap','ncm','agent','compliance');
CREATE TABLE alr.rules (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
name text NOT NULL,
description text,
source alr.rule_source NOT NULL,
severity alr.severity NOT NULL DEFAULT 'warning',
-- До чого застосовується: пристрої/групи/теги (той самий формат фільтра)
selector jsonb NOT NULL DEFAULT '{}'::jsonb,
-- Умова. Приклади:
-- metric: {"metric_key":"cpu.util","agg":"avg","window":"5m","op":">","value":85}
-- icmp: {"op":"loss_pct >","value":20,"for":"3m"}
-- interface: {"metric":"util_out_pct","op":">","value":90,"for":"10m"}
-- syslog: {"regex":"%LINK-3-UPDOWN.*down"}
condition jsonb NOT NULL,
-- Гістерезис: умова зняття алерту (якщо NULL — інверсія condition)
recovery jsonb,
-- Скільки триматися умові перед firing (антифлап)
for_seconds int NOT NULL DEFAULT 60,
-- Не піднімати алерт, якщо батьківський вузол на мапі вже down
depends_on_topology boolean NOT NULL DEFAULT true,
enabled boolean NOT NULL DEFAULT true,
created_by uuid REFERENCES core.users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, name)
);
CREATE INDEX alr_rules_tenant_idx ON alr.rules (tenant_id) WHERE enabled;
-- ---------------------------------------------------------------------
-- Алерти
-- ---------------------------------------------------------------------
CREATE TABLE alr.alerts (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
rule_id uuid REFERENCES alr.rules(id) ON DELETE SET NULL,
device_id uuid REFERENCES inv.devices(id) ON DELETE CASCADE,
interface_id uuid REFERENCES inv.interfaces(id) ON DELETE CASCADE,
link_id uuid REFERENCES topo.links(id) ON DELETE CASCADE,
severity alr.severity NOT NULL,
state alr.alert_state NOT NULL DEFAULT 'firing',
title text NOT NULL,
message text,
-- Ключ дедуплікації: rule_id + object → один активний алерт
dedup_key text NOT NULL,
value double precision,
threshold double precision,
context jsonb NOT NULL DEFAULT '{}'::jsonb,
-- Кореляція: кореневий алерт (маршрутизатор впав → 40 пристроїв за ним)
root_alert_id uuid REFERENCES alr.alerts(id) ON DELETE SET NULL,
suppressed_by text, -- 'maintenance' | 'topology' | 'dependency'
started_at timestamptz NOT NULL DEFAULT now(),
acked_at timestamptz,
acked_by uuid REFERENCES core.users(id) ON DELETE SET NULL,
ack_comment text,
resolved_at timestamptz,
last_seen_at timestamptz NOT NULL DEFAULT now(),
notify_count int NOT NULL DEFAULT 0
);
-- Один активний алерт на dedup_key
CREATE UNIQUE INDEX alerts_active_dedup_uniq ON alr.alerts (tenant_id, dedup_key)
WHERE state IN ('firing','acknowledged','suppressed');
CREATE INDEX alerts_tenant_state_idx ON alr.alerts (tenant_id, state, severity, started_at DESC);
CREATE INDEX alerts_device_idx ON alr.alerts (device_id, started_at DESC);
CREATE INDEX alerts_link_idx ON alr.alerts (link_id) WHERE state = 'firing';
CREATE INDEX alerts_root_idx ON alr.alerts (root_alert_id);
-- Архів (після resolve алерти переносяться сюди фоном; hypertable)
CREATE TABLE alr.alerts_history (
ts timestamptz NOT NULL,
id uuid NOT NULL,
tenant_id uuid NOT NULL,
rule_id uuid,
device_id uuid,
severity alr.severity NOT NULL,
title text NOT NULL,
started_at timestamptz NOT NULL,
resolved_at timestamptz,
duration_sec int,
acked_by uuid,
context jsonb NOT NULL DEFAULT '{}'::jsonb,
PRIMARY KEY (ts, id)
);
SELECT create_hypertable('alr.alerts_history', 'ts',
chunk_time_interval => INTERVAL '30 days', if_not_exists => TRUE);
CREATE INDEX alerts_hist_tenant_idx ON alr.alerts_history (tenant_id, ts DESC);
-- ---------------------------------------------------------------------
-- Канали доставки
-- ---------------------------------------------------------------------
CREATE TYPE alr.channel_kind AS ENUM (
'email','telegram','webhook','slack','discord','sms','web_push','pagerduty','mattermost','viber'
);
CREATE TABLE alr.channels (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
kind alr.channel_kind NOT NULL,
name text NOT NULL,
-- Несекретна частина: {"chat_id":-100123,"thread_id":5} / {"url":"https://..."}
config jsonb NOT NULL DEFAULT '{}'::jsonb,
secret_id uuid REFERENCES core.secrets(id) ON DELETE SET NULL,
-- Кастомний шаблон повідомлення (Go template / Handlebars)
template text,
min_severity alr.severity NOT NULL DEFAULT 'warning',
enabled boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, name)
);
-- Підписки PWA Web Push (на користувача, не на тенант)
CREATE TABLE alr.push_subscriptions (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES core.users(id) ON DELETE CASCADE,
endpoint text NOT NULL,
p256dh text NOT NULL,
auth text NOT NULL,
user_agent text,
last_success_at timestamptz,
failure_count int NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, endpoint)
);
-- ---------------------------------------------------------------------
-- Маршрутизація та ескалація
-- ---------------------------------------------------------------------
CREATE TABLE alr.escalation_policies (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
name text NOT NULL,
-- Кому і коли: [{"after_min":0,"channels":[...]},{"after_min":15,"channels":[...]}]
steps jsonb NOT NULL DEFAULT '[]'::jsonb,
repeat_after_min int,
max_repeats int NOT NULL DEFAULT 3,
UNIQUE (tenant_id, name)
);
CREATE TABLE alr.routes (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
name text NOT NULL,
priority int NOT NULL DEFAULT 100,
-- Фільтр: {"severity_gte":"high","tags":{"env":"prod"},"site_ids":[...]}
matcher jsonb NOT NULL DEFAULT '{}'::jsonb,
policy_id uuid REFERENCES alr.escalation_policies(id) ON DELETE SET NULL,
channel_ids uuid[] NOT NULL DEFAULT '{}',
-- Тихі години: {"tz":"Europe/Kyiv","quiet":[{"days":[6,0],"from":"22:00","to":"08:00"}]}
schedule jsonb,
enabled boolean NOT NULL DEFAULT true,
UNIQUE (tenant_id, name)
);
-- Журнал доставки
CREATE TYPE alr.delivery_status AS ENUM ('queued','sent','failed','throttled','skipped');
CREATE TABLE alr.notifications (
ts timestamptz NOT NULL DEFAULT now(),
id uuid NOT NULL DEFAULT core.new_id(),
tenant_id uuid NOT NULL,
alert_id uuid,
channel_id uuid,
user_id uuid,
status alr.delivery_status NOT NULL DEFAULT 'queued',
attempt smallint NOT NULL DEFAULT 1,
error text,
-- Для Telegram: message_id, щоб потім редагувати кнопки Ack/Mute
external_id text,
payload jsonb,
PRIMARY KEY (ts, id)
);
SELECT create_hypertable('alr.notifications', 'ts',
chunk_time_interval => INTERVAL '7 days', if_not_exists => TRUE);
CREATE INDEX notif_alert_idx ON alr.notifications (alert_id, ts DESC);
SELECT add_retention_policy('alr.notifications', INTERVAL '90 days', if_not_exists => TRUE);
-- ---------------------------------------------------------------------
-- Вікна обслуговування (алерти придушуються, SLA не псується)
-- ---------------------------------------------------------------------
CREATE TABLE alr.maintenance_windows (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
name text NOT NULL,
period tstzrange NOT NULL,
-- Повторюваність (RRULE, RFC 5545) для регулярних вікон
rrule text,
selector jsonb NOT NULL DEFAULT '{}'::jsonb,
suppress_notifications boolean NOT NULL DEFAULT true,
exclude_from_sla boolean NOT NULL DEFAULT true,
created_by uuid REFERENCES core.users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX mw_active_idx ON alr.maintenance_windows USING gist (tenant_id, period);
-- Ручне придушення конкретного об'єкта ("Mute" з Telegram Mini App)
CREATE TABLE alr.mutes (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
device_id uuid REFERENCES inv.devices(id) ON DELETE CASCADE,
link_id uuid REFERENCES topo.links(id) ON DELETE CASCADE,
rule_id uuid REFERENCES alr.rules(id) ON DELETE CASCADE,
until timestamptz NOT NULL,
reason text,
created_by uuid REFERENCES core.users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
CHECK (device_id IS NOT NULL OR link_id IS NOT NULL OR rule_id IS NOT NULL)
);
CREATE INDEX mutes_active_idx ON alr.mutes (tenant_id, until);

View file

@ -0,0 +1,102 @@
-- =====================================================================
-- NetPulse :: 0008_dashboards.sql
-- Дашборди (в т.ч. NOC/TV-режим), віджети, збережені фільтри, SLA-звіти.
-- =====================================================================
CREATE TYPE core.dashboard_kind AS ENUM ('standard','noc_tv','mobile','embed');
CREATE TABLE core.dashboards (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
name text NOT NULL,
slug core.slug NOT NULL,
kind core.dashboard_kind NOT NULL DEFAULT 'standard',
-- Сітка: {"cols":24,"rowHeight":40,"compact":"vertical"}
layout jsonb NOT NULL DEFAULT '{"cols":24,"rowHeight":40}'::jsonb,
-- TV-режим: автопрокрутка сторінок, темна тема, приховані меню
tv_options jsonb NOT NULL DEFAULT '{"rotate_sec":0,"hide_chrome":true,"theme":"dark"}'::jsonb,
refresh_sec int NOT NULL DEFAULT 30,
time_range jsonb NOT NULL DEFAULT '{"from":"now-6h","to":"now"}'::jsonb,
variables jsonb NOT NULL DEFAULT '[]'::jsonb, -- $site, $group для фільтрів
is_default boolean NOT NULL DEFAULT false,
public_token text UNIQUE, -- read-only лінк для телевізора в NOC
created_by uuid REFERENCES core.users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, slug)
);
CREATE TYPE core.widget_kind AS ENUM (
'map', -- повноекранна мапа топології
'timeseries','gauge','stat','table','heatmap','topn','pie',
'alert_list','device_grid','status_matrix','uptime_bar',
'syslog_stream','ncm_changes','text','iframe','weather_map'
);
CREATE TABLE core.dashboard_widgets (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
dashboard_id uuid NOT NULL REFERENCES core.dashboards(id) ON DELETE CASCADE,
kind core.widget_kind NOT NULL,
title text,
-- Позиція в сітці
grid_x int NOT NULL DEFAULT 0,
grid_y int NOT NULL DEFAULT 0,
grid_w int NOT NULL DEFAULT 6,
grid_h int NOT NULL DEFAULT 4,
-- Для kind='map'
map_id uuid REFERENCES topo.maps(id) ON DELETE SET NULL,
-- Джерела даних: [{"metric_key":"cpu.util","selector":{...},"agg":"avg"}]
queries jsonb NOT NULL DEFAULT '[]'::jsonb,
options jsonb NOT NULL DEFAULT '{}'::jsonb, -- осі, кольори, пороги, легенда
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX widgets_dashboard_idx ON core.dashboard_widgets (dashboard_id);
-- Збережені фільтри / представлення інвентарю
CREATE TABLE core.saved_views (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
user_id uuid REFERENCES core.users(id) ON DELETE CASCADE, -- NULL = спільний
scope text NOT NULL, -- devices | alerts | maps | ncm
name text NOT NULL,
filter jsonb NOT NULL DEFAULT '{}'::jsonb,
columns jsonb NOT NULL DEFAULT '[]'::jsonb,
sort jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
-- ---------------------------------------------------------------------
-- SLA / uptime-звіти
-- ---------------------------------------------------------------------
CREATE TABLE core.sla_targets (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
name text NOT NULL,
selector jsonb NOT NULL DEFAULT '{}'::jsonb,
target_pct numeric(5,3) NOT NULL DEFAULT 99.900,
-- 'window' — зарезервоване слово в PostgreSQL, тому period_kind
period_kind text NOT NULL DEFAULT 'monthly', -- daily | weekly | monthly | quarterly
-- Робочі години, поза якими простій не рахується
business_hours jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, name)
);
CREATE TABLE core.sla_periods (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
sla_target_id uuid NOT NULL REFERENCES core.sla_targets(id) ON DELETE CASCADE,
device_id uuid REFERENCES inv.devices(id) ON DELETE CASCADE,
period daterange NOT NULL,
uptime_pct numeric(6,3) NOT NULL,
downtime_sec bigint NOT NULL DEFAULT 0,
maintenance_sec bigint NOT NULL DEFAULT 0,
incidents int NOT NULL DEFAULT 0,
breached boolean NOT NULL DEFAULT false,
computed_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (sla_target_id, device_id, period)
);
CREATE INDEX sla_periods_tenant_idx ON core.sla_periods (tenant_id, period);

View file

@ -0,0 +1,290 @@
-- =====================================================================
-- NetPulse :: 0009_billing_licensing.sql
-- Тарифи, entitlements, підписки (Stripe/Paddle), per-device usage,
-- інвойси, ліцензійні ключі RSA-4096 для self-hosted.
-- =====================================================================
-- ---------------------------------------------------------------------
-- Тарифні плани та ліміти
-- ---------------------------------------------------------------------
CREATE TABLE bill.plans (
key core.slug PRIMARY KEY, -- free, pro, enterprise
name text NOT NULL,
description text,
-- Базова абонплата (може бути 0 при чистому per-device)
base_price_cents int NOT NULL DEFAULT 0,
-- Ціна за пристрій/місяць у центах: Pro 50..150
per_device_cents int NOT NULL DEFAULT 0,
currency char(3) NOT NULL DEFAULT 'USD',
billing_period text NOT NULL DEFAULT 'monthly', -- monthly | yearly
-- Обмеження. NULL = без обмежень.
max_devices int,
max_maps int,
max_map_nodes int,
max_agents int,
max_users int,
metric_retention_days int NOT NULL DEFAULT 35,
-- Доступні плагіни/можливості
features text[] NOT NULL DEFAULT '{}',
sort_order int NOT NULL DEFAULT 0,
is_public boolean NOT NULL DEFAULT true,
stripe_price_id text,
paddle_price_id text
);
-- Каталог фіч, на які посилаються plans.features та перевірки в API
CREATE TABLE bill.features (
key core.slug PRIMARY KEY, -- snmp, lldp_discovery, ncm_git, floor_plans,
name text NOT NULL, -- white_label, telegram, custom_roles, netflow
description text,
plugin_key core.slug REFERENCES core.plugins(key) ON DELETE SET NULL
);
-- ---------------------------------------------------------------------
-- Підписки
-- ---------------------------------------------------------------------
CREATE TYPE bill.provider AS ENUM ('stripe','paddle','manual','license_key');
CREATE TYPE bill.sub_status AS ENUM ('trialing','active','past_due','canceled','unpaid','paused','incomplete');
CREATE TABLE bill.subscriptions (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
plan_key core.slug NOT NULL REFERENCES bill.plans(key) ON DELETE RESTRICT,
provider bill.provider NOT NULL DEFAULT 'stripe',
status bill.sub_status NOT NULL DEFAULT 'trialing',
external_customer_id text, -- cus_...
external_subscription_id text, -- sub_...
external_item_id text, -- si_... (для usage-based reporting)
quantity int NOT NULL DEFAULT 0, -- поточна кількість оплачених пристроїв
currency char(3) NOT NULL DEFAULT 'USD',
current_period_start timestamptz,
current_period_end timestamptz,
trial_end timestamptz,
cancel_at_period_end boolean NOT NULL DEFAULT false,
canceled_at timestamptz,
-- Персональні перевизначення лімітів (Enterprise-домовленості)
overrides jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX subs_tenant_active_uniq ON bill.subscriptions (tenant_id)
WHERE status IN ('trialing','active','past_due','paused');
CREATE INDEX subs_external_idx ON bill.subscriptions (external_subscription_id);
-- Матеріалізовані права доступу тенанта — те, що читає API на кожному запиті.
-- Перераховується при зміні підписки/ліцензії. Кеш у Redis, істина тут.
CREATE TABLE bill.entitlements (
tenant_id uuid PRIMARY KEY REFERENCES core.tenants(id) ON DELETE CASCADE,
plan_key core.slug NOT NULL REFERENCES bill.plans(key) ON DELETE RESTRICT,
max_devices int,
max_maps int,
max_map_nodes int,
max_agents int,
max_users int,
metric_retention_days int NOT NULL DEFAULT 35,
features text[] NOT NULL DEFAULT '{}',
source bill.provider NOT NULL DEFAULT 'stripe',
valid_until timestamptz,
-- Grace-період після несплати: доступ read-only, опитування зупинено
grace_until timestamptz,
updated_at timestamptz NOT NULL DEFAULT now()
);
-- ---------------------------------------------------------------------
-- Облік використання (per-device billing)
-- ---------------------------------------------------------------------
-- Щоденний зріз: скільки пристроїв реально було увімкнено.
-- Тарифікація за піком або середнім — рішення застосунку.
CREATE TABLE bill.usage_daily (
day date NOT NULL,
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
devices_total int NOT NULL DEFAULT 0,
devices_billable int NOT NULL DEFAULT 0,
devices_peak int NOT NULL DEFAULT 0,
agents_active int NOT NULL DEFAULT 0,
map_nodes int NOT NULL DEFAULT 0,
maps_total int NOT NULL DEFAULT 0,
ncm_configs int NOT NULL DEFAULT 0,
samples_ingested bigint NOT NULL DEFAULT 0,
storage_bytes bigint NOT NULL DEFAULT 0,
computed_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (day, tenant_id)
);
CREATE INDEX usage_daily_tenant_idx ON bill.usage_daily (tenant_id, day DESC);
-- Позиції, відправлені провайдеру як usage records (ідемпотентність)
CREATE TABLE bill.usage_reports (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
subscription_id uuid NOT NULL REFERENCES bill.subscriptions(id) ON DELETE CASCADE,
period daterange NOT NULL,
quantity int NOT NULL,
idempotency_key text NOT NULL UNIQUE,
reported_at timestamptz,
external_id text,
error text,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (subscription_id, period)
);
-- ---------------------------------------------------------------------
-- Інвойси (B2B PDF)
-- ---------------------------------------------------------------------
CREATE TYPE bill.invoice_status AS ENUM ('draft','open','paid','void','uncollectible','refunded');
CREATE TABLE bill.invoices (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
subscription_id uuid REFERENCES bill.subscriptions(id) ON DELETE SET NULL,
number text NOT NULL, -- NP-2026-000123
status bill.invoice_status NOT NULL DEFAULT 'draft',
currency char(3) NOT NULL DEFAULT 'USD',
subtotal_cents int NOT NULL DEFAULT 0,
tax_cents int NOT NULL DEFAULT 0,
total_cents int NOT NULL DEFAULT 0,
amount_paid_cents int NOT NULL DEFAULT 0,
period daterange,
-- Реквізити покупця на момент виписки (юр. вимога — не FK, а знімок)
bill_to jsonb NOT NULL DEFAULT '{}'::jsonb,
vat_number text,
pdf_storage_key text,
external_id text, -- in_... / Paddle txn id
issued_at timestamptz,
due_at timestamptz,
paid_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, number)
);
CREATE INDEX invoices_tenant_idx ON bill.invoices (tenant_id, created_at DESC);
CREATE TABLE bill.invoice_lines (
id uuid PRIMARY KEY DEFAULT core.new_id(),
invoice_id uuid NOT NULL REFERENCES bill.invoices(id) ON DELETE CASCADE,
description text NOT NULL,
quantity numeric(12,3) NOT NULL DEFAULT 1,
unit_price_cents int NOT NULL DEFAULT 0,
amount_cents int NOT NULL DEFAULT 0,
period daterange,
meta jsonb NOT NULL DEFAULT '{}'::jsonb
);
-- Вебхуки платіжних провайдерів: спершу сирий запис, потім обробка.
CREATE TABLE bill.payment_events (
id uuid PRIMARY KEY DEFAULT core.new_id(),
provider bill.provider NOT NULL,
external_id text NOT NULL, -- evt_...
type text NOT NULL,
tenant_id uuid REFERENCES core.tenants(id) ON DELETE SET NULL,
payload jsonb NOT NULL,
signature_ok boolean NOT NULL DEFAULT false,
processed_at timestamptz,
error text,
received_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (provider, external_id)
);
CREATE INDEX payment_events_pending_idx ON bill.payment_events (received_at)
WHERE processed_at IS NULL;
-- ---------------------------------------------------------------------
-- Ліцензійні ключі для self-hosted (підпис RSA-4096)
-- ---------------------------------------------------------------------
CREATE TYPE bill.license_status AS ENUM ('issued','active','expired','revoked','suspended');
CREATE TABLE bill.license_keys (
id uuid PRIMARY KEY DEFAULT core.new_id(),
tenant_id uuid REFERENCES core.tenants(id) ON DELETE SET NULL,
plan_key core.slug NOT NULL REFERENCES bill.plans(key) ON DELETE RESTRICT,
-- Ключ, який бачить клієнт (NP-XXXX-...), у БД — лише хеш
key_hash bytea NOT NULL UNIQUE,
key_prefix text NOT NULL,
-- Підписаний payload: {install_id, plan, max_devices, max_map_nodes, features, exp}
payload jsonb NOT NULL,
signature bytea NOT NULL, -- RSA-4096 PSS SHA-256 над canonical(payload)
signing_key_id text NOT NULL, -- ротація ключів підпису
status bill.license_status NOT NULL DEFAULT 'issued',
-- Обмеження, продубльовані для швидких SQL-перевірок
max_devices int,
max_map_nodes int,
features text[] NOT NULL DEFAULT '{}',
-- Прив'язка до інсталяції (перший активований install_id фіксується)
bound_install_id uuid,
issued_to text,
issued_at timestamptz NOT NULL DEFAULT now(),
activated_at timestamptz,
expires_at timestamptz,
revoked_at timestamptz,
revoke_reason text
);
CREATE INDEX license_keys_tenant_idx ON bill.license_keys (tenant_id);
CREATE INDEX license_keys_install_idx ON bill.license_keys (bound_install_id);
-- Пінги self-hosted інсталяцій (online-перевірка ліцензії, опційна)
CREATE TABLE bill.license_checkins (
ts timestamptz NOT NULL DEFAULT now(),
license_id uuid NOT NULL,
install_id uuid NOT NULL,
version text,
device_count int,
map_node_count int,
ip inet,
PRIMARY KEY (ts, license_id)
);
SELECT create_hypertable('bill.license_checkins', 'ts',
chunk_time_interval => INTERVAL '30 days', if_not_exists => TRUE);
SELECT add_retention_policy('bill.license_checkins', INTERVAL '400 days', if_not_exists => TRUE);
-- ---------------------------------------------------------------------
-- Перевірка лімітів на рівні БД (друга лінія оборони після API)
-- ---------------------------------------------------------------------
CREATE OR REPLACE FUNCTION bill.assert_device_limit() RETURNS trigger
LANGUAGE plpgsql AS $$
DECLARE
lim int;
cnt int;
BEGIN
SELECT max_devices INTO lim FROM bill.entitlements WHERE tenant_id = NEW.tenant_id;
IF lim IS NULL THEN
RETURN NEW; -- без ліміту або entitlements ще не створені
END IF;
SELECT count(*) INTO cnt FROM inv.devices
WHERE tenant_id = NEW.tenant_id AND deleted_at IS NULL AND enabled;
IF cnt >= lim THEN
RAISE EXCEPTION 'device limit reached for tenant % (limit %)', NEW.tenant_id, lim
USING ERRCODE = 'check_violation', HINT = 'upgrade_plan';
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER trg_devices_limit BEFORE INSERT ON inv.devices
FOR EACH ROW EXECUTE FUNCTION bill.assert_device_limit();
CREATE OR REPLACE FUNCTION bill.assert_map_node_limit() RETURNS trigger
LANGUAGE plpgsql AS $$
DECLARE
lim int;
cnt int;
BEGIN
SELECT max_map_nodes INTO lim FROM bill.entitlements WHERE tenant_id = NEW.tenant_id;
IF lim IS NULL THEN
RETURN NEW;
END IF;
SELECT count(*) INTO cnt FROM topo.map_nodes WHERE map_id = NEW.map_id;
IF cnt >= lim THEN
RAISE EXCEPTION 'map node limit reached (limit %)', lim
USING ERRCODE = 'check_violation', HINT = 'upgrade_plan';
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER trg_map_nodes_limit BEFORE INSERT ON topo.map_nodes
FOR EACH ROW EXECUTE FUNCTION bill.assert_map_node_limit();

205
db/migrations/0010_seed.sql Normal file
View file

@ -0,0 +1,205 @@
-- =====================================================================
-- NetPulse :: 0010_seed.sql
-- Довідники: права, системні ролі, core-плагіни, типи чеків, тарифи, фічі.
-- Ідемпотентно (ON CONFLICT DO NOTHING/UPDATE).
-- ВАЖЛИВО: виконується ДО 0011_rls.sql — після вмикання FORCE RLS
-- навіть власник схеми не зможе вставити рядки з tenant_id IS NULL.
-- =====================================================================
-- ---------------------------------------------------------------------
-- Права доступу
-- ---------------------------------------------------------------------
INSERT INTO core.permissions (key, description) VALUES
('devices:read', 'Перегляд пристроїв та метрик'),
('devices:write', 'Створення/редагування/видалення пристроїв'),
('devices:control', 'Керування опитуванням, ручний запуск чеків'),
('maps:read', 'Перегляд мап топології'),
('maps:write', 'Редагування мап, вузлів та зв''язків'),
('maps:publish', 'Публічні посилання на мапи'),
('alerts:read', 'Перегляд алертів'),
('alerts:ack', 'Підтвердження та mute алертів'),
('alerts:write', 'Редагування правил, каналів, ескалацій'),
('ncm:read', 'Перегляд конфігів та diff'),
('ncm:write', 'Редагування політик бекапу'),
('ncm:rollback', 'Відкат конфігурації на пристрій'),
('agents:read', 'Перегляд зондів'),
('agents:write', 'Реєстрація та налаштування зондів'),
('users:read', 'Перегляд користувачів'),
('users:write', 'Запрошення та керування ролями'),
('billing:read', 'Перегляд тарифу та інвойсів'),
('billing:manage', 'Зміна тарифу, платіжні дані'),
('audit:read', 'Перегляд журналу аудиту'),
('settings:write', 'Налаштування тенанта, білий лейбл')
ON CONFLICT (key) DO NOTHING;
-- ---------------------------------------------------------------------
-- Системні ролі (tenant_id IS NULL)
-- ---------------------------------------------------------------------
INSERT INTO core.roles (id, tenant_id, key, name, description, is_system) VALUES
('00000000-0000-0000-0000-0000000000a1', NULL, 'owner', 'Власник', 'Повний доступ, включно з білінгом', true),
('00000000-0000-0000-0000-0000000000a2', NULL, 'admin', 'Адмін', 'Усе, крім білінгу', true),
('00000000-0000-0000-0000-0000000000a3', NULL, 'engineer', 'Інженер', 'Пристрої, мапи, алерти, NCM без rollback', true),
('00000000-0000-0000-0000-0000000000a4', NULL, 'operator', 'Оператор', 'Перегляд + ack алертів', true),
('00000000-0000-0000-0000-0000000000a5', NULL, 'viewer', 'Глядач', 'Тільки перегляд', true)
ON CONFLICT (id) DO NOTHING;
-- owner: усі права
INSERT INTO core.role_permissions (role_id, permission_key)
SELECT '00000000-0000-0000-0000-0000000000a1', key FROM core.permissions
ON CONFLICT DO NOTHING;
-- admin: усе, крім billing:manage
INSERT INTO core.role_permissions (role_id, permission_key)
SELECT '00000000-0000-0000-0000-0000000000a2', key FROM core.permissions
WHERE key <> 'billing:manage'
ON CONFLICT DO NOTHING;
INSERT INTO core.role_permissions (role_id, permission_key) VALUES
('00000000-0000-0000-0000-0000000000a3','devices:read'),
('00000000-0000-0000-0000-0000000000a3','devices:write'),
('00000000-0000-0000-0000-0000000000a3','devices:control'),
('00000000-0000-0000-0000-0000000000a3','maps:read'),
('00000000-0000-0000-0000-0000000000a3','maps:write'),
('00000000-0000-0000-0000-0000000000a3','alerts:read'),
('00000000-0000-0000-0000-0000000000a3','alerts:ack'),
('00000000-0000-0000-0000-0000000000a3','alerts:write'),
('00000000-0000-0000-0000-0000000000a3','ncm:read'),
('00000000-0000-0000-0000-0000000000a3','ncm:write'),
('00000000-0000-0000-0000-0000000000a3','agents:read'),
('00000000-0000-0000-0000-0000000000a4','devices:read'),
('00000000-0000-0000-0000-0000000000a4','maps:read'),
('00000000-0000-0000-0000-0000000000a4','alerts:read'),
('00000000-0000-0000-0000-0000000000a4','alerts:ack'),
('00000000-0000-0000-0000-0000000000a4','ncm:read'),
('00000000-0000-0000-0000-0000000000a5','devices:read'),
('00000000-0000-0000-0000-0000000000a5','maps:read'),
('00000000-0000-0000-0000-0000000000a5','alerts:read')
ON CONFLICT DO NOTHING;
-- ---------------------------------------------------------------------
-- Плагіни ядра
-- ---------------------------------------------------------------------
INSERT INTO core.plugins (key, name, version, scope, description, manifest, min_plan_key, is_core) VALUES
('icmp', 'ICMP / Ping', '1.0.0', 'agent', 'Доступність, RTT, jitter, втрати',
'{"metrics":["icmp.rtt","icmp.loss","icmp.jitter"],"checks":["icmp.ping"]}', 'free', true),
('snmp', 'SNMP v2c/v3', '1.0.0', 'agent', 'Опитування OID, інтерфейси, CPU/RAM/сенсори',
'{"metrics":["if.*","cpu.util","mem.used","sensor.*"],"checks":["snmp.get","snmp.walk","snmp.if"]}', 'pro', true),
('http', 'HTTP/HTTPS/SSL', '1.0.0', 'agent', 'Код відповіді, час, строк дії сертифіката',
'{"metrics":["http.status","http.latency","ssl.days_left"],"checks":["http.status","ssl.expiry"]}', 'free', true),
('topology', 'Topology Discovery', '1.0.0', 'both', 'LLDP/CDP/ARP/FDB автовиявлення зв''язків',
'{"protocols":["lldp","cdp","arp","fdb"],"checks":["topo.discover"]}', 'pro', true),
('ncm', 'Config Backup (NCM)', '1.0.0', 'both', 'SSH/Telnet бекап конфігів + Git diff',
'{"transports":["ssh","telnet","api"],"checks":["ncm.backup"]}', 'enterprise', true),
('modbus', 'Modbus-TCP', '1.0.0', 'agent', 'Інвертори, UPS, BMS',
'{"metrics":["modbus.*"],"checks":["modbus.read"]}', 'pro', false),
('netflow', 'NetFlow / sFlow', '0.9.0', 'server', 'Аналіз потоків трафіку',
'{"metrics":["flow.*"],"collectors":["netflow5","netflow9","ipfix","sflow"]}', 'enterprise', false),
('syslog', 'Syslog Collector', '1.0.0', 'server', 'Прийом та парсинг syslog, тригер NCM',
'{"ports":[514],"triggers":["ncm.backup"]}', 'pro', true)
ON CONFLICT (key) DO UPDATE SET version = EXCLUDED.version, manifest = EXCLUDED.manifest;
-- ---------------------------------------------------------------------
-- Типи чеків
-- ---------------------------------------------------------------------
INSERT INTO core.check_types (key, plugin_key, name, params_schema, metrics) VALUES
('icmp.ping', 'icmp', 'ICMP Ping',
'{"type":"object","properties":{"count":{"type":"integer","default":3},"packet_size":{"type":"integer","default":56}}}',
'["icmp.rtt_avg","icmp.rtt_min","icmp.rtt_max","icmp.loss_pct","icmp.jitter"]'),
('snmp.get', 'snmp', 'SNMP Get',
'{"type":"object","required":["oids"],"properties":{"oids":{"type":"array","items":{"type":"string"}}}}',
'[]'),
('snmp.walk', 'snmp', 'SNMP Walk',
'{"type":"object","required":["oid"],"properties":{"oid":{"type":"string"},"max_rows":{"type":"integer","default":500}}}',
'[]'),
('snmp.if', 'snmp', 'SNMP Interfaces',
'{"type":"object","properties":{"use_hc_counters":{"type":"boolean","default":true}}}',
'["if.in_bps","if.out_bps","if.util_in_pct","if.util_out_pct","if.errors"]'),
('http.status', 'http', 'HTTP Status',
'{"type":"object","required":["url"],"properties":{"url":{"type":"string"},"expect_status":{"type":"integer","default":200},"keyword":{"type":"string"}}}',
'["http.status","http.latency_ms"]'),
('ssl.expiry', 'http', 'SSL Certificate Expiry',
'{"type":"object","required":["host"],"properties":{"host":{"type":"string"},"port":{"type":"integer","default":443}}}',
'["ssl.days_left"]'),
('topo.discover','topology','Neighbor Discovery',
'{"type":"object","properties":{"protos":{"type":"array","default":["lldp","cdp","arp"]}}}',
'[]'),
('ncm.backup', 'ncm', 'Config Backup',
'{"type":"object","properties":{"config_type":{"type":"string","default":"running"}}}',
'[]'),
('modbus.read', 'modbus','Modbus Read Registers',
'{"type":"object","required":["registers"],"properties":{"unit_id":{"type":"integer","default":1},"registers":{"type":"array"}}}',
'[]')
ON CONFLICT (key) DO NOTHING;
-- ---------------------------------------------------------------------
-- Фічі та тарифи
-- ---------------------------------------------------------------------
INSERT INTO bill.features (key, name, description, plugin_key) VALUES
('icmp', 'ICMP-моніторинг', 'Ping, RTT, втрати', 'icmp'),
('snmp', 'SNMP v2c/v3', 'Опитування метрик пристроїв', 'snmp'),
('http_checks', 'HTTP/SSL-чеки', 'Веб-сервіси та сертифікати', 'http'),
('maps_basic', 'Базова мапа', 'Одна мапа, до 15 вузлів', NULL),
('maps_unlimited', 'Мапи без обмежень', 'Необмежена кількість мап і вузлів', NULL),
('auto_discovery', 'Автовиявлення LLDP/CDP', 'Автопобудова топології', 'topology'),
('floor_plans', 'Плани приміщень', 'Підкладки PNG/SVG/OSM, схеми стійок', NULL),
('traffic_animation','Анімація трафіку', 'Анімовані лінії за завантаженням каналу', NULL),
('telegram', 'Telegram-сповіщення', 'Бот + Mini App', NULL),
('web_push', 'PWA Push', 'Web Push-сповіщення', NULL),
('ncm_git', 'NCM + Git diff', 'Бекап конфігів і контроль версій', 'ncm'),
('ncm_rollback', 'Відкат конфігурації', 'Push конфігу назад на пристрій', 'ncm'),
('compliance', 'Compliance-правила', 'Перевірка конфігів на відповідність', 'ncm'),
('netflow', 'NetFlow/sFlow', 'Аналіз потоків', 'netflow'),
('white_label', 'White-label', 'Власний бренд і домен', NULL),
('custom_roles', 'Кастомні ролі', 'Власна матриця прав', NULL),
('api_access', 'Публічний API', 'REST/gRPC + вебхуки', NULL),
('sla_reports', 'SLA-звіти', 'Uptime-звіти та PDF-експорт', NULL),
('sso', 'SSO / SAML', 'Корпоративна автентифікація', NULL)
ON CONFLICT (key) DO NOTHING;
INSERT INTO bill.plans
(key, name, description, base_price_cents, per_device_cents, max_devices, max_maps,
max_map_nodes, max_agents, max_users, metric_retention_days, features, sort_order)
VALUES
('free', 'Free / Community', 'До 15 пристроїв, 1 мапа, базовий ICMP-моніторинг',
0, 0, 15, 1, 15, 1, 3, 7,
ARRAY['icmp','http_checks','maps_basic','web_push'], 10),
('pro', 'Pro', 'Per-device білінг: SNMP, мапи без обмежень, автовиявлення, Telegram',
0, 100, NULL, NULL, NULL, 10, 25, 90,
ARRAY['icmp','snmp','http_checks','maps_basic','maps_unlimited','auto_discovery',
'traffic_animation','telegram','web_push','api_access','sla_reports'], 20),
('enterprise', 'Enterprise / NCM Suite', 'Pro + конфіги з Git diff, плани приміщень, white-label',
0, 150, NULL, NULL, NULL, NULL, NULL, 400,
ARRAY['icmp','snmp','http_checks','maps_basic','maps_unlimited','auto_discovery',
'traffic_animation','telegram','web_push','api_access','sla_reports',
'floor_plans','ncm_git','ncm_rollback','compliance','netflow',
'white_label','custom_roles','sso'], 30)
ON CONFLICT (key) DO UPDATE SET
features = EXCLUDED.features,
max_devices = EXCLUDED.max_devices,
max_map_nodes = EXCLUDED.max_map_nodes,
metric_retention_days = EXCLUDED.metric_retention_days;
-- ---------------------------------------------------------------------
-- Вбудовані NCM-профілі (tenant_id IS NULL)
-- ---------------------------------------------------------------------
INSERT INTO ncm.profiles (key, name, vendor, transport, commands, prompt_regex, enable_required,
scrub_patterns, redact_patterns, is_builtin) VALUES
('cisco-ios', 'Cisco IOS', 'cisco', 'ssh',
'["terminal length 0","show running-config"]', '[>#]\s*$', true,
'["^Building configuration","^Current configuration : \\d+ bytes","^ntp clock-period"]',
'["(password|secret) \\d? ?\\S+","snmp-server community \\S+"]', true),
('mikrotik-routeros', 'MikroTik RouterOS', 'mikrotik', 'ssh',
'["/export terse"]', '\] > $', false,
'["^# \\w{3}/\\d{2}/\\d{4}"]', '["password=\\S+"]', true),
('juniper-junos', 'Juniper JunOS', 'juniper', 'ssh',
'["set cli screen-length 0","show configuration | display set"]', '[%>#]\s*$', false,
'["^## Last commit:"]', '["encrypted-password \\S+"]', true),
('huawei-vrp', 'Huawei VRP', 'huawei', 'ssh',
'["screen-length 0 temporary","display current-configuration"]', '[<\\[].*[>\\]]', false,
'[]', '["cipher \\S+"]', true),
('bdcom-olt', 'BDCOM OLT', 'bdcom', 'telnet',
'["terminal length 0","show running-config"]', '[>#]\s*$', true,
'[]', '["(password|secret) \\S+"]', true)
ON CONFLICT DO NOTHING;

112
db/migrations/0011_rls.sql Normal file
View file

@ -0,0 +1,112 @@
-- =====================================================================
-- NetPulse :: 0011_rls.sql
-- Ізоляція тенантів через Row Level Security.
-- API виставляє SET LOCAL app.tenant_id = '<uuid>' на кожній транзакції.
-- =====================================================================
-- Ролі: власник схеми (міграції) та робоча роль застосунку.
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'netpulse_app') THEN
CREATE ROLE netpulse_app NOLOGIN;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'netpulse_worker') THEN
-- Воркери (агрегація, білінг, retention) працюють поверх усіх тенантів
CREATE ROLE netpulse_worker NOLOGIN BYPASSRLS;
END IF;
END $$;
GRANT USAGE ON SCHEMA core, inv, topo, ts, ncm, alr, bill TO netpulse_app, netpulse_worker;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA core, inv, topo, ts, ncm, alr, bill
TO netpulse_app, netpulse_worker;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA core, inv, topo, ts, ncm, alr, bill
TO netpulse_app, netpulse_worker;
ALTER DEFAULT PRIVILEGES IN SCHEMA core, inv, topo, ts, ncm, alr, bill
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO netpulse_app, netpulse_worker;
-- ---------------------------------------------------------------------
-- Вмикаємо RLS на всіх звичайних таблицях, що мають колонку tenant_id.
--
-- ВИНЯТОК: усі hypertables. TimescaleDB (перевірено на 2.29.1) відмовляє:
-- "operation not supported on hypertables that have columnstore enabled"
-- Тобто RLS несумісний зі стисненням (columnstore/hypercore), а стиснення
-- увімкнене на всіх гарячих time-series таблицях. Виключаємо гіпертаблі
-- цілком — інакше набір захищених таблиць мовчки залежав би від того,
-- на які з них уже накотили compression policy.
--
-- Ізоляція для time-series забезпечується на рівні запитів:
-- * доступ лише через JOIN з inv.devices / inv.interfaces / ts.series,
-- які під RLS;
-- * обов'язковий предикат tenant_id у репозиторному шарі API.
-- ---------------------------------------------------------------------
DO $$
DECLARE
r record;
BEGIN
FOR r IN
SELECT c.oid::regclass AS tbl, n.nspname, c.relname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attname = 'tenant_id' AND a.attnum > 0
WHERE c.relkind = 'r'
AND n.nspname IN ('core','inv','topo','ts','ncm','alr','bill')
AND NOT EXISTS (
SELECT 1 FROM timescaledb_information.hypertables h
WHERE h.hypertable_schema = n.nspname
AND h.hypertable_name = c.relname
)
LOOP
EXECUTE format('ALTER TABLE %s ENABLE ROW LEVEL SECURITY', r.tbl);
EXECUTE format('ALTER TABLE %s FORCE ROW LEVEL SECURITY', r.tbl);
EXECUTE format($p$
CREATE POLICY tenant_isolation ON %s
USING (tenant_id = core.current_tenant())
WITH CHECK (tenant_id = core.current_tenant())
$p$, r.tbl);
END LOOP;
END $$;
-- core.tenants: тенант бачить лише сам себе
ALTER TABLE core.tenants ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_self ON core.tenants
USING (id = core.current_tenant());
-- core.users глобальні (один користувач може бути в кількох тенантах):
-- видимі лише ті, хто має membership у поточному тенанті.
ALTER TABLE core.users ENABLE ROW LEVEL SECURITY;
CREATE POLICY users_in_tenant ON core.users
USING (EXISTS (
SELECT 1 FROM core.memberships m
WHERE m.user_id = core.users.id AND m.tenant_id = core.current_tenant()
));
-- Довідники без tenant_id читають усі
ALTER TABLE core.plugins ENABLE ROW LEVEL SECURITY;
ALTER TABLE core.check_types ENABLE ROW LEVEL SECURITY;
ALTER TABLE core.permissions ENABLE ROW LEVEL SECURITY;
ALTER TABLE bill.plans ENABLE ROW LEVEL SECURITY;
ALTER TABLE bill.features ENABLE ROW LEVEL SECURITY;
CREATE POLICY read_all ON core.plugins FOR SELECT USING (true);
CREATE POLICY read_all ON core.check_types FOR SELECT USING (true);
CREATE POLICY read_all ON core.permissions FOR SELECT USING (true);
CREATE POLICY read_all ON bill.plans FOR SELECT USING (true);
CREATE POLICY read_all ON bill.features FOR SELECT USING (true);
-- core.roles: системні (tenant_id IS NULL) + власні
DROP POLICY IF EXISTS tenant_isolation ON core.roles;
CREATE POLICY roles_visible ON core.roles
USING (tenant_id IS NULL OR tenant_id = core.current_tenant())
WITH CHECK (tenant_id = core.current_tenant());
-- ncm.profiles: вбудовані профілі спільні для всіх
DROP POLICY IF EXISTS tenant_isolation ON ncm.profiles;
CREATE POLICY profiles_visible ON ncm.profiles
USING (tenant_id IS NULL OR tenant_id = core.current_tenant())
WITH CHECK (tenant_id = core.current_tenant());
-- ---------------------------------------------------------------------
-- Захист від забутого SET app.tenant_id:
-- core.current_tenant() повертає NULL → жоден рядок не пройде політику.
-- Це навмисно: "порожній результат" безпечніший за "усі тенанти".
-- ---------------------------------------------------------------------

302
db/tests/smoke.sql Normal file
View file

@ -0,0 +1,302 @@
-- =====================================================================
-- NetPulse :: smoke.sql
-- Функціональна перевірка схеми на чистій БД після всіх міграцій.
-- Запуск: psql -d netpulse -v ON_ERROR_STOP=1 -f db/tests/smoke.sql
-- Виконувати від суперкористувача (частина перевірок робить SET ROLE).
-- =====================================================================
\set ON_ERROR_STOP on
\timing off
BEGIN;
-- ---------------------------------------------------------------------
-- 1. Два тенанти
-- ---------------------------------------------------------------------
INSERT INTO core.tenants (id, slug, name, status) VALUES
('11111111-1111-1111-1111-111111111111', 'acme', 'ACME ISP', 'active'),
('22222222-2222-2222-2222-222222222222', 'globex','Globex MSP', 'active');
INSERT INTO bill.entitlements (tenant_id, plan_key, max_devices, max_maps, max_map_nodes, features)
SELECT '11111111-1111-1111-1111-111111111111', key, max_devices, max_maps, max_map_nodes, features
FROM bill.plans WHERE key = 'free';
INSERT INTO bill.entitlements (tenant_id, plan_key, max_devices, max_maps, max_map_nodes, features)
SELECT '22222222-2222-2222-2222-222222222222', key, max_devices, max_maps, max_map_nodes, features
FROM bill.plans WHERE key = 'pro';
-- ---------------------------------------------------------------------
-- 2. Сайт, агент, пристрої, інтерфейси
-- ---------------------------------------------------------------------
INSERT INTO inv.sites (id, tenant_id, name, code, lat, lon) VALUES
('33333333-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111',
'Kyiv DC1', 'KYV-DC1', 50.4501, 30.5234);
INSERT INTO core.agents (id, tenant_id, site_id, name, token_hash, status, enabled_modules) VALUES
('44444444-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111',
'33333333-0000-0000-0000-000000000001', 'probe-kyv-01',
digest('agent-token-1','sha256'), 'online', '{icmp,snmp,topology}');
INSERT INTO inv.devices (id, tenant_id, site_id, agent_id, name, address, kind, vendor, chassis_id, system_name) VALUES
('55555555-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111',
'33333333-0000-0000-0000-000000000001', '44444444-0000-0000-0000-000000000001',
'core-sw-01', '10.0.0.1', 'switch', 'cisco', '0011.2233.4455', 'core-sw-01'),
('55555555-0000-0000-0000-000000000002', '11111111-1111-1111-1111-111111111111',
'33333333-0000-0000-0000-000000000001', '44444444-0000-0000-0000-000000000001',
'edge-rtr-01', '10.0.0.2', 'router', 'mikrotik', '0011.2233.6677', 'edge-rtr-01'),
('55555555-0000-0000-0000-000000000003', '11111111-1111-1111-1111-111111111111',
'33333333-0000-0000-0000-000000000001', '44444444-0000-0000-0000-000000000001',
'ups-01', '10.0.0.3', 'ups', 'apc', NULL, NULL);
INSERT INTO inv.interfaces (id, tenant_id, device_id, if_index, name, speed_bps, oper_status, is_uplink) VALUES
('66666666-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111',
'55555555-0000-0000-0000-000000000001', 1, 'GigabitEthernet0/1', 1000000000, 'up', true),
('66666666-0000-0000-0000-000000000002', '11111111-1111-1111-1111-111111111111',
'55555555-0000-0000-0000-000000000002', 1, 'ether1', 1000000000, 'up', true);
-- ---------------------------------------------------------------------
-- 3. Автовиявлення: LLDP-сусід → зведений лінк
-- ---------------------------------------------------------------------
INSERT INTO topo.neighbors (tenant_id, device_id, interface_id, proto,
remote_chassis_id, remote_system_name, remote_port_id,
resolved_device_id, resolved_interface_id, confidence)
VALUES ('11111111-1111-1111-1111-111111111111',
'55555555-0000-0000-0000-000000000001', '66666666-0000-0000-0000-000000000001', 'lldp',
'0011.2233.6677', 'edge-rtr-01', 'ether1',
'55555555-0000-0000-0000-000000000002', '66666666-0000-0000-0000-000000000002', 95);
INSERT INTO topo.links (id, tenant_id, a_device_id, a_interface_id, b_device_id, b_interface_id,
kind, capacity_bps, discovered_by, status)
VALUES ('77777777-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111',
'55555555-0000-0000-0000-000000000001', '66666666-0000-0000-0000-000000000001',
'55555555-0000-0000-0000-000000000002', '66666666-0000-0000-0000-000000000002',
'physical', 1000000000, 'lldp', 'up');
-- Перевірка нормалізації пари: дзеркальний лінк B→A має впертись в unique index
DO $$
BEGIN
INSERT INTO topo.links (tenant_id, a_device_id, a_interface_id, b_device_id, b_interface_id)
VALUES ('11111111-1111-1111-1111-111111111111',
'55555555-0000-0000-0000-000000000002', '66666666-0000-0000-0000-000000000002',
'55555555-0000-0000-0000-000000000001', '66666666-0000-0000-0000-000000000001');
RAISE EXCEPTION 'FAIL: дзеркальний лінк B->A створився (нормалізація пари не працює)';
EXCEPTION WHEN unique_violation THEN
RAISE NOTICE 'PASS: дзеркальний лінк B->A відхилено (links_pair_uniq)';
END $$;
-- ---------------------------------------------------------------------
-- 4. Мапа з підкладкою, вузлами та ребром port->port
-- ---------------------------------------------------------------------
INSERT INTO topo.maps (id, tenant_id, site_id, name, slug, kind, layout_algo) VALUES
('88888888-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111',
'33333333-0000-0000-0000-000000000001', 'Kyiv DC1 — основна', 'kyiv-dc1', 'floor_plan', 'manual');
INSERT INTO topo.map_backgrounds (tenant_id, map_id, kind, storage_key, mime_type,
natural_width, natural_height, width, height, opacity)
VALUES ('11111111-1111-1111-1111-111111111111', '88888888-0000-0000-0000-000000000001',
'image', 's3://netpulse/acme/floorplan-dc1.svg', 'image/svg+xml',
2400, 1600, 2400, 1600, 0.6);
INSERT INTO topo.map_nodes (id, tenant_id, map_id, kind, device_id, label, x, y, style) VALUES
('99999999-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111',
'88888888-0000-0000-0000-000000000001', 'device', '55555555-0000-0000-0000-000000000001',
'core-sw-01', 400, 300, '{"icon":"switch","color":"#22c55e"}'),
('99999999-0000-0000-0000-000000000002', '11111111-1111-1111-1111-111111111111',
'88888888-0000-0000-0000-000000000001', 'device', '55555555-0000-0000-0000-000000000002',
'edge-rtr-01', 800, 300, '{"icon":"router"}'),
('99999999-0000-0000-0000-000000000003', '11111111-1111-1111-1111-111111111111',
'88888888-0000-0000-0000-000000000001', 'text', NULL,
'Серверна №2', 400, 120, '{"fontSize":18}');
-- Нода kind='device' без device_id має впертись у CHECK
DO $$
BEGIN
INSERT INTO topo.map_nodes (tenant_id, map_id, kind, x, y)
VALUES ('11111111-1111-1111-1111-111111111111','88888888-0000-0000-0000-000000000001','device',0,0);
RAISE EXCEPTION 'FAIL: device-нода без device_id створилась';
EXCEPTION WHEN check_violation THEN
RAISE NOTICE 'PASS: device-нода без device_id відхилена (map_nodes_kind_ref_chk)';
END $$;
INSERT INTO topo.map_edges (tenant_id, map_id, source_node_id, target_node_id,
source_interface_id, target_interface_id, link_id,
label, style, animation, thresholds)
VALUES ('11111111-1111-1111-1111-111111111111', '88888888-0000-0000-0000-000000000001',
'99999999-0000-0000-0000-000000000001', '99999999-0000-0000-0000-000000000002',
'66666666-0000-0000-0000-000000000001', '66666666-0000-0000-0000-000000000002',
'77777777-0000-0000-0000-000000000001',
'Gi0/1 → ether1', 'smoothstep',
'{"enabled":true,"speed_source":"utilization","direction":"a_to_b","max_speed":3}',
'{"warn_pct":70,"crit_pct":90}');
-- ---------------------------------------------------------------------
-- 5. Телеметрія
-- ---------------------------------------------------------------------
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)
SELECT now() - (g || ' minutes')::interval,
'55555555-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111',
'44444444-0000-0000-0000-000000000001',
1.2 + g * 0.05, 1.0, 2.5, 0.3, CASE WHEN g = 0 THEN 0 ELSE 0 END, 3, 3, true
FROM generate_series(0, 59) g;
INSERT INTO ts.if_counters (ts, interface_id, device_id, tenant_id,
in_octets, out_octets, in_bps, out_bps,
util_in_pct, util_out_pct, oper_up)
SELECT now() - (g || ' minutes')::interval,
'66666666-0000-0000-0000-000000000001', '55555555-0000-0000-0000-000000000001',
'11111111-1111-1111-1111-111111111111',
1000000 * g, 900000 * g,
420000000, 780000000,
42.0, 78.0, true
FROM generate_series(0, 59) g;
-- Узагальнені метрики через series/samples
INSERT INTO ts.series (tenant_id, device_id, plugin_key, metric_key, unit, labels)
VALUES ('11111111-1111-1111-1111-111111111111','55555555-0000-0000-0000-000000000001',
'snmp','cpu.util','pct','{"core":"0"}');
INSERT INTO ts.samples (ts, series_id, value)
SELECT now() - (g || ' minutes')::interval, s.id, 30 + (g % 17)
FROM generate_series(0, 59) g,
ts.series s
WHERE s.metric_key = 'cpu.util';
-- ---------------------------------------------------------------------
-- 6. NCM: репо, конфіг, diff
-- ---------------------------------------------------------------------
INSERT INTO ncm.repos (id, tenant_id, name, storage_path) VALUES
('aaaaaaaa-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111',
'acme', '/var/lib/netpulse/git/acme.git');
INSERT INTO ncm.configs (id, tenant_id, device_id, repo_id, commit_sha, blob_sha, branch, path,
size_bytes, line_count, content_hash, is_change, lines_added, lines_removed)
VALUES
('bbbbbbbb-0000-0000-0000-000000000001','11111111-1111-1111-1111-111111111111',
'55555555-0000-0000-0000-000000000001','aaaaaaaa-0000-0000-0000-000000000001',
repeat('a',40), repeat('b',40), 'device/core-sw-01', 'kyv-dc1/core-sw-01/running.cfg',
4096, 180, digest('config-v1','sha256'), false, 0, 0),
('bbbbbbbb-0000-0000-0000-000000000002','11111111-1111-1111-1111-111111111111',
'55555555-0000-0000-0000-000000000001','aaaaaaaa-0000-0000-0000-000000000001',
repeat('c',40), repeat('d',40), 'device/core-sw-01', 'kyv-dc1/core-sw-01/running.cfg',
4130, 183, digest('config-v2','sha256'), true, 4, 1);
UPDATE ncm.configs SET prev_config_id = 'bbbbbbbb-0000-0000-0000-000000000001'
WHERE id = 'bbbbbbbb-0000-0000-0000-000000000002';
-- ---------------------------------------------------------------------
-- 7. Алерт
-- ---------------------------------------------------------------------
INSERT INTO alr.rules (id, tenant_id, name, source, severity, condition, for_seconds) VALUES
('cccccccc-0000-0000-0000-000000000001','11111111-1111-1111-1111-111111111111',
'Uplink > 90%', 'interface', 'high',
'{"metric":"util_out_pct","op":">","value":90}', 600);
INSERT INTO alr.alerts (tenant_id, rule_id, device_id, interface_id, link_id,
severity, title, dedup_key, value, threshold)
VALUES ('11111111-1111-1111-1111-111111111111','cccccccc-0000-0000-0000-000000000001',
'55555555-0000-0000-0000-000000000001','66666666-0000-0000-0000-000000000001',
'77777777-0000-0000-0000-000000000001',
'high','Uplink Gi0/1 завантажений на 94%','rule:uplink90:if:6666...0001', 94.0, 90.0);
-- Дедуплікація: другий активний алерт із тим самим ключем не пройде
DO $$
BEGIN
INSERT INTO alr.alerts (tenant_id, rule_id, device_id, severity, title, dedup_key)
VALUES ('11111111-1111-1111-1111-111111111111','cccccccc-0000-0000-0000-000000000001',
'55555555-0000-0000-0000-000000000001','high','дубль','rule:uplink90:if:6666...0001');
RAISE EXCEPTION 'FAIL: дубль активного алерту створився';
EXCEPTION WHEN unique_violation THEN
RAISE NOTICE 'PASS: дубль активного алерту відхилено (alerts_active_dedup_uniq)';
END $$;
COMMIT;
-- =====================================================================
-- ПЕРЕВІРКИ
-- =====================================================================
\echo ''
\echo '--- 1. Стан полотна мапи одним запитом ---'
SELECT n.label,
n.kind,
n.x, n.y,
d.status AS device_status,
round(i.rtt_avg_ms::numeric,2) AS rtt_ms,
i.loss_pct
FROM topo.map_nodes n
LEFT JOIN inv.devices d ON d.id = n.device_id
LEFT JOIN ts.device_last_icmp i ON i.device_id = n.device_id
WHERE n.map_id = '88888888-0000-0000-0000-000000000001'
ORDER BY n.x, n.y;
\echo ''
\echo '--- 2. Ребро мапи з портами і живим завантаженням (джерело анімації) ---'
SELECT e.label,
si.name AS source_port,
ti.name AS target_port,
lv.status,
round(lv.util_pct::numeric,1) AS util_pct,
e.animation->>'speed_source' AS speed_source,
e.thresholds->>'crit_pct' AS crit_pct
FROM topo.map_edges e
LEFT JOIN inv.interfaces si ON si.id = e.source_interface_id
LEFT JOIN inv.interfaces ti ON ti.id = e.target_interface_id
LEFT JOIN topo.link_live lv ON lv.link_id = e.link_id
WHERE e.map_id = '88888888-0000-0000-0000-000000000001';
\echo ''
\echo '--- 3. Continuous aggregate: 5-хвилинні роллапи трафіку ---'
CALL refresh_continuous_aggregate('ts.if_counters_5m', now() - interval '2 hours', now());
SELECT bucket, round(in_bps_avg::numeric/1e6,1) AS in_mbps, round(util_out_max::numeric,1) AS util_out_max
FROM ts.if_counters_5m
WHERE interface_id = '66666666-0000-0000-0000-000000000001'
ORDER BY bucket DESC LIMIT 5;
\echo ''
\echo '--- 4. Ліміт пристроїв тарифу Free (15) ---'
DO $$
DECLARE i int;
BEGIN
FOR i IN 4..15 LOOP
INSERT INTO inv.devices (tenant_id, name, address, kind)
VALUES ('11111111-1111-1111-1111-111111111111', 'filler-' || i,
('10.0.1.' || i)::inet, 'other');
END LOOP;
BEGIN
INSERT INTO inv.devices (tenant_id, name, address, kind)
VALUES ('11111111-1111-1111-1111-111111111111', 'over-limit', '10.0.9.9', 'other');
RAISE EXCEPTION 'FAIL: 16-й пристрій створився попри ліміт 15';
EXCEPTION WHEN check_violation THEN
RAISE NOTICE 'PASS: 16-й пристрій відхилено тригером bill.assert_device_limit';
END;
END $$;
\echo ''
\echo '--- 5. RLS: ізоляція тенантів (від імені netpulse_app) ---'
SET ROLE netpulse_app;
SET app.tenant_id = '11111111-1111-1111-1111-111111111111';
SELECT 'acme бачить пристроїв' AS check, count(*) FROM inv.devices;
SELECT 'acme бачить мап' AS check, count(*) FROM topo.maps;
SET app.tenant_id = '22222222-2222-2222-2222-222222222222';
SELECT 'globex бачить пристроїв ACME' AS check, count(*) FROM inv.devices;
SELECT 'globex бачить мап ACME' AS check, count(*) FROM topo.maps;
RESET app.tenant_id;
SELECT 'без app.tenant_id бачить пристроїв' AS check, count(*) FROM inv.devices;
RESET ROLE;
\echo ''
\echo '--- 6. Зведення по об''єктах схеми ---'
SELECT 'таблиць' AS obj, count(*) FROM pg_tables
WHERE schemaname IN ('core','inv','topo','ts','ncm','alr','bill')
UNION ALL SELECT 'hypertables', count(*) FROM timescaledb_information.hypertables
UNION ALL SELECT 'continuous aggregates', count(*) FROM timescaledb_information.continuous_aggregates
UNION ALL SELECT 'фонових job-ів', count(*) FROM timescaledb_information.jobs WHERE job_id >= 1000
UNION ALL SELECT 'RLS-політик', count(*) FROM pg_policies
WHERE schemaname IN ('core','inv','topo','ts','ncm','alr','bill')
UNION ALL SELECT 'індексів', count(*) FROM pg_indexes
WHERE schemaname IN ('core','inv','topo','ts','ncm','alr','bill');

47
docker-compose.yml Normal file
View file

@ -0,0 +1,47 @@
version: "3.9"
# Локальний стенд для перевірки схеми: TimescaleDB + Redis-сумісний DragonflyDB.
# Міграції накочуються скриптом db/migrate.ps1 (або psql -f у порядку номерів).
services:
db:
image: timescale/timescaledb:2.17.2-pg16
container_name: netpulse-db
environment:
POSTGRES_USER: netpulse
POSTGRES_PASSWORD: netpulse
POSTGRES_DB: netpulse
TIMESCALEDB_TELEMETRY: "off"
command:
- postgres
- -c
- shared_preload_libraries=timescaledb
- -c
- max_connections=200
- -c
- shared_buffers=512MB
- -c
- timescaledb.max_background_workers=8
ports:
- "5432:5432"
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U netpulse -d netpulse"]
interval: 5s
timeout: 5s
retries: 20
cache:
image: docker.dragonflydb.io/dragonflydb/dragonfly:latest
container_name: netpulse-cache
ulimits:
memlock: -1
ports:
- "6379:6379"
volumes:
- cache-data:/data
volumes:
db-data:
cache-data:

15
gen/go/go.mod Normal file
View file

@ -0,0 +1,15 @@
module github.com/netpulse/netpulse/gen/go
go 1.25.0
require (
google.golang.org/grpc v1.83.0
google.golang.org/protobuf v1.36.12
)
require (
golang.org/x/net v0.55.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
)

38
gen/go/go.sum Normal file
View file

@ -0,0 +1,38 @@
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/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=
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/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
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=

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,380 @@
// =====================================================================
// NetPulse :: agent.proto
// Головний контракт агент↔сервер.
//
// ФУНДАМЕНТАЛЬНЕ ОБМЕЖЕННЯ: усі з'єднання ініціює агент.
// Сервер ніколи не стукає в мережу клієнта — там немає ані відкритих
// портів, ані прокидання NAT. Тому "команда з сервера" фізично є
// повідомленням у зустрічному напрямку вже відкритого агентом
// bidi-стріму Control.
// =====================================================================
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc v3.21.12
// source: netpulse/v1/agent.proto
package netpulsev1
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
EnrollmentService_Enroll_FullMethodName = "/netpulse.v1.EnrollmentService/Enroll"
)
// EnrollmentServiceClient is the client API for EnrollmentService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type EnrollmentServiceClient interface {
Enroll(ctx context.Context, in *EnrollRequest, opts ...grpc.CallOption) (*EnrollResponse, error)
}
type enrollmentServiceClient struct {
cc grpc.ClientConnInterface
}
func NewEnrollmentServiceClient(cc grpc.ClientConnInterface) EnrollmentServiceClient {
return &enrollmentServiceClient{cc}
}
func (c *enrollmentServiceClient) Enroll(ctx context.Context, in *EnrollRequest, opts ...grpc.CallOption) (*EnrollResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(EnrollResponse)
err := c.cc.Invoke(ctx, EnrollmentService_Enroll_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// EnrollmentServiceServer is the server API for EnrollmentService service.
// All implementations must embed UnimplementedEnrollmentServiceServer
// for forward compatibility.
type EnrollmentServiceServer interface {
Enroll(context.Context, *EnrollRequest) (*EnrollResponse, error)
mustEmbedUnimplementedEnrollmentServiceServer()
}
// UnimplementedEnrollmentServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedEnrollmentServiceServer struct{}
func (UnimplementedEnrollmentServiceServer) Enroll(context.Context, *EnrollRequest) (*EnrollResponse, error) {
return nil, status.Error(codes.Unimplemented, "method Enroll not implemented")
}
func (UnimplementedEnrollmentServiceServer) mustEmbedUnimplementedEnrollmentServiceServer() {}
func (UnimplementedEnrollmentServiceServer) testEmbeddedByValue() {}
// UnsafeEnrollmentServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to EnrollmentServiceServer will
// result in compilation errors.
type UnsafeEnrollmentServiceServer interface {
mustEmbedUnimplementedEnrollmentServiceServer()
}
func RegisterEnrollmentServiceServer(s grpc.ServiceRegistrar, srv EnrollmentServiceServer) {
// If the following call panics, it indicates UnimplementedEnrollmentServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&EnrollmentService_ServiceDesc, srv)
}
func _EnrollmentService_Enroll_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(EnrollRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(EnrollmentServiceServer).Enroll(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: EnrollmentService_Enroll_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(EnrollmentServiceServer).Enroll(ctx, req.(*EnrollRequest))
}
return interceptor(ctx, in, info, handler)
}
// EnrollmentService_ServiceDesc is the grpc.ServiceDesc for EnrollmentService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var EnrollmentService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "netpulse.v1.EnrollmentService",
HandlerType: (*EnrollmentServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "Enroll",
Handler: _EnrollmentService_Enroll_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "netpulse/v1/agent.proto",
}
const (
AgentService_Control_FullMethodName = "/netpulse.v1.AgentService/Control"
AgentService_StreamTelemetry_FullMethodName = "/netpulse.v1.AgentService/StreamTelemetry"
AgentService_StreamLogs_FullMethodName = "/netpulse.v1.AgentService/StreamLogs"
AgentService_ReportDiscovery_FullMethodName = "/netpulse.v1.AgentService/ReportDiscovery"
AgentService_UploadConfig_FullMethodName = "/netpulse.v1.AgentService/UploadConfig"
)
// AgentServiceClient is the client API for AgentService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type AgentServiceClient interface {
// Довгоживучий двонаправлений канал керування. Одна сесія = одне
// з'єднання. Розрив стріму = кінець сесії з усім її станом
// (таблиця серій, in-flight батчі).
Control(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ControlUp, ControlDown], error)
// Телеметрія окремим стрімом, щоб пачка на 10 000 семплів
// не блокувала heartbeat і не затримувала команду з сервера.
StreamTelemetry(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[TelemetryBatch, TelemetryAck], error)
// Syslog/трапи — теж окремо: сплеск логів під час аварії не має
// топити телеметрію, за якою ця аварія й видно.
StreamLogs(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[LogBatch, LogAck], error)
// Автовиявлення: рідко, великими звітами.
ReportDiscovery(ctx context.Context, in *DiscoveryReport, opts ...grpc.CallOption) (*DiscoveryAck, error)
// Вивантаження зібраного конфігу (чанками).
UploadConfig(ctx context.Context, opts ...grpc.CallOption) (grpc.ClientStreamingClient[ConfigUpload, ConfigReceipt], error)
}
type agentServiceClient struct {
cc grpc.ClientConnInterface
}
func NewAgentServiceClient(cc grpc.ClientConnInterface) AgentServiceClient {
return &agentServiceClient{cc}
}
func (c *agentServiceClient) Control(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ControlUp, ControlDown], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
stream, err := c.cc.NewStream(ctx, &AgentService_ServiceDesc.Streams[0], AgentService_Control_FullMethodName, cOpts...)
if err != nil {
return nil, err
}
x := &grpc.GenericClientStream[ControlUp, ControlDown]{ClientStream: stream}
return x, nil
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type AgentService_ControlClient = grpc.BidiStreamingClient[ControlUp, ControlDown]
func (c *agentServiceClient) StreamTelemetry(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[TelemetryBatch, TelemetryAck], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
stream, err := c.cc.NewStream(ctx, &AgentService_ServiceDesc.Streams[1], AgentService_StreamTelemetry_FullMethodName, cOpts...)
if err != nil {
return nil, err
}
x := &grpc.GenericClientStream[TelemetryBatch, TelemetryAck]{ClientStream: stream}
return x, nil
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type AgentService_StreamTelemetryClient = grpc.BidiStreamingClient[TelemetryBatch, TelemetryAck]
func (c *agentServiceClient) StreamLogs(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[LogBatch, LogAck], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
stream, err := c.cc.NewStream(ctx, &AgentService_ServiceDesc.Streams[2], AgentService_StreamLogs_FullMethodName, cOpts...)
if err != nil {
return nil, err
}
x := &grpc.GenericClientStream[LogBatch, LogAck]{ClientStream: stream}
return x, nil
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type AgentService_StreamLogsClient = grpc.BidiStreamingClient[LogBatch, LogAck]
func (c *agentServiceClient) ReportDiscovery(ctx context.Context, in *DiscoveryReport, opts ...grpc.CallOption) (*DiscoveryAck, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DiscoveryAck)
err := c.cc.Invoke(ctx, AgentService_ReportDiscovery_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *agentServiceClient) UploadConfig(ctx context.Context, opts ...grpc.CallOption) (grpc.ClientStreamingClient[ConfigUpload, ConfigReceipt], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
stream, err := c.cc.NewStream(ctx, &AgentService_ServiceDesc.Streams[3], AgentService_UploadConfig_FullMethodName, cOpts...)
if err != nil {
return nil, err
}
x := &grpc.GenericClientStream[ConfigUpload, ConfigReceipt]{ClientStream: stream}
return x, nil
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type AgentService_UploadConfigClient = grpc.ClientStreamingClient[ConfigUpload, ConfigReceipt]
// AgentServiceServer is the server API for AgentService service.
// All implementations must embed UnimplementedAgentServiceServer
// for forward compatibility.
type AgentServiceServer interface {
// Довгоживучий двонаправлений канал керування. Одна сесія = одне
// з'єднання. Розрив стріму = кінець сесії з усім її станом
// (таблиця серій, in-flight батчі).
Control(grpc.BidiStreamingServer[ControlUp, ControlDown]) error
// Телеметрія окремим стрімом, щоб пачка на 10 000 семплів
// не блокувала heartbeat і не затримувала команду з сервера.
StreamTelemetry(grpc.BidiStreamingServer[TelemetryBatch, TelemetryAck]) error
// Syslog/трапи — теж окремо: сплеск логів під час аварії не має
// топити телеметрію, за якою ця аварія й видно.
StreamLogs(grpc.BidiStreamingServer[LogBatch, LogAck]) error
// Автовиявлення: рідко, великими звітами.
ReportDiscovery(context.Context, *DiscoveryReport) (*DiscoveryAck, error)
// Вивантаження зібраного конфігу (чанками).
UploadConfig(grpc.ClientStreamingServer[ConfigUpload, ConfigReceipt]) error
mustEmbedUnimplementedAgentServiceServer()
}
// UnimplementedAgentServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedAgentServiceServer struct{}
func (UnimplementedAgentServiceServer) Control(grpc.BidiStreamingServer[ControlUp, ControlDown]) error {
return status.Error(codes.Unimplemented, "method Control not implemented")
}
func (UnimplementedAgentServiceServer) StreamTelemetry(grpc.BidiStreamingServer[TelemetryBatch, TelemetryAck]) error {
return status.Error(codes.Unimplemented, "method StreamTelemetry not implemented")
}
func (UnimplementedAgentServiceServer) StreamLogs(grpc.BidiStreamingServer[LogBatch, LogAck]) error {
return status.Error(codes.Unimplemented, "method StreamLogs not implemented")
}
func (UnimplementedAgentServiceServer) ReportDiscovery(context.Context, *DiscoveryReport) (*DiscoveryAck, error) {
return nil, status.Error(codes.Unimplemented, "method ReportDiscovery not implemented")
}
func (UnimplementedAgentServiceServer) UploadConfig(grpc.ClientStreamingServer[ConfigUpload, ConfigReceipt]) error {
return status.Error(codes.Unimplemented, "method UploadConfig not implemented")
}
func (UnimplementedAgentServiceServer) mustEmbedUnimplementedAgentServiceServer() {}
func (UnimplementedAgentServiceServer) testEmbeddedByValue() {}
// UnsafeAgentServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to AgentServiceServer will
// result in compilation errors.
type UnsafeAgentServiceServer interface {
mustEmbedUnimplementedAgentServiceServer()
}
func RegisterAgentServiceServer(s grpc.ServiceRegistrar, srv AgentServiceServer) {
// If the following call panics, it indicates UnimplementedAgentServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&AgentService_ServiceDesc, srv)
}
func _AgentService_Control_Handler(srv interface{}, stream grpc.ServerStream) error {
return srv.(AgentServiceServer).Control(&grpc.GenericServerStream[ControlUp, ControlDown]{ServerStream: stream})
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type AgentService_ControlServer = grpc.BidiStreamingServer[ControlUp, ControlDown]
func _AgentService_StreamTelemetry_Handler(srv interface{}, stream grpc.ServerStream) error {
return srv.(AgentServiceServer).StreamTelemetry(&grpc.GenericServerStream[TelemetryBatch, TelemetryAck]{ServerStream: stream})
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type AgentService_StreamTelemetryServer = grpc.BidiStreamingServer[TelemetryBatch, TelemetryAck]
func _AgentService_StreamLogs_Handler(srv interface{}, stream grpc.ServerStream) error {
return srv.(AgentServiceServer).StreamLogs(&grpc.GenericServerStream[LogBatch, LogAck]{ServerStream: stream})
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type AgentService_StreamLogsServer = grpc.BidiStreamingServer[LogBatch, LogAck]
func _AgentService_ReportDiscovery_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DiscoveryReport)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AgentServiceServer).ReportDiscovery(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AgentService_ReportDiscovery_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AgentServiceServer).ReportDiscovery(ctx, req.(*DiscoveryReport))
}
return interceptor(ctx, in, info, handler)
}
func _AgentService_UploadConfig_Handler(srv interface{}, stream grpc.ServerStream) error {
return srv.(AgentServiceServer).UploadConfig(&grpc.GenericServerStream[ConfigUpload, ConfigReceipt]{ServerStream: stream})
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type AgentService_UploadConfigServer = grpc.ClientStreamingServer[ConfigUpload, ConfigReceipt]
// AgentService_ServiceDesc is the grpc.ServiceDesc for AgentService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var AgentService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "netpulse.v1.AgentService",
HandlerType: (*AgentServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "ReportDiscovery",
Handler: _AgentService_ReportDiscovery_Handler,
},
},
Streams: []grpc.StreamDesc{
{
StreamName: "Control",
Handler: _AgentService_Control_Handler,
ServerStreams: true,
ClientStreams: true,
},
{
StreamName: "StreamTelemetry",
Handler: _AgentService_StreamTelemetry_Handler,
ServerStreams: true,
ClientStreams: true,
},
{
StreamName: "StreamLogs",
Handler: _AgentService_StreamLogs_Handler,
ServerStreams: true,
ClientStreams: true,
},
{
StreamName: "UploadConfig",
Handler: _AgentService_UploadConfig_Handler,
ClientStreams: true,
},
},
Metadata: "netpulse/v1/agent.proto",
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,800 @@
// =====================================================================
// NetPulse :: discovery.proto
// Автовиявлення сусідів (LLDP/CDP/ARP/FDB) та інвентаризація інтерфейсів.
//
// Агент НЕ вирішує, хто з ким з'єднаний. Він доповідає сире:
// "на порту X я бачу chassis-id Y, port-id Z". Резолвер на сервері
// зводить це в topo.links і виставляє confidence. Так автовиявлення
// лишається відтворюваним і не залежить від версії агента.
// =====================================================================
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.12
// protoc v3.21.12
// source: netpulse/v1/discovery.proto
package netpulsev1
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
timestamppb "google.golang.org/protobuf/types/known/timestamppb"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type NeighborRecord struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Пристрій, який доповідає (той, що ми опитали).
DeviceId string `protobuf:"bytes,1,opt,name=device_id,json=deviceId,proto3" json:"device_id,omitempty"`
// Локальний порт. Якщо агент ще не знає interface_id — заповнює
// local_if_index/local_port_name, і сервер резолвить сам.
LocalInterfaceId string `protobuf:"bytes,2,opt,name=local_interface_id,json=localInterfaceId,proto3" json:"local_interface_id,omitempty"`
LocalIfIndex int64 `protobuf:"varint,3,opt,name=local_if_index,json=localIfIndex,proto3" json:"local_if_index,omitempty"`
LocalPortName string `protobuf:"bytes,4,opt,name=local_port_name,json=localPortName,proto3" json:"local_port_name,omitempty"`
Proto DiscoveryProto `protobuf:"varint,5,opt,name=proto,proto3,enum=netpulse.v1.DiscoveryProto" json:"proto,omitempty"`
// Те, що видно з іншого боку. Порожні поля — норма: CDP не дає
// chassis_id у форматі LLDP, ARP не дає port_id взагалі.
RemoteChassisId string `protobuf:"bytes,6,opt,name=remote_chassis_id,json=remoteChassisId,proto3" json:"remote_chassis_id,omitempty"`
RemoteSystemName string `protobuf:"bytes,7,opt,name=remote_system_name,json=remoteSystemName,proto3" json:"remote_system_name,omitempty"`
RemotePortId string `protobuf:"bytes,8,opt,name=remote_port_id,json=remotePortId,proto3" json:"remote_port_id,omitempty"`
RemotePortDescr string `protobuf:"bytes,9,opt,name=remote_port_descr,json=remotePortDescr,proto3" json:"remote_port_descr,omitempty"`
RemoteMgmtIp string `protobuf:"bytes,10,opt,name=remote_mgmt_ip,json=remoteMgmtIp,proto3" json:"remote_mgmt_ip,omitempty"`
RemoteMac string `protobuf:"bytes,11,opt,name=remote_mac,json=remoteMac,proto3" json:"remote_mac,omitempty"`
RemotePlatform string `protobuf:"bytes,12,opt,name=remote_platform,json=remotePlatform,proto3" json:"remote_platform,omitempty"`
RemoteCapabilities []string `protobuf:"bytes,13,rep,name=remote_capabilities,json=remoteCapabilities,proto3" json:"remote_capabilities,omitempty"` // bridge, router, wlan-ap
SeenAt *timestamppb.Timestamp `protobuf:"bytes,14,opt,name=seen_at,json=seenAt,proto3" json:"seen_at,omitempty"`
// Сирий запис як його віддав пристрій — для розбору спірних випадків.
RawJson []byte `protobuf:"bytes,15,opt,name=raw_json,json=rawJson,proto3" json:"raw_json,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *NeighborRecord) Reset() {
*x = NeighborRecord{}
mi := &file_netpulse_v1_discovery_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *NeighborRecord) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*NeighborRecord) ProtoMessage() {}
func (x *NeighborRecord) ProtoReflect() protoreflect.Message {
mi := &file_netpulse_v1_discovery_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use NeighborRecord.ProtoReflect.Descriptor instead.
func (*NeighborRecord) Descriptor() ([]byte, []int) {
return file_netpulse_v1_discovery_proto_rawDescGZIP(), []int{0}
}
func (x *NeighborRecord) GetDeviceId() string {
if x != nil {
return x.DeviceId
}
return ""
}
func (x *NeighborRecord) GetLocalInterfaceId() string {
if x != nil {
return x.LocalInterfaceId
}
return ""
}
func (x *NeighborRecord) GetLocalIfIndex() int64 {
if x != nil {
return x.LocalIfIndex
}
return 0
}
func (x *NeighborRecord) GetLocalPortName() string {
if x != nil {
return x.LocalPortName
}
return ""
}
func (x *NeighborRecord) GetProto() DiscoveryProto {
if x != nil {
return x.Proto
}
return DiscoveryProto_DISCOVERY_PROTO_UNSPECIFIED
}
func (x *NeighborRecord) GetRemoteChassisId() string {
if x != nil {
return x.RemoteChassisId
}
return ""
}
func (x *NeighborRecord) GetRemoteSystemName() string {
if x != nil {
return x.RemoteSystemName
}
return ""
}
func (x *NeighborRecord) GetRemotePortId() string {
if x != nil {
return x.RemotePortId
}
return ""
}
func (x *NeighborRecord) GetRemotePortDescr() string {
if x != nil {
return x.RemotePortDescr
}
return ""
}
func (x *NeighborRecord) GetRemoteMgmtIp() string {
if x != nil {
return x.RemoteMgmtIp
}
return ""
}
func (x *NeighborRecord) GetRemoteMac() string {
if x != nil {
return x.RemoteMac
}
return ""
}
func (x *NeighborRecord) GetRemotePlatform() string {
if x != nil {
return x.RemotePlatform
}
return ""
}
func (x *NeighborRecord) GetRemoteCapabilities() []string {
if x != nil {
return x.RemoteCapabilities
}
return nil
}
func (x *NeighborRecord) GetSeenAt() *timestamppb.Timestamp {
if x != nil {
return x.SeenAt
}
return nil
}
func (x *NeighborRecord) GetRawJson() []byte {
if x != nil {
return x.RawJson
}
return nil
}
type InterfaceRecord struct {
state protoimpl.MessageState `protogen:"open.v1"`
DeviceId string `protobuf:"bytes,1,opt,name=device_id,json=deviceId,proto3" json:"device_id,omitempty"`
IfIndex int64 `protobuf:"varint,2,opt,name=if_index,json=ifIndex,proto3" json:"if_index,omitempty"`
Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"`
Alias string `protobuf:"bytes,4,opt,name=alias,proto3" json:"alias,omitempty"`
Mac string `protobuf:"bytes,5,opt,name=mac,proto3" json:"mac,omitempty"`
Mtu uint32 `protobuf:"varint,6,opt,name=mtu,proto3" json:"mtu,omitempty"`
Type string `protobuf:"bytes,7,opt,name=type,proto3" json:"type,omitempty"` // ethernetCsmacd, ieee8023adLag, l3ipvlan
SpeedBps uint64 `protobuf:"varint,8,opt,name=speed_bps,json=speedBps,proto3" json:"speed_bps,omitempty"`
Duplex string `protobuf:"bytes,9,opt,name=duplex,proto3" json:"duplex,omitempty"`
AdminStatus string `protobuf:"bytes,10,opt,name=admin_status,json=adminStatus,proto3" json:"admin_status,omitempty"` // up | down | testing | unknown
OperStatus string `protobuf:"bytes,11,opt,name=oper_status,json=operStatus,proto3" json:"oper_status,omitempty"`
ParentIfIndex int64 `protobuf:"varint,12,opt,name=parent_if_index,json=parentIfIndex,proto3" json:"parent_if_index,omitempty"` // для членів LAG
IpAddresses []string `protobuf:"bytes,13,rep,name=ip_addresses,json=ipAddresses,proto3" json:"ip_addresses,omitempty"` // CIDR: 10.0.0.1/24
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *InterfaceRecord) Reset() {
*x = InterfaceRecord{}
mi := &file_netpulse_v1_discovery_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *InterfaceRecord) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*InterfaceRecord) ProtoMessage() {}
func (x *InterfaceRecord) ProtoReflect() protoreflect.Message {
mi := &file_netpulse_v1_discovery_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use InterfaceRecord.ProtoReflect.Descriptor instead.
func (*InterfaceRecord) Descriptor() ([]byte, []int) {
return file_netpulse_v1_discovery_proto_rawDescGZIP(), []int{1}
}
func (x *InterfaceRecord) GetDeviceId() string {
if x != nil {
return x.DeviceId
}
return ""
}
func (x *InterfaceRecord) GetIfIndex() int64 {
if x != nil {
return x.IfIndex
}
return 0
}
func (x *InterfaceRecord) GetName() string {
if x != nil {
return x.Name
}
return ""
}
func (x *InterfaceRecord) GetAlias() string {
if x != nil {
return x.Alias
}
return ""
}
func (x *InterfaceRecord) GetMac() string {
if x != nil {
return x.Mac
}
return ""
}
func (x *InterfaceRecord) GetMtu() uint32 {
if x != nil {
return x.Mtu
}
return 0
}
func (x *InterfaceRecord) GetType() string {
if x != nil {
return x.Type
}
return ""
}
func (x *InterfaceRecord) GetSpeedBps() uint64 {
if x != nil {
return x.SpeedBps
}
return 0
}
func (x *InterfaceRecord) GetDuplex() string {
if x != nil {
return x.Duplex
}
return ""
}
func (x *InterfaceRecord) GetAdminStatus() string {
if x != nil {
return x.AdminStatus
}
return ""
}
func (x *InterfaceRecord) GetOperStatus() string {
if x != nil {
return x.OperStatus
}
return ""
}
func (x *InterfaceRecord) GetParentIfIndex() int64 {
if x != nil {
return x.ParentIfIndex
}
return 0
}
func (x *InterfaceRecord) GetIpAddresses() []string {
if x != nil {
return x.IpAddresses
}
return nil
}
type DiscoveredDevice struct {
state protoimpl.MessageState `protogen:"open.v1"`
Address string `protobuf:"bytes,1,opt,name=address,proto3" json:"address,omitempty"`
Hostname string `protobuf:"bytes,2,opt,name=hostname,proto3" json:"hostname,omitempty"`
Mac string `protobuf:"bytes,3,opt,name=mac,proto3" json:"mac,omitempty"`
SysName string `protobuf:"bytes,4,opt,name=sys_name,json=sysName,proto3" json:"sys_name,omitempty"`
SysDescr string `protobuf:"bytes,5,opt,name=sys_descr,json=sysDescr,proto3" json:"sys_descr,omitempty"`
SysObjectId string `protobuf:"bytes,6,opt,name=sys_object_id,json=sysObjectId,proto3" json:"sys_object_id,omitempty"`
Vendor string `protobuf:"bytes,7,opt,name=vendor,proto3" json:"vendor,omitempty"`
Model string `protobuf:"bytes,8,opt,name=model,proto3" json:"model,omitempty"`
// Які транспорти відповіли під час сканування.
ReachableVia []Transport `protobuf:"varint,9,rep,packed,name=reachable_via,json=reachableVia,proto3,enum=netpulse.v1.Transport" json:"reachable_via,omitempty"`
// Здогад агента про тип пристрою — сервер може перевизначити.
GuessedKind string `protobuf:"bytes,10,opt,name=guessed_kind,json=guessedKind,proto3" json:"guessed_kind,omitempty"`
SeenAt *timestamppb.Timestamp `protobuf:"bytes,11,opt,name=seen_at,json=seenAt,proto3" json:"seen_at,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DiscoveredDevice) Reset() {
*x = DiscoveredDevice{}
mi := &file_netpulse_v1_discovery_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DiscoveredDevice) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DiscoveredDevice) ProtoMessage() {}
func (x *DiscoveredDevice) ProtoReflect() protoreflect.Message {
mi := &file_netpulse_v1_discovery_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use DiscoveredDevice.ProtoReflect.Descriptor instead.
func (*DiscoveredDevice) Descriptor() ([]byte, []int) {
return file_netpulse_v1_discovery_proto_rawDescGZIP(), []int{2}
}
func (x *DiscoveredDevice) GetAddress() string {
if x != nil {
return x.Address
}
return ""
}
func (x *DiscoveredDevice) GetHostname() string {
if x != nil {
return x.Hostname
}
return ""
}
func (x *DiscoveredDevice) GetMac() string {
if x != nil {
return x.Mac
}
return ""
}
func (x *DiscoveredDevice) GetSysName() string {
if x != nil {
return x.SysName
}
return ""
}
func (x *DiscoveredDevice) GetSysDescr() string {
if x != nil {
return x.SysDescr
}
return ""
}
func (x *DiscoveredDevice) GetSysObjectId() string {
if x != nil {
return x.SysObjectId
}
return ""
}
func (x *DiscoveredDevice) GetVendor() string {
if x != nil {
return x.Vendor
}
return ""
}
func (x *DiscoveredDevice) GetModel() string {
if x != nil {
return x.Model
}
return ""
}
func (x *DiscoveredDevice) GetReachableVia() []Transport {
if x != nil {
return x.ReachableVia
}
return nil
}
func (x *DiscoveredDevice) GetGuessedKind() string {
if x != nil {
return x.GuessedKind
}
return ""
}
func (x *DiscoveredDevice) GetSeenAt() *timestamppb.Timestamp {
if x != nil {
return x.SeenAt
}
return nil
}
type DiscoveryReport struct {
state protoimpl.MessageState `protogen:"open.v1"`
AgentId string `protobuf:"bytes,1,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"`
// Порожній, якщо це фонове періодичне виявлення, а не запуск із UI.
RunId string `protobuf:"bytes,2,opt,name=run_id,json=runId,proto3" json:"run_id,omitempty"`
Neighbors []*NeighborRecord `protobuf:"bytes,3,rep,name=neighbors,proto3" json:"neighbors,omitempty"`
Interfaces []*InterfaceRecord `protobuf:"bytes,4,rep,name=interfaces,proto3" json:"interfaces,omitempty"`
Devices []*DiscoveredDevice `protobuf:"bytes,5,rep,name=devices,proto3" json:"devices,omitempty"`
// Звіт розбитий на частини: сервер має чекати final = true,
// перш ніж закривати run і чистити застарілих сусідів.
Final bool `protobuf:"varint,6,opt,name=final,proto3" json:"final,omitempty"`
Part uint32 `protobuf:"varint,7,opt,name=part,proto3" json:"part,omitempty"`
StartedAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=started_at,json=startedAt,proto3" json:"started_at,omitempty"`
FinishedAt *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=finished_at,json=finishedAt,proto3" json:"finished_at,omitempty"`
Errors []*Error `protobuf:"bytes,10,rep,name=errors,proto3" json:"errors,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DiscoveryReport) Reset() {
*x = DiscoveryReport{}
mi := &file_netpulse_v1_discovery_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DiscoveryReport) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DiscoveryReport) ProtoMessage() {}
func (x *DiscoveryReport) ProtoReflect() protoreflect.Message {
mi := &file_netpulse_v1_discovery_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use DiscoveryReport.ProtoReflect.Descriptor instead.
func (*DiscoveryReport) Descriptor() ([]byte, []int) {
return file_netpulse_v1_discovery_proto_rawDescGZIP(), []int{3}
}
func (x *DiscoveryReport) GetAgentId() string {
if x != nil {
return x.AgentId
}
return ""
}
func (x *DiscoveryReport) GetRunId() string {
if x != nil {
return x.RunId
}
return ""
}
func (x *DiscoveryReport) GetNeighbors() []*NeighborRecord {
if x != nil {
return x.Neighbors
}
return nil
}
func (x *DiscoveryReport) GetInterfaces() []*InterfaceRecord {
if x != nil {
return x.Interfaces
}
return nil
}
func (x *DiscoveryReport) GetDevices() []*DiscoveredDevice {
if x != nil {
return x.Devices
}
return nil
}
func (x *DiscoveryReport) GetFinal() bool {
if x != nil {
return x.Final
}
return false
}
func (x *DiscoveryReport) GetPart() uint32 {
if x != nil {
return x.Part
}
return 0
}
func (x *DiscoveryReport) GetStartedAt() *timestamppb.Timestamp {
if x != nil {
return x.StartedAt
}
return nil
}
func (x *DiscoveryReport) GetFinishedAt() *timestamppb.Timestamp {
if x != nil {
return x.FinishedAt
}
return nil
}
func (x *DiscoveryReport) GetErrors() []*Error {
if x != nil {
return x.Errors
}
return nil
}
type DiscoveryAck struct {
state protoimpl.MessageState `protogen:"open.v1"`
Accepted bool `protobuf:"varint,1,opt,name=accepted,proto3" json:"accepted,omitempty"`
// Скільки записів сервер зміг зіставити з інвентарем.
NeighborsResolved uint32 `protobuf:"varint,2,opt,name=neighbors_resolved,json=neighborsResolved,proto3" json:"neighbors_resolved,omitempty"`
LinksCreated uint32 `protobuf:"varint,3,opt,name=links_created,json=linksCreated,proto3" json:"links_created,omitempty"`
DevicesCreated uint32 `protobuf:"varint,4,opt,name=devices_created,json=devicesCreated,proto3" json:"devices_created,omitempty"`
Error *Error `protobuf:"bytes,5,opt,name=error,proto3" json:"error,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DiscoveryAck) Reset() {
*x = DiscoveryAck{}
mi := &file_netpulse_v1_discovery_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DiscoveryAck) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DiscoveryAck) ProtoMessage() {}
func (x *DiscoveryAck) ProtoReflect() protoreflect.Message {
mi := &file_netpulse_v1_discovery_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use DiscoveryAck.ProtoReflect.Descriptor instead.
func (*DiscoveryAck) Descriptor() ([]byte, []int) {
return file_netpulse_v1_discovery_proto_rawDescGZIP(), []int{4}
}
func (x *DiscoveryAck) GetAccepted() bool {
if x != nil {
return x.Accepted
}
return false
}
func (x *DiscoveryAck) GetNeighborsResolved() uint32 {
if x != nil {
return x.NeighborsResolved
}
return 0
}
func (x *DiscoveryAck) GetLinksCreated() uint32 {
if x != nil {
return x.LinksCreated
}
return 0
}
func (x *DiscoveryAck) GetDevicesCreated() uint32 {
if x != nil {
return x.DevicesCreated
}
return 0
}
func (x *DiscoveryAck) GetError() *Error {
if x != nil {
return x.Error
}
return nil
}
var File_netpulse_v1_discovery_proto protoreflect.FileDescriptor
const file_netpulse_v1_discovery_proto_rawDesc = "" +
"\n" +
"\x1bnetpulse/v1/discovery.proto\x12\vnetpulse.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x18netpulse/v1/common.proto\"\xf7\x04\n" +
"\x0eNeighborRecord\x12\x1b\n" +
"\tdevice_id\x18\x01 \x01(\tR\bdeviceId\x12,\n" +
"\x12local_interface_id\x18\x02 \x01(\tR\x10localInterfaceId\x12$\n" +
"\x0elocal_if_index\x18\x03 \x01(\x03R\flocalIfIndex\x12&\n" +
"\x0flocal_port_name\x18\x04 \x01(\tR\rlocalPortName\x121\n" +
"\x05proto\x18\x05 \x01(\x0e2\x1b.netpulse.v1.DiscoveryProtoR\x05proto\x12*\n" +
"\x11remote_chassis_id\x18\x06 \x01(\tR\x0fremoteChassisId\x12,\n" +
"\x12remote_system_name\x18\a \x01(\tR\x10remoteSystemName\x12$\n" +
"\x0eremote_port_id\x18\b \x01(\tR\fremotePortId\x12*\n" +
"\x11remote_port_descr\x18\t \x01(\tR\x0fremotePortDescr\x12$\n" +
"\x0eremote_mgmt_ip\x18\n" +
" \x01(\tR\fremoteMgmtIp\x12\x1d\n" +
"\n" +
"remote_mac\x18\v \x01(\tR\tremoteMac\x12'\n" +
"\x0fremote_platform\x18\f \x01(\tR\x0eremotePlatform\x12/\n" +
"\x13remote_capabilities\x18\r \x03(\tR\x12remoteCapabilities\x123\n" +
"\aseen_at\x18\x0e \x01(\v2\x1a.google.protobuf.TimestampR\x06seenAt\x12\x19\n" +
"\braw_json\x18\x0f \x01(\fR\arawJson\"\xef\x02\n" +
"\x0fInterfaceRecord\x12\x1b\n" +
"\tdevice_id\x18\x01 \x01(\tR\bdeviceId\x12\x19\n" +
"\bif_index\x18\x02 \x01(\x03R\aifIndex\x12\x12\n" +
"\x04name\x18\x03 \x01(\tR\x04name\x12\x14\n" +
"\x05alias\x18\x04 \x01(\tR\x05alias\x12\x10\n" +
"\x03mac\x18\x05 \x01(\tR\x03mac\x12\x10\n" +
"\x03mtu\x18\x06 \x01(\rR\x03mtu\x12\x12\n" +
"\x04type\x18\a \x01(\tR\x04type\x12\x1b\n" +
"\tspeed_bps\x18\b \x01(\x04R\bspeedBps\x12\x16\n" +
"\x06duplex\x18\t \x01(\tR\x06duplex\x12!\n" +
"\fadmin_status\x18\n" +
" \x01(\tR\vadminStatus\x12\x1f\n" +
"\voper_status\x18\v \x01(\tR\n" +
"operStatus\x12&\n" +
"\x0fparent_if_index\x18\f \x01(\x03R\rparentIfIndex\x12!\n" +
"\fip_addresses\x18\r \x03(\tR\vipAddresses\"\xf9\x02\n" +
"\x10DiscoveredDevice\x12\x18\n" +
"\aaddress\x18\x01 \x01(\tR\aaddress\x12\x1a\n" +
"\bhostname\x18\x02 \x01(\tR\bhostname\x12\x10\n" +
"\x03mac\x18\x03 \x01(\tR\x03mac\x12\x19\n" +
"\bsys_name\x18\x04 \x01(\tR\asysName\x12\x1b\n" +
"\tsys_descr\x18\x05 \x01(\tR\bsysDescr\x12\"\n" +
"\rsys_object_id\x18\x06 \x01(\tR\vsysObjectId\x12\x16\n" +
"\x06vendor\x18\a \x01(\tR\x06vendor\x12\x14\n" +
"\x05model\x18\b \x01(\tR\x05model\x12;\n" +
"\rreachable_via\x18\t \x03(\x0e2\x16.netpulse.v1.TransportR\freachableVia\x12!\n" +
"\fguessed_kind\x18\n" +
" \x01(\tR\vguessedKind\x123\n" +
"\aseen_at\x18\v \x01(\v2\x1a.google.protobuf.TimestampR\x06seenAt\"\xc3\x03\n" +
"\x0fDiscoveryReport\x12\x19\n" +
"\bagent_id\x18\x01 \x01(\tR\aagentId\x12\x15\n" +
"\x06run_id\x18\x02 \x01(\tR\x05runId\x129\n" +
"\tneighbors\x18\x03 \x03(\v2\x1b.netpulse.v1.NeighborRecordR\tneighbors\x12<\n" +
"\n" +
"interfaces\x18\x04 \x03(\v2\x1c.netpulse.v1.InterfaceRecordR\n" +
"interfaces\x127\n" +
"\adevices\x18\x05 \x03(\v2\x1d.netpulse.v1.DiscoveredDeviceR\adevices\x12\x14\n" +
"\x05final\x18\x06 \x01(\bR\x05final\x12\x12\n" +
"\x04part\x18\a \x01(\rR\x04part\x129\n" +
"\n" +
"started_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\tstartedAt\x12;\n" +
"\vfinished_at\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\n" +
"finishedAt\x12*\n" +
"\x06errors\x18\n" +
" \x03(\v2\x12.netpulse.v1.ErrorR\x06errors\"\xd1\x01\n" +
"\fDiscoveryAck\x12\x1a\n" +
"\baccepted\x18\x01 \x01(\bR\baccepted\x12-\n" +
"\x12neighbors_resolved\x18\x02 \x01(\rR\x11neighborsResolved\x12#\n" +
"\rlinks_created\x18\x03 \x01(\rR\flinksCreated\x12'\n" +
"\x0fdevices_created\x18\x04 \x01(\rR\x0edevicesCreated\x12(\n" +
"\x05error\x18\x05 \x01(\v2\x12.netpulse.v1.ErrorR\x05errorB<Z:github.com/netpulse/netpulse/gen/go/netpulse/v1;netpulsev1b\x06proto3"
var (
file_netpulse_v1_discovery_proto_rawDescOnce sync.Once
file_netpulse_v1_discovery_proto_rawDescData []byte
)
func file_netpulse_v1_discovery_proto_rawDescGZIP() []byte {
file_netpulse_v1_discovery_proto_rawDescOnce.Do(func() {
file_netpulse_v1_discovery_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_netpulse_v1_discovery_proto_rawDesc), len(file_netpulse_v1_discovery_proto_rawDesc)))
})
return file_netpulse_v1_discovery_proto_rawDescData
}
var file_netpulse_v1_discovery_proto_msgTypes = make([]protoimpl.MessageInfo, 5)
var file_netpulse_v1_discovery_proto_goTypes = []any{
(*NeighborRecord)(nil), // 0: netpulse.v1.NeighborRecord
(*InterfaceRecord)(nil), // 1: netpulse.v1.InterfaceRecord
(*DiscoveredDevice)(nil), // 2: netpulse.v1.DiscoveredDevice
(*DiscoveryReport)(nil), // 3: netpulse.v1.DiscoveryReport
(*DiscoveryAck)(nil), // 4: netpulse.v1.DiscoveryAck
(DiscoveryProto)(0), // 5: netpulse.v1.DiscoveryProto
(*timestamppb.Timestamp)(nil), // 6: google.protobuf.Timestamp
(Transport)(0), // 7: netpulse.v1.Transport
(*Error)(nil), // 8: netpulse.v1.Error
}
var file_netpulse_v1_discovery_proto_depIdxs = []int32{
5, // 0: netpulse.v1.NeighborRecord.proto:type_name -> netpulse.v1.DiscoveryProto
6, // 1: netpulse.v1.NeighborRecord.seen_at:type_name -> google.protobuf.Timestamp
7, // 2: netpulse.v1.DiscoveredDevice.reachable_via:type_name -> netpulse.v1.Transport
6, // 3: netpulse.v1.DiscoveredDevice.seen_at:type_name -> google.protobuf.Timestamp
0, // 4: netpulse.v1.DiscoveryReport.neighbors:type_name -> netpulse.v1.NeighborRecord
1, // 5: netpulse.v1.DiscoveryReport.interfaces:type_name -> netpulse.v1.InterfaceRecord
2, // 6: netpulse.v1.DiscoveryReport.devices:type_name -> netpulse.v1.DiscoveredDevice
6, // 7: netpulse.v1.DiscoveryReport.started_at:type_name -> google.protobuf.Timestamp
6, // 8: netpulse.v1.DiscoveryReport.finished_at:type_name -> google.protobuf.Timestamp
8, // 9: netpulse.v1.DiscoveryReport.errors:type_name -> netpulse.v1.Error
8, // 10: netpulse.v1.DiscoveryAck.error:type_name -> netpulse.v1.Error
11, // [11:11] is the sub-list for method output_type
11, // [11:11] is the sub-list for method input_type
11, // [11:11] is the sub-list for extension type_name
11, // [11:11] is the sub-list for extension extendee
0, // [0:11] is the sub-list for field type_name
}
func init() { file_netpulse_v1_discovery_proto_init() }
func file_netpulse_v1_discovery_proto_init() {
if File_netpulse_v1_discovery_proto != nil {
return
}
file_netpulse_v1_common_proto_init()
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_netpulse_v1_discovery_proto_rawDesc), len(file_netpulse_v1_discovery_proto_rawDesc)),
NumEnums: 0,
NumMessages: 5,
NumExtensions: 0,
NumServices: 0,
},
GoTypes: file_netpulse_v1_discovery_proto_goTypes,
DependencyIndexes: file_netpulse_v1_discovery_proto_depIdxs,
MessageInfos: file_netpulse_v1_discovery_proto_msgTypes,
}.Build()
File_netpulse_v1_discovery_proto = out.File
file_netpulse_v1_discovery_proto_goTypes = nil
file_netpulse_v1_discovery_proto_depIdxs = nil
}

View file

@ -0,0 +1,539 @@
// =====================================================================
// NetPulse :: logs.proto
// Syslog та SNMP-трапи, зібрані агентом. → ts.syslog / ts.snmp_traps
//
// Агент слухає 514/udp і 162/udp у мережі клієнта й тунелює події
// назовні. Це важливо не лише для журналу: подія "%SYS-5-CONFIG_I"
// тригерить позачерговий бекап конфігу (ncm.device_policies.on_syslog).
// =====================================================================
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.12
// protoc v3.21.12
// source: netpulse/v1/logs.proto
package netpulsev1
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
timestamppb "google.golang.org/protobuf/types/known/timestamppb"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type SyslogEntry struct {
state protoimpl.MessageState `protogen:"open.v1"`
Ts *timestamppb.Timestamp `protobuf:"bytes,1,opt,name=ts,proto3" json:"ts,omitempty"`
// Резолвиться агентом за source_ip; порожній, якщо пристрій невідомий.
DeviceId string `protobuf:"bytes,2,opt,name=device_id,json=deviceId,proto3" json:"device_id,omitempty"`
SourceIp string `protobuf:"bytes,3,opt,name=source_ip,json=sourceIp,proto3" json:"source_ip,omitempty"`
Facility uint32 `protobuf:"varint,4,opt,name=facility,proto3" json:"facility,omitempty"`
Severity uint32 `protobuf:"varint,5,opt,name=severity,proto3" json:"severity,omitempty"` // 0 emerg .. 7 debug
Hostname string `protobuf:"bytes,6,opt,name=hostname,proto3" json:"hostname,omitempty"`
Tag string `protobuf:"bytes,7,opt,name=tag,proto3" json:"tag,omitempty"`
Message string `protobuf:"bytes,8,opt,name=message,proto3" json:"message,omitempty"`
// Розібрані поля, якщо агент упізнав формат (RFC5424 structured data).
Parsed map[string]string `protobuf:"bytes,9,rep,name=parsed,proto3" json:"parsed,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *SyslogEntry) Reset() {
*x = SyslogEntry{}
mi := &file_netpulse_v1_logs_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *SyslogEntry) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*SyslogEntry) ProtoMessage() {}
func (x *SyslogEntry) ProtoReflect() protoreflect.Message {
mi := &file_netpulse_v1_logs_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use SyslogEntry.ProtoReflect.Descriptor instead.
func (*SyslogEntry) Descriptor() ([]byte, []int) {
return file_netpulse_v1_logs_proto_rawDescGZIP(), []int{0}
}
func (x *SyslogEntry) GetTs() *timestamppb.Timestamp {
if x != nil {
return x.Ts
}
return nil
}
func (x *SyslogEntry) GetDeviceId() string {
if x != nil {
return x.DeviceId
}
return ""
}
func (x *SyslogEntry) GetSourceIp() string {
if x != nil {
return x.SourceIp
}
return ""
}
func (x *SyslogEntry) GetFacility() uint32 {
if x != nil {
return x.Facility
}
return 0
}
func (x *SyslogEntry) GetSeverity() uint32 {
if x != nil {
return x.Severity
}
return 0
}
func (x *SyslogEntry) GetHostname() string {
if x != nil {
return x.Hostname
}
return ""
}
func (x *SyslogEntry) GetTag() string {
if x != nil {
return x.Tag
}
return ""
}
func (x *SyslogEntry) GetMessage() string {
if x != nil {
return x.Message
}
return ""
}
func (x *SyslogEntry) GetParsed() map[string]string {
if x != nil {
return x.Parsed
}
return nil
}
type SnmpTrap struct {
state protoimpl.MessageState `protogen:"open.v1"`
Ts *timestamppb.Timestamp `protobuf:"bytes,1,opt,name=ts,proto3" json:"ts,omitempty"`
DeviceId string `protobuf:"bytes,2,opt,name=device_id,json=deviceId,proto3" json:"device_id,omitempty"`
SourceIp string `protobuf:"bytes,3,opt,name=source_ip,json=sourceIp,proto3" json:"source_ip,omitempty"`
TrapOid string `protobuf:"bytes,4,opt,name=trap_oid,json=trapOid,proto3" json:"trap_oid,omitempty"`
Varbinds []*VarBind `protobuf:"bytes,5,rep,name=varbinds,proto3" json:"varbinds,omitempty"`
// v2c community або v3 security name — сервер перевіряє довіру джерела.
AuthContext string `protobuf:"bytes,6,opt,name=auth_context,json=authContext,proto3" json:"auth_context,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *SnmpTrap) Reset() {
*x = SnmpTrap{}
mi := &file_netpulse_v1_logs_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *SnmpTrap) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*SnmpTrap) ProtoMessage() {}
func (x *SnmpTrap) ProtoReflect() protoreflect.Message {
mi := &file_netpulse_v1_logs_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use SnmpTrap.ProtoReflect.Descriptor instead.
func (*SnmpTrap) Descriptor() ([]byte, []int) {
return file_netpulse_v1_logs_proto_rawDescGZIP(), []int{1}
}
func (x *SnmpTrap) GetTs() *timestamppb.Timestamp {
if x != nil {
return x.Ts
}
return nil
}
func (x *SnmpTrap) GetDeviceId() string {
if x != nil {
return x.DeviceId
}
return ""
}
func (x *SnmpTrap) GetSourceIp() string {
if x != nil {
return x.SourceIp
}
return ""
}
func (x *SnmpTrap) GetTrapOid() string {
if x != nil {
return x.TrapOid
}
return ""
}
func (x *SnmpTrap) GetVarbinds() []*VarBind {
if x != nil {
return x.Varbinds
}
return nil
}
func (x *SnmpTrap) GetAuthContext() string {
if x != nil {
return x.AuthContext
}
return ""
}
type VarBind struct {
state protoimpl.MessageState `protogen:"open.v1"`
Oid string `protobuf:"bytes,1,opt,name=oid,proto3" json:"oid,omitempty"`
Type string `protobuf:"bytes,2,opt,name=type,proto3" json:"type,omitempty"` // INTEGER, OCTET STRING, Counter64, ...
Value string `protobuf:"bytes,3,opt,name=value,proto3" json:"value,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *VarBind) Reset() {
*x = VarBind{}
mi := &file_netpulse_v1_logs_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *VarBind) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*VarBind) ProtoMessage() {}
func (x *VarBind) ProtoReflect() protoreflect.Message {
mi := &file_netpulse_v1_logs_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use VarBind.ProtoReflect.Descriptor instead.
func (*VarBind) Descriptor() ([]byte, []int) {
return file_netpulse_v1_logs_proto_rawDescGZIP(), []int{2}
}
func (x *VarBind) GetOid() string {
if x != nil {
return x.Oid
}
return ""
}
func (x *VarBind) GetType() string {
if x != nil {
return x.Type
}
return ""
}
func (x *VarBind) GetValue() string {
if x != nil {
return x.Value
}
return ""
}
type LogBatch struct {
state protoimpl.MessageState `protogen:"open.v1"`
BatchId uint64 `protobuf:"varint,1,opt,name=batch_id,json=batchId,proto3" json:"batch_id,omitempty"`
AgentId string `protobuf:"bytes,2,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"`
Syslog []*SyslogEntry `protobuf:"bytes,3,rep,name=syslog,proto3" json:"syslog,omitempty"`
Traps []*SnmpTrap `protobuf:"bytes,4,rep,name=traps,proto3" json:"traps,omitempty"`
// Скільки подій агент відкинув через переповнення (rate limit).
// Ненульове — сигнал, що клієнт шумить або ліміт замалий.
Dropped uint64 `protobuf:"varint,5,opt,name=dropped,proto3" json:"dropped,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *LogBatch) Reset() {
*x = LogBatch{}
mi := &file_netpulse_v1_logs_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *LogBatch) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*LogBatch) ProtoMessage() {}
func (x *LogBatch) ProtoReflect() protoreflect.Message {
mi := &file_netpulse_v1_logs_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use LogBatch.ProtoReflect.Descriptor instead.
func (*LogBatch) Descriptor() ([]byte, []int) {
return file_netpulse_v1_logs_proto_rawDescGZIP(), []int{3}
}
func (x *LogBatch) GetBatchId() uint64 {
if x != nil {
return x.BatchId
}
return 0
}
func (x *LogBatch) GetAgentId() string {
if x != nil {
return x.AgentId
}
return ""
}
func (x *LogBatch) GetSyslog() []*SyslogEntry {
if x != nil {
return x.Syslog
}
return nil
}
func (x *LogBatch) GetTraps() []*SnmpTrap {
if x != nil {
return x.Traps
}
return nil
}
func (x *LogBatch) GetDropped() uint64 {
if x != nil {
return x.Dropped
}
return 0
}
type LogAck struct {
state protoimpl.MessageState `protogen:"open.v1"`
AckedThroughBatchId uint64 `protobuf:"varint,1,opt,name=acked_through_batch_id,json=ackedThroughBatchId,proto3" json:"acked_through_batch_id,omitempty"`
// Сервер просить фільтрувати шум на агенті: не слати нижче цієї severity.
MinSeverity uint32 `protobuf:"varint,2,opt,name=min_severity,json=minSeverity,proto3" json:"min_severity,omitempty"`
// Ліміт подій за секунду з одного джерела.
RateLimitPerSource uint32 `protobuf:"varint,3,opt,name=rate_limit_per_source,json=rateLimitPerSource,proto3" json:"rate_limit_per_source,omitempty"`
Error *Error `protobuf:"bytes,4,opt,name=error,proto3" json:"error,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *LogAck) Reset() {
*x = LogAck{}
mi := &file_netpulse_v1_logs_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *LogAck) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*LogAck) ProtoMessage() {}
func (x *LogAck) ProtoReflect() protoreflect.Message {
mi := &file_netpulse_v1_logs_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use LogAck.ProtoReflect.Descriptor instead.
func (*LogAck) Descriptor() ([]byte, []int) {
return file_netpulse_v1_logs_proto_rawDescGZIP(), []int{4}
}
func (x *LogAck) GetAckedThroughBatchId() uint64 {
if x != nil {
return x.AckedThroughBatchId
}
return 0
}
func (x *LogAck) GetMinSeverity() uint32 {
if x != nil {
return x.MinSeverity
}
return 0
}
func (x *LogAck) GetRateLimitPerSource() uint32 {
if x != nil {
return x.RateLimitPerSource
}
return 0
}
func (x *LogAck) GetError() *Error {
if x != nil {
return x.Error
}
return nil
}
var File_netpulse_v1_logs_proto protoreflect.FileDescriptor
const file_netpulse_v1_logs_proto_rawDesc = "" +
"\n" +
"\x16netpulse/v1/logs.proto\x12\vnetpulse.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x18netpulse/v1/common.proto\"\xec\x02\n" +
"\vSyslogEntry\x12*\n" +
"\x02ts\x18\x01 \x01(\v2\x1a.google.protobuf.TimestampR\x02ts\x12\x1b\n" +
"\tdevice_id\x18\x02 \x01(\tR\bdeviceId\x12\x1b\n" +
"\tsource_ip\x18\x03 \x01(\tR\bsourceIp\x12\x1a\n" +
"\bfacility\x18\x04 \x01(\rR\bfacility\x12\x1a\n" +
"\bseverity\x18\x05 \x01(\rR\bseverity\x12\x1a\n" +
"\bhostname\x18\x06 \x01(\tR\bhostname\x12\x10\n" +
"\x03tag\x18\a \x01(\tR\x03tag\x12\x18\n" +
"\amessage\x18\b \x01(\tR\amessage\x12<\n" +
"\x06parsed\x18\t \x03(\v2$.netpulse.v1.SyslogEntry.ParsedEntryR\x06parsed\x1a9\n" +
"\vParsedEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xe0\x01\n" +
"\bSnmpTrap\x12*\n" +
"\x02ts\x18\x01 \x01(\v2\x1a.google.protobuf.TimestampR\x02ts\x12\x1b\n" +
"\tdevice_id\x18\x02 \x01(\tR\bdeviceId\x12\x1b\n" +
"\tsource_ip\x18\x03 \x01(\tR\bsourceIp\x12\x19\n" +
"\btrap_oid\x18\x04 \x01(\tR\atrapOid\x120\n" +
"\bvarbinds\x18\x05 \x03(\v2\x14.netpulse.v1.VarBindR\bvarbinds\x12!\n" +
"\fauth_context\x18\x06 \x01(\tR\vauthContext\"E\n" +
"\aVarBind\x12\x10\n" +
"\x03oid\x18\x01 \x01(\tR\x03oid\x12\x12\n" +
"\x04type\x18\x02 \x01(\tR\x04type\x12\x14\n" +
"\x05value\x18\x03 \x01(\tR\x05value\"\xb9\x01\n" +
"\bLogBatch\x12\x19\n" +
"\bbatch_id\x18\x01 \x01(\x04R\abatchId\x12\x19\n" +
"\bagent_id\x18\x02 \x01(\tR\aagentId\x120\n" +
"\x06syslog\x18\x03 \x03(\v2\x18.netpulse.v1.SyslogEntryR\x06syslog\x12+\n" +
"\x05traps\x18\x04 \x03(\v2\x15.netpulse.v1.SnmpTrapR\x05traps\x12\x18\n" +
"\adropped\x18\x05 \x01(\x04R\adropped\"\xbd\x01\n" +
"\x06LogAck\x123\n" +
"\x16acked_through_batch_id\x18\x01 \x01(\x04R\x13ackedThroughBatchId\x12!\n" +
"\fmin_severity\x18\x02 \x01(\rR\vminSeverity\x121\n" +
"\x15rate_limit_per_source\x18\x03 \x01(\rR\x12rateLimitPerSource\x12(\n" +
"\x05error\x18\x04 \x01(\v2\x12.netpulse.v1.ErrorR\x05errorB<Z:github.com/netpulse/netpulse/gen/go/netpulse/v1;netpulsev1b\x06proto3"
var (
file_netpulse_v1_logs_proto_rawDescOnce sync.Once
file_netpulse_v1_logs_proto_rawDescData []byte
)
func file_netpulse_v1_logs_proto_rawDescGZIP() []byte {
file_netpulse_v1_logs_proto_rawDescOnce.Do(func() {
file_netpulse_v1_logs_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_netpulse_v1_logs_proto_rawDesc), len(file_netpulse_v1_logs_proto_rawDesc)))
})
return file_netpulse_v1_logs_proto_rawDescData
}
var file_netpulse_v1_logs_proto_msgTypes = make([]protoimpl.MessageInfo, 6)
var file_netpulse_v1_logs_proto_goTypes = []any{
(*SyslogEntry)(nil), // 0: netpulse.v1.SyslogEntry
(*SnmpTrap)(nil), // 1: netpulse.v1.SnmpTrap
(*VarBind)(nil), // 2: netpulse.v1.VarBind
(*LogBatch)(nil), // 3: netpulse.v1.LogBatch
(*LogAck)(nil), // 4: netpulse.v1.LogAck
nil, // 5: netpulse.v1.SyslogEntry.ParsedEntry
(*timestamppb.Timestamp)(nil), // 6: google.protobuf.Timestamp
(*Error)(nil), // 7: netpulse.v1.Error
}
var file_netpulse_v1_logs_proto_depIdxs = []int32{
6, // 0: netpulse.v1.SyslogEntry.ts:type_name -> google.protobuf.Timestamp
5, // 1: netpulse.v1.SyslogEntry.parsed:type_name -> netpulse.v1.SyslogEntry.ParsedEntry
6, // 2: netpulse.v1.SnmpTrap.ts:type_name -> google.protobuf.Timestamp
2, // 3: netpulse.v1.SnmpTrap.varbinds:type_name -> netpulse.v1.VarBind
0, // 4: netpulse.v1.LogBatch.syslog:type_name -> netpulse.v1.SyslogEntry
1, // 5: netpulse.v1.LogBatch.traps:type_name -> netpulse.v1.SnmpTrap
7, // 6: netpulse.v1.LogAck.error:type_name -> netpulse.v1.Error
7, // [7:7] is the sub-list for method output_type
7, // [7:7] is the sub-list for method input_type
7, // [7:7] is the sub-list for extension type_name
7, // [7:7] is the sub-list for extension extendee
0, // [0:7] is the sub-list for field type_name
}
func init() { file_netpulse_v1_logs_proto_init() }
func file_netpulse_v1_logs_proto_init() {
if File_netpulse_v1_logs_proto != nil {
return
}
file_netpulse_v1_common_proto_init()
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_netpulse_v1_logs_proto_rawDesc), len(file_netpulse_v1_logs_proto_rawDesc)),
NumEnums: 0,
NumMessages: 6,
NumExtensions: 0,
NumServices: 0,
},
GoTypes: file_netpulse_v1_logs_proto_goTypes,
DependencyIndexes: file_netpulse_v1_logs_proto_depIdxs,
MessageInfos: file_netpulse_v1_logs_proto_msgTypes,
}.Build()
File_netpulse_v1_logs_proto = out.File
file_netpulse_v1_logs_proto_goTypes = nil
file_netpulse_v1_logs_proto_depIdxs = nil
}

1093
gen/go/netpulse/v1/ncm.pb.go Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

189
proto/README.md Normal file
View file

@ -0,0 +1,189 @@
# NetPulse — контракт агент↔сервер (Етап 2)
gRPC / protobuf, пакет `netpulse.v1`. Джерело істини — файли в `netpulse/v1/`;
`gen/go` згенерований і комітиться, щоб збірка агента не залежала від наявності `buf`.
## Головне обмеження, з якого випливає вся форма контракту
**Усі з'єднання ініціює агент.** У мережі клієнта немає ані відкритих портів, ані
прокидання NAT — зонд стоїть за фаєрволом і має лише вихідний доступ. Тому
«команда з сервера» фізично є повідомленням у зустрічному напрямку вже відкритого
агентом bidi-стріму `Control`. Це не деталь реалізації, а причина, чому контракт
побудований навколо довгоживучих потоків, а не навколо RPC-викликів у бік агента.
## Файли
| Файл | Зміст |
|------|-------|
| `common.proto` | Спільні типи: `Status`, `Transport`, `DiscoveryProto`, `Error`, `DeviceTarget`, `Credential`, `AgentBuild`, `AgentHealth` |
| `agent.proto` | `EnrollmentService`, `AgentService`, `ControlUp`/`ControlDown` і все, що всередині них |
| `telemetry.proto` | `SeriesDescriptor`, `MetricSample`, `IcmpResult`, `InterfaceCounters`, `TelemetryBatch`, `TelemetryAck` |
| `discovery.proto` | `NeighborRecord`, `InterfaceRecord`, `DiscoveredDevice`, `DiscoveryReport` |
| `ncm.proto` | `ConfigJob`, `ConfigUpload` (header/chunk/trailer), `ConfigApplyJob` |
| `logs.proto` | `SyslogEntry`, `SnmpTrap`, `LogBatch` |
## Сервіси
```
EnrollmentService
Enroll(EnrollRequest) → EnrollResponse одноразово, без клієнтського сертифіката
AgentService усе далі — тільки mTLS
Control(stream ControlUp) → stream ControlDown керування, довгоживучий
StreamTelemetry(stream TelemetryBatch) → stream TelemetryAck
StreamLogs(stream LogBatch) → stream LogAck
ReportDiscovery(DiscoveryReport) → DiscoveryAck
UploadConfig(stream ConfigUpload) → ConfigReceipt
```
Чотири окремі стріми, а не один — навмисно. Пачка на 10 000 семплів не має
блокувати heartbeat і затримувати команду з сервера, а сплеск syslog під час
аварії не має топити телеметрію, за якою цю аварію й видно.
## Життєвий цикл сесії
```mermaid
sequenceDiagram
participant A as Агент (мережа клієнта)
participant S as Сервер
Note over A,S: одноразово
A->>S: Enroll(enrollment_token, CSR)
S-->>A: сертифікат + CA + control_endpoint
Note over A,S: кожна сесія, тільки вихідне з'єднання
A->>S: Control: Hello(build, task_plan_hash, last_acked_batch_id)
S-->>A: Welcome(session_id, server_time, ліміти батчингу)
S-->>A: TaskPlan(tasks, devices) або TaskDelta
S-->>A: ModuleControl(які модулі активувати)
S-->>A: CredentialBundle(розшифровані креди, з TTL)
loop опитування
A->>S: StreamTelemetry: TelemetryBatch(new_series?, samples, icmp, interfaces)
S-->>A: TelemetryAck(acked_through, max_in_flight)
A->>S: Control: Heartbeat(AgentHealth)
S-->>A: Control: Ping
A->>S: Control: Pong
end
Note over A,S: за подією або розкладом
S-->>A: Control: ConfigJob(commands, prompt_regex)
A->>S: UploadConfig: header → chunk* → trailer(sha256)
S-->>A: ConfigReceipt(commit_sha | unchanged)
```
## Рішення, які варто розуміти перед реалізацією агента
### Інтернування серій
Повторювати `device_id` (36 байт) + `metric_key` + labels у кожному семплі задорого:
при 50 000 пристроїв це десятки байт службових даних на одне число `float64`.
Тому агент реєструє серію один раз під локальним номером `series_ref`, далі шле
лише номер і значення. Сервер тримає мапу `series_ref → ts.series.id` на час сесії.
Заміряно в `test/contract`: **66 байт → 25 байт** на семпл.
`series_ref` дійсний рівно в межах сесії. Після реконекту агент нумерує з 1 і
реєструє все заново. Якщо сервер втратив стан — відповідає `reset_series_table = true`,
а не мовчки викидає дані.
### Хто рахує швидкості
Агент шле **і** сирі 64-бітні лічильники, **і** вже пораховані `in_bps`/`out_bps`/
`util_*_pct`. Швидкості рахує агент, бо лише він знає точний інтервал між двома
опитуваннями — мережева затримка робить серверний розрахунок неточним. Сирі
лічильники потрібні, щоб сервер міг перерахувати заднім числом. Обробка
wrap/reset — на агенті: при перезавантаженні пристрою виставляється
`counter_reset = true`, і швидкості цього разу недійсні.
### Хто чистить конфіги
Агент віддає **сирий** текст. Scrub (прибрати `uptime` та timestamp'и, щоб не шуміти
в diff), redact (замаскувати паролі) і коміт у Git — на сервері. Це навмисно:
правила очищення живуть у `ncm.profiles` і мають змінюватись без оновлення
агентів у полі.
### Хто вирішує топологію
Агент доповідає сире: «на порту X я бачу chassis-id Y, port-id Z». Він **не**
вирішує, хто з ким з'єднаний. Резолвер на сервері зводить це в `topo.links` і
виставляє `confidence`. Так автовиявлення лишається відтворюваним і не залежить
від версії агента в полі.
### Непрозорі параметри задач
`Task.params_json` — байти, а не типізоване поле. Валідність гарантує
`params_schema` з `core.check_types`, розбирає їх сам модуль. Завдяки цьому новий
плагін не потребує зміни `.proto` — саме те, заради чого затівалась плагінна
архітектура. Модуль-виконавець визначається префіксом `check_type` до крапки:
`snmp.if` → модуль `snmp`.
### Семантика доставки
**At-least-once.** Дедуплікацію забезпечує сама схема БД: первинні ключі
`(ts, device_id)`, `(ts, interface_id)`, `(ts, series_id)` роблять повторний запис
безпечним. Агент тримає неacknowledged батчі й після реконекту шле їх із
`is_retransmit = true`, продовжуючи від `last_acked_batch_id` з `Hello`.
### Зворотний тиск
Розмір батчу, інтервал і `max_in_flight` диктує сервер у `Welcome`, а коригує в
кожному `TelemetryAck`. Балакучого агента можна пригальмувати на льоту, не
оновлюючи бінарник у полі. `AgentHealth.dropped_samples` показує, коли ліміти
затиснуті надто сильно.
## Безпека
- **mTLS на всьому, крім `Enroll`.** Агент приходить із одноразовим enrollment-токеном
і CSR, іде з власним сертифікатом. Приватний ключ ніколи не залишає агента.
- **Креденшели передаються розшифрованими** — сервер дістає їх із `core.secrets` і
розшифровує. Агент тримає їх лише в пам'яті, не пише на диск, не логує, і
зобов'язаний занулити після `Credential.expires_at`.
- **Самооновлення підписане.** `UpdateInfo` несе sha256 бінарника і підпис Ed25519
над ним. Без валідного підпису оновлення не застосовується: інакше компрометація
CDN перетворюється на RCE в мережі кожного клієнта.
- **Відкат конфігурації** (`ConfigApplyJob`) агент виконує беззастережно — уся логіка
погодження (`ncm.rollbacks`: draft → awaiting_approval → approved) лишається на
сервері. `confirm_timeout` вмикає confirmed commit там, де пристрій це підтримує:
найкращий захист від втрати керування після помилкового правила фаєрвола.
## Версіонування
Пакет `netpulse.v1`. Правила: не змінювати номери полів, не перевикористовувати
видалені (позначати `reserved`), нові поля — тільки додавати. Ламкі зміни —
через `netpulse.v2` поруч, бо агенти в полі оновлюються не одночасно з сервером.
`buf breaking` у CI проти головної гілки ловить порушення автоматично.
## Кодогенерація
```bash
buf generate
```
Без `buf` (те, чим це перевірялось):
```bash
protoc -I proto --go_out=gen/go --go_opt=paths=source_relative --go-grpc_out=gen/go --go-grpc_opt=paths=source_relative proto/netpulse/v1/*.proto
```
## Стан перевірки
Перевірено на стенді Debian 13: protoc 3.21.12, Go 1.24.4, buf 1.72.0.
| Крок | Результат |
|------|-----------|
| `protoc` — валідність усіх 6 файлів і залежностей | OK |
| `buf lint` (STANDARD) | без зауважень |
| Генерація Go + gRPC, `go build`, `go vet` | OK |
| `go test ./test/contract/...` — 5 наскрізних тестів на bufconn | усі PASS |
Тести в [test/contract/contract_test.go](../test/contract/contract_test.go) перевіряють не компіляцію, а поведінку:
| Тест | Що доводить |
|------|-------------|
| `TestControlHandshakeAndTaskPush` | Hello → Welcome → TaskPlan доїжджає в зустрічному напрямку стріму; `params_json` розбирається; модуль виводиться з `check_type`; Ping/Pong міряє clock skew |
| `TestTelemetrySeriesInterning` | Серія реєструється раз, далі розв'язується з номера; ICMP і `util_out_pct` доїжджають без спотворень; економія 66 → 25 байт |
| `TestTelemetryUnknownSeriesRefTriggersReset` | Невідомий `series_ref` дає `reset_series_table` + retryable-помилку, а не тихе відкидання даних |
| `TestConfigUploadChunked` | header → chunk × N → trailer збирається байт-у-байт зі звіркою sha256 |
| `TestConfigUploadRejectsBadChecksum` | Пошкоджений конфіг відхиляється з `checksum_mismatch`, а не комітиться в Git |

View file

@ -0,0 +1,355 @@
// =====================================================================
// NetPulse :: agent.proto
// Головний контракт агентсервер.
//
// ФУНДАМЕНТАЛЬНЕ ОБМЕЖЕННЯ: усі з'єднання ініціює агент.
// Сервер ніколи не стукає в мережу клієнта там немає ані відкритих
// портів, ані прокидання NAT. Тому "команда з сервера" фізично є
// повідомленням у зустрічному напрямку вже відкритого агентом
// bidi-стріму Control.
// =====================================================================
syntax = "proto3";
package netpulse.v1;
import "google/protobuf/duration.proto";
import "google/protobuf/timestamp.proto";
import "netpulse/v1/common.proto";
import "netpulse/v1/discovery.proto";
import "netpulse/v1/logs.proto";
import "netpulse/v1/ncm.proto";
import "netpulse/v1/telemetry.proto";
option go_package = "github.com/netpulse/netpulse/gen/go/netpulse/v1;netpulsev1";
// ---------------------------------------------------------------------
// Реєстрація зонда
//
// Єдиний RPC без клієнтського сертифіката: агент приходить із
// одноразовим enrollment-токеном (згенерованим у UI) і CSR, а йде
// з власним сертифікатом. Далі тільки mTLS.
// ---------------------------------------------------------------------
service EnrollmentService {
rpc Enroll(EnrollRequest) returns (EnrollResponse);
}
message EnrollRequest {
// Одноразовий токен із UI: "np_enroll_...". Згорає після використання.
string enrollment_token = 1;
// PKCS#10. Приватний ключ ніколи не залишає агента.
bytes csr = 2;
string hostname = 3;
AgentBuild build = 4;
// Бажане ім'я зонда; сервер може змінити на унікальне.
string requested_name = 5;
}
message EnrollResponse {
string agent_id = 1;
string agent_name = 2;
// Підписаний клієнтський сертифікат + ланцюг CA сервера.
bytes certificate = 3;
bytes ca_chain = 4;
google.protobuf.Timestamp certificate_expires_at = 5;
// Куди підключатись далі (може відрізнятись від адреси реєстрації:
// балансувальник, регіональний шлюз).
string control_endpoint = 6;
}
// ---------------------------------------------------------------------
// Основний сервіс
// ---------------------------------------------------------------------
service AgentService {
// Довгоживучий двонаправлений канал керування. Одна сесія = одне
// з'єднання. Розрив стріму = кінець сесії з усім її станом
// (таблиця серій, in-flight батчі).
rpc Control(stream ControlUp) returns (stream ControlDown);
// Телеметрія окремим стрімом, щоб пачка на 10 000 семплів
// не блокувала heartbeat і не затримувала команду з сервера.
rpc StreamTelemetry(stream TelemetryBatch) returns (stream TelemetryAck);
// Syslog/трапи теж окремо: сплеск логів під час аварії не має
// топити телеметрію, за якою ця аварія й видно.
rpc StreamLogs(stream LogBatch) returns (stream LogAck);
// Автовиявлення: рідко, великими звітами.
rpc ReportDiscovery(DiscoveryReport) returns (DiscoveryAck);
// Вивантаження зібраного конфігу (чанками).
rpc UploadConfig(stream ConfigUpload) returns (ConfigReceipt);
}
// ---------------------------------------------------------------------
// Агент Сервер
// ---------------------------------------------------------------------
message ControlUp {
// Монотонний номер повідомлення в сесії для трасування.
uint64 seq = 1;
oneof payload {
Hello hello = 2;
Heartbeat heartbeat = 3;
TaskStatusUpdate task_status = 4;
ModuleStatusUpdate module_status = 5;
ConfigApplyResult config_apply_result = 6;
CredentialRequest credential_request = 7;
Pong pong = 8;
AgentEvent event = 9;
}
}
// Перше повідомлення в стрімі. Сервер відповідає Welcome.
message Hello {
string agent_id = 1;
AgentBuild build = 2;
string hostname = 3;
// Локальні адреси зонда корисно для L2-виявлення й діагностики NAT.
repeated string local_addresses = 4;
google.protobuf.Timestamp started_at = 5;
// Хеш конфігурації задач, яку агент має локально. Якщо збігається
// з серверним сервер не шле повний план, лише дельти.
bytes task_plan_hash = 6;
// Останній батч, який агент вважає підтвердженим. Дозволяє
// продовжити з місця розриву замість повного перезливу.
uint64 last_acked_batch_id = 7;
}
message Heartbeat {
google.protobuf.Timestamp ts = 1;
AgentHealth health = 2;
// Скільки задач зараз виконується / чекає в черзі.
uint32 tasks_running = 3;
uint32 tasks_queued = 4;
}
// Життєвий цикл задачі. Сервер оновлює core.checks.last_run_at/last_error.
message TaskStatusUpdate {
string check_id = 1;
enum State {
STATE_UNSPECIFIED = 0;
STATE_ACCEPTED = 1;
STATE_RUNNING = 2;
STATE_SUCCEEDED = 3;
STATE_FAILED = 4;
STATE_SKIPPED = 5; // не встиг у вікно інтервалу
STATE_REJECTED = 6; // модуль не активний або параметри невалідні
}
State state = 2;
google.protobuf.Timestamp ts = 3;
Error error = 4;
}
message ModuleStatusUpdate {
string module_key = 1;
bool active = 2;
string version = 3;
Error error = 4;
}
// Агент просить креденшели: або вперше, або бо старі протермінувались.
message CredentialRequest {
repeated string device_ids = 1;
string reason = 2; // "expired" | "missing" | "auth_failed"
}
message Pong {
uint64 ping_id = 1;
google.protobuf.Timestamp agent_time = 2;
}
// Позапланова подія самого зонда (не пристрою).
message AgentEvent {
enum Kind {
KIND_UNSPECIFIED = 0;
KIND_STARTED = 1;
KIND_STOPPING = 2;
KIND_MODULE_CRASH = 3;
KIND_BUFFER_OVERFLOW = 4;
KIND_CLOCK_JUMP = 5;
KIND_UPDATE_APPLIED = 6;
KIND_CONFIG_REJECTED = 7;
}
Kind kind = 1;
google.protobuf.Timestamp ts = 2;
string message = 3;
map<string, string> details = 4;
}
// ---------------------------------------------------------------------
// Сервер Агент
// ---------------------------------------------------------------------
message ControlDown {
uint64 seq = 1;
oneof payload {
Welcome welcome = 2;
TaskPlan task_plan = 3;
TaskDelta task_delta = 4;
ModuleControl module_control = 5;
CredentialBundle credentials = 6;
ConfigJob config_job = 7;
ConfigApplyJob config_apply_job = 8;
DiscoveryRequest discovery_request = 9;
Ping ping = 10;
Directive directive = 11;
}
}
message Welcome {
string session_id = 1;
// Час сервера агент рахує з нього clock_skew. Мітки часу в
// телеметрії лишаються агентськими, але сервер знає поправку.
google.protobuf.Timestamp server_time = 2;
google.protobuf.Duration heartbeat_interval = 3;
// Параметри батчингу телеметрії. Сервер диктує їх на льоту
// так можна пригальмувати балакучого агента без оновлення бінарника.
uint32 telemetry_max_batch_size = 4;
google.protobuf.Duration telemetry_max_batch_interval = 5;
uint32 telemetry_max_in_flight = 6;
// Скільки чеків агенту дозволено виконувати паралельно.
uint32 max_concurrent_checks = 7;
// Ліміт ICMP, щоб зонд не виглядав як сканер для IDS клієнта.
uint32 icmp_rate_pps = 8;
// Повний план задач надійде окремим TaskPlan, якщо хеш не збігся.
bool task_plan_follows = 9;
}
// Повна синхронізація: те, що агент має виконувати. Заміщає все.
message TaskPlan {
bytes plan_hash = 1;
repeated Task tasks = 2;
repeated DeviceTarget devices = 3;
// План може прийти частинами; агент застосовує його атомарно
// лише після final = true.
bool final = 4;
uint32 part = 5;
}
// Інкрементальна зміна плану типовий випадок після додавання
// одного пристрою в UI. Перезаливати 50 000 задач заради цього не треба.
message TaskDelta {
bytes plan_hash = 1; // хеш плану ПІСЛЯ застосування дельти
repeated Task upsert = 2;
repeated string remove_check_ids = 3;
repeated DeviceTarget upsert_devices = 4;
repeated string remove_device_ids = 5;
}
// Одна задача опитування. Дзеркалить core.checks.
message Task {
string check_id = 1;
string device_id = 2;
// Заповнюється для чеків рівня інтерфейсу.
string interface_id = 3;
// "<plugin>.<check>": icmp.ping, snmp.if, http.status, ncm.backup.
// Модуль-виконавець визначається префіксом до крапки.
string check_type = 4;
// Параметри чека непрозорий для ядра JSON. Валідність гарантує
// params_schema з core.check_types; розбирає їх сам модуль.
// Так новий плагін не потребує зміни .proto.
bytes params_json = 5;
google.protobuf.Duration interval = 6;
google.protobuf.Duration timeout = 7;
uint32 retries = 8;
bool enabled = 9;
// Зсув у межах інтервалу, щоб 5000 чеків не стартували одночасно.
// Розраховує сервер детерміновано від check_id, щоб зберігався
// між перезапусками.
google.protobuf.Duration schedule_offset = 10;
string credential_id = 11;
}
// Активація/деактивація модулів на зонді.
message ModuleControl {
repeated ModuleSpec modules = 1;
// Модулі, відсутні в списку, вимкнути.
bool exclusive = 2;
}
message ModuleSpec {
string key = 1; // icmp, snmp, topology, ncm, modbus
bool enabled = 2;
string min_version = 3;
// Налаштування модуля (JSON), напр. розмір SNMP-пулу.
bytes config_json = 4;
}
message CredentialBundle {
// device_id креденшели, які до нього застосовні, за пріоритетом.
map<string, CredentialList> by_device = 1;
// Спільний строк придатності комплекту.
google.protobuf.Timestamp expires_at = 2;
}
message CredentialList {
repeated Credential credentials = 1;
}
message DiscoveryRequest {
string run_id = 1;
repeated DiscoveryProto protocols = 2;
// Пристрої, які опитати на предмет сусідів.
repeated string device_ids = 3;
// Підмережі для сканування (CIDR). Порожньо не сканувати.
repeated string subnets = 4;
// Обмеження темпу сканування, щоб не збурювати мережу клієнта.
uint32 scan_rate_pps = 5;
google.protobuf.Duration timeout = 6;
}
message Ping {
uint64 ping_id = 1;
google.protobuf.Timestamp server_time = 2;
}
// Команди життєвого циклу самого агента.
message Directive {
enum Action {
ACTION_UNSPECIFIED = 0;
// Перечитати конфігурацію, не розриваючи сесію.
ACTION_RELOAD = 1;
// Дозбирати й відправити буфер, потім коректно завершитись.
ACTION_DRAIN = 2;
// Зупинити опитування, лишити канал (несплата grace-період).
ACTION_PAUSE = 3;
ACTION_RESUME = 4;
// Доступне оновлення: деталі в update.
ACTION_UPDATE = 5;
// Перепідключитись (перебалансування сервера). Агент чекає
// reconnect_after і йде на новий endpoint.
ACTION_RECONNECT = 6;
// Скинути локальну таблицю series_ref і перереєструвати серії.
ACTION_RESET_SERIES_TABLE = 7;
}
Action action = 1;
string reason = 2;
google.protobuf.Duration reconnect_after = 3;
string new_endpoint = 4;
UpdateInfo update = 5;
}
message UpdateInfo {
string version = 1;
string download_url = 2;
// sha256 бінарника агент зобов'язаний звірити перед запуском.
bytes sha256 = 3;
// Підпис постачальника (Ed25519) над sha256. Без валідного підпису
// оновлення не застосовується: інакше компрометація CDN
// перетворюється на RCE в мережі кожного клієнта.
bytes signature = 4;
bool mandatory = 5;
}

View file

@ -0,0 +1,153 @@
// =====================================================================
// NetPulse :: common.proto
// Спільні типи для всіх сервісів контракту агентсервер.
// =====================================================================
syntax = "proto3";
package netpulse.v1;
import "google/protobuf/duration.proto";
import "google/protobuf/timestamp.proto";
option go_package = "github.com/netpulse/netpulse/gen/go/netpulse/v1;netpulsev1";
// ---------------------------------------------------------------------
// Ідентифікатори
//
// UUID передаються рядком у канонічному вигляді (36 байт). Це дорожче за
// bytes(16), але контрольний канал низькочастотний, а логи й трейси
// читабельні. Гарячий шлях телеметрії натомість використовує числові
// series_ref (див. telemetry.proto) саме там економія має значення.
// ---------------------------------------------------------------------
// Статус об'єкта. Дзеркалить inv.device_status у БД.
enum Status {
STATUS_UNSPECIFIED = 0;
STATUS_UP = 1;
STATUS_DOWN = 2;
STATUS_WARNING = 3;
STATUS_UNKNOWN = 4;
STATUS_MAINTENANCE = 5;
}
// Транспорт підключення до пристрою. Дзеркалить inv.credential_proto.
enum Transport {
TRANSPORT_UNSPECIFIED = 0;
TRANSPORT_SSH = 1;
TRANSPORT_TELNET = 2;
TRANSPORT_SNMP_V2C = 3;
TRANSPORT_SNMP_V3 = 4;
TRANSPORT_HTTP = 5;
TRANSPORT_HTTPS = 6;
TRANSPORT_API = 7;
TRANSPORT_MODBUS = 8;
}
// Протокол автовиявлення сусідів. Дзеркалить topo.discovery_proto.
enum DiscoveryProto {
DISCOVERY_PROTO_UNSPECIFIED = 0;
DISCOVERY_PROTO_LLDP = 1;
DISCOVERY_PROTO_CDP = 2;
DISCOVERY_PROTO_ARP = 3;
DISCOVERY_PROTO_FDB = 4;
DISCOVERY_PROTO_STP = 5;
DISCOVERY_PROTO_ROUTING = 6;
DISCOVERY_PROTO_SNMP_TOPO = 7;
}
// Помилка виконання. Свідомо не gRPC-статус: одна задача може впасти,
// поки решта потоку жива, тому помилки їдуть у корисному навантаженні.
message Error {
// Стабільний машинний код: "timeout", "auth_failed", "unreachable",
// "snmp_no_such_object", "prompt_mismatch", "permission_denied".
string code = 1;
string message = 2;
// Чи має сенс повторювати. Сервер вирішує, чи планувати retry.
bool retryable = 3;
map<string, string> details = 4;
}
// Мінімальний опис пристрою, потрібний агенту для опитування.
// Агент не має жодного уявлення про тенанти, тарифи чи мапи
// це навмисно: зонд знає лише "куди йти і як".
message DeviceTarget {
string device_id = 1;
string name = 2;
// Адреса опитування: IP або FQDN.
string address = 3;
// Ідентифікатори для кореляції автовиявлення (topo.neighbors).
string chassis_id = 4;
string system_name = 5;
}
// Облікові дані. Приходять із сервера вже РОЗШИФРОВАНИМИ.
//
// БЕЗПЕКА:
// * передаються виключно всередині mTLS-каналу;
// * агент тримає їх лише в пам'яті, ніколи не пише на диск і не логує;
// * мають TTL (expires_at) після спливу агент зобов'язаний запитати
// новий комплект, а старий занулити.
message Credential {
string credential_id = 1;
Transport transport = 2;
string username = 3;
uint32 port = 4;
oneof secret {
string password = 5;
bytes private_key = 6;
string community = 7; // SNMP v2c
string token = 8; // HTTP/API
}
// Enable/privileged-пароль (Cisco-подібні).
string enable_password = 9;
// Параметри SNMPv3.
SnmpV3Options snmp_v3 = 10;
google.protobuf.Timestamp expires_at = 11;
}
message SnmpV3Options {
enum SecurityLevel {
SECURITY_LEVEL_UNSPECIFIED = 0;
SECURITY_LEVEL_NO_AUTH_NO_PRIV = 1;
SECURITY_LEVEL_AUTH_NO_PRIV = 2;
SECURITY_LEVEL_AUTH_PRIV = 3;
}
SecurityLevel level = 1;
string auth_protocol = 2; // MD5 | SHA | SHA224 | SHA256 | SHA384 | SHA512
string auth_password = 3;
string priv_protocol = 4; // DES | AES | AES192 | AES256
string priv_password = 5;
string context_name = 6;
string security_name = 7;
}
// Опис версії агента для сумісності й самооновлення.
message AgentBuild {
string version = 1; // 1.4.2
string commit = 2;
string os = 3; // linux | windows | darwin
string arch = 4; // amd64 | arm64
string go_version = 5;
// Модулі, вкомпільовані в цей бінарник (не обов'язково активні).
repeated string compiled_modules = 6;
}
// Самометрики агента. Лягають у ts.agent_health.
message AgentHealth {
float cpu_pct = 1;
uint64 rss_bytes = 2; // цільовий бюджет: < 30 МБ
uint32 goroutines = 3;
uint32 queue_depth = 4; // скільки результатів чекає на відправку
float checks_per_sec = 5;
float errors_per_min = 6;
// Скільки семплів довелося викинути через переповнення буфера.
// Ненульове значення привід підняти алерт на самого агента.
uint64 dropped_samples = 7;
google.protobuf.Duration uptime = 8;
// Розсинхронізація годинника з сервером; агент рахує її з Welcome.server_time.
google.protobuf.Duration clock_skew = 9;
}

View file

@ -0,0 +1,124 @@
// =====================================================================
// NetPulse :: discovery.proto
// Автовиявлення сусідів (LLDP/CDP/ARP/FDB) та інвентаризація інтерфейсів.
//
// Агент НЕ вирішує, хто з ким з'єднаний. Він доповідає сире:
// "на порту X я бачу chassis-id Y, port-id Z". Резолвер на сервері
// зводить це в topo.links і виставляє confidence. Так автовиявлення
// лишається відтворюваним і не залежить від версії агента.
// =====================================================================
syntax = "proto3";
package netpulse.v1;
import "google/protobuf/timestamp.proto";
import "netpulse/v1/common.proto";
option go_package = "github.com/netpulse/netpulse/gen/go/netpulse/v1;netpulsev1";
// ---------------------------------------------------------------------
// Сусід, побачений з конкретного порту. topo.neighbors
// ---------------------------------------------------------------------
message NeighborRecord {
// Пристрій, який доповідає (той, що ми опитали).
string device_id = 1;
// Локальний порт. Якщо агент ще не знає interface_id заповнює
// local_if_index/local_port_name, і сервер резолвить сам.
string local_interface_id = 2;
int64 local_if_index = 3;
string local_port_name = 4;
DiscoveryProto proto = 5;
// Те, що видно з іншого боку. Порожні поля норма: CDP не дає
// chassis_id у форматі LLDP, ARP не дає port_id взагалі.
string remote_chassis_id = 6;
string remote_system_name = 7;
string remote_port_id = 8;
string remote_port_descr = 9;
string remote_mgmt_ip = 10;
string remote_mac = 11;
string remote_platform = 12;
repeated string remote_capabilities = 13; // bridge, router, wlan-ap
google.protobuf.Timestamp seen_at = 14;
// Сирий запис як його віддав пристрій для розбору спірних випадків.
bytes raw_json = 15;
}
// ---------------------------------------------------------------------
// Інвентаризація інтерфейсів. inv.interfaces (upsert за ifIndex)
// Йде разом із discovery, бо обидва беруться з одного SNMP-walk.
// ---------------------------------------------------------------------
message InterfaceRecord {
string device_id = 1;
int64 if_index = 2;
string name = 3;
string alias = 4;
string mac = 5;
uint32 mtu = 6;
string type = 7; // ethernetCsmacd, ieee8023adLag, l3ipvlan
uint64 speed_bps = 8;
string duplex = 9;
string admin_status = 10; // up | down | testing | unknown
string oper_status = 11;
int64 parent_if_index = 12; // для членів LAG
repeated string ip_addresses = 13; // CIDR: 10.0.0.1/24
}
// ---------------------------------------------------------------------
// Знайдений, але ще не заведений пристрій (сканування підмережі).
// Сервер вирішує, чи створювати його в inv.devices це впирається
// в ліміт тарифу, тому рішення не може бути на агенті.
// ---------------------------------------------------------------------
message DiscoveredDevice {
string address = 1;
string hostname = 2;
string mac = 3;
string sys_name = 4;
string sys_descr = 5;
string sys_object_id = 6;
string vendor = 7;
string model = 8;
// Які транспорти відповіли під час сканування.
repeated Transport reachable_via = 9;
// Здогад агента про тип пристрою сервер може перевизначити.
string guessed_kind = 10;
google.protobuf.Timestamp seen_at = 11;
}
// ---------------------------------------------------------------------
// Звіт про запуск автовиявлення topo.discovery_runs
// ---------------------------------------------------------------------
message DiscoveryReport {
string agent_id = 1;
// Порожній, якщо це фонове періодичне виявлення, а не запуск із UI.
string run_id = 2;
repeated NeighborRecord neighbors = 3;
repeated InterfaceRecord interfaces = 4;
repeated DiscoveredDevice devices = 5;
// Звіт розбитий на частини: сервер має чекати final = true,
// перш ніж закривати run і чистити застарілих сусідів.
bool final = 6;
uint32 part = 7;
google.protobuf.Timestamp started_at = 8;
google.protobuf.Timestamp finished_at = 9;
repeated Error errors = 10;
}
message DiscoveryAck {
bool accepted = 1;
// Скільки записів сервер зміг зіставити з інвентарем.
uint32 neighbors_resolved = 2;
uint32 links_created = 3;
uint32 devices_created = 4;
Error error = 5;
}

View file

@ -0,0 +1,66 @@
// =====================================================================
// NetPulse :: logs.proto
// Syslog та SNMP-трапи, зібрані агентом. ts.syslog / ts.snmp_traps
//
// Агент слухає 514/udp і 162/udp у мережі клієнта й тунелює події
// назовні. Це важливо не лише для журналу: подія "%SYS-5-CONFIG_I"
// тригерить позачерговий бекап конфігу (ncm.device_policies.on_syslog).
// =====================================================================
syntax = "proto3";
package netpulse.v1;
import "google/protobuf/timestamp.proto";
import "netpulse/v1/common.proto";
option go_package = "github.com/netpulse/netpulse/gen/go/netpulse/v1;netpulsev1";
message SyslogEntry {
google.protobuf.Timestamp ts = 1;
// Резолвиться агентом за source_ip; порожній, якщо пристрій невідомий.
string device_id = 2;
string source_ip = 3;
uint32 facility = 4;
uint32 severity = 5; // 0 emerg .. 7 debug
string hostname = 6;
string tag = 7;
string message = 8;
// Розібрані поля, якщо агент упізнав формат (RFC5424 structured data).
map<string, string> parsed = 9;
}
message SnmpTrap {
google.protobuf.Timestamp ts = 1;
string device_id = 2;
string source_ip = 3;
string trap_oid = 4;
repeated VarBind varbinds = 5;
// v2c community або v3 security name сервер перевіряє довіру джерела.
string auth_context = 6;
}
message VarBind {
string oid = 1;
string type = 2; // INTEGER, OCTET STRING, Counter64, ...
string value = 3;
}
message LogBatch {
uint64 batch_id = 1;
string agent_id = 2;
repeated SyslogEntry syslog = 3;
repeated SnmpTrap traps = 4;
// Скільки подій агент відкинув через переповнення (rate limit).
// Ненульове сигнал, що клієнт шумить або ліміт замалий.
uint64 dropped = 5;
}
message LogAck {
uint64 acked_through_batch_id = 1;
// Сервер просить фільтрувати шум на агенті: не слати нижче цієї severity.
uint32 min_severity = 2;
// Ліміт подій за секунду з одного джерела.
uint32 rate_limit_per_source = 3;
Error error = 4;
}

164
proto/netpulse/v1/ncm.proto Normal file
View file

@ -0,0 +1,164 @@
// =====================================================================
// NetPulse :: ncm.proto
// Збір конфігурацій (SSH/Telnet/API) та застосування відкату.
//
// Розподіл відповідальності:
// Агент відкриває сесію, виконує команди, віддає сирий текст.
// Сервер scrub/redact, хешування, коміт у Git, diff, compliance.
//
// Це навмисно: правила очищення живуть у ncm.profiles і мають
// змінюватись без оновлення агентів у полі.
// =====================================================================
syntax = "proto3";
package netpulse.v1;
import "google/protobuf/duration.proto";
import "google/protobuf/timestamp.proto";
import "netpulse/v1/common.proto";
option go_package = "github.com/netpulse/netpulse/gen/go/netpulse/v1;netpulsev1";
// ---------------------------------------------------------------------
// Завдання на збір конфігу (сервер агент)
// ---------------------------------------------------------------------
message ConfigJob {
string job_id = 1;
DeviceTarget device = 2;
Credential credential = 3;
Transport transport = 4;
uint32 port = 5;
// Команди по черзі: ["terminal length 0", "show running-config"].
// Вивід останньої команди вважається тілом конфігу; вивід попередніх
// (підготовчих) відкидається.
repeated string commands = 6;
// Регекс запрошення командного рядка за ним агент розуміє,
// що команда відпрацювала.
string prompt_regex = 7;
// Потрібен enable/privileged режим перед виконанням.
bool enable_required = 8;
string enable_prompt_regex = 9;
// running | startup | vlan | license | inventory
string config_type = 10;
google.protobuf.Duration timeout = 11;
// Ліміт розміру: захист від пристрою, що віддає гігабайт сміття.
uint64 max_bytes = 12;
// Записувати повний транскрипт сесії (для діагностики prompt_regex).
bool capture_transcript = 13;
}
// ---------------------------------------------------------------------
// Вивантаження конфігу (агент сервер), потоком
//
// Порядок повідомлень: header chunk* trailer.
// Тіло конфігу може бути кілька МБ, тому чанками, а не одним blob.
// ---------------------------------------------------------------------
message ConfigUpload {
oneof part {
ConfigHeader header = 1;
ConfigChunk chunk = 2;
ConfigTrailer trailer = 3;
}
}
message ConfigHeader {
string job_id = 1;
string agent_id = 2;
string device_id = 3;
string config_type = 4;
google.protobuf.Timestamp collected_at = 5;
// gzip | none агент стискає, бо конфіги добре жмуться,
// а канал може бути вузьким.
string encoding = 6;
}
message ConfigChunk {
// Номер чанка з 0; сервер збирає в порядку зростання.
uint32 sequence = 1;
bytes data = 2;
}
message ConfigTrailer {
bool success = 1;
Error error = 2;
// sha256 ПОВНОГО тіла ДО стиснення. Сервер звіряє й лише потім комітить.
bytes content_sha256 = 3;
uint64 size_bytes = 4;
uint32 line_count = 5;
uint32 chunk_count = 6;
google.protobuf.Duration duration = 7;
// Транскрипт сесії, якщо capture_transcript = true.
string transcript = 8;
}
message ConfigReceipt {
string job_id = 1;
bool accepted = 2;
// Сервер порівняв content_hash із попередньою версією: конфіг не змінився,
// нового коміту не буде. Агенту корисно знати для локальної статистики.
bool unchanged = 3;
// Заповнюється, якщо коміт відбувся.
string commit_sha = 4;
string config_id = 5;
Error error = 6;
}
// ---------------------------------------------------------------------
// Застосування конфігурації / відкат (сервер агент)
//
// НЕБЕЗПЕЧНА операція. Агент виконує її беззастережно, бо вся логіка
// погодження (ncm.rollbacks: draft awaiting_approval approved)
// лишається на сервері. Агент лише перевіряє, що завдання прийшло
// у межах живої автентифікованої сесії.
// ---------------------------------------------------------------------
message ConfigApplyJob {
string rollback_id = 1;
DeviceTarget device = 2;
Credential credential = 3;
Transport transport = 4;
// Рядки конфігу, які треба виконати послідовно.
repeated string commands = 5;
string prompt_regex = 6;
bool enable_required = 7;
// Команда збереження після успіху ("write memory", "/system backup save").
string commit_command = 8;
// Якщо пристрій підтримує confirmed commit відкотиться сам,
// коли агент не підтвердить у цей строк. Найкращий захист від
// втрати керування після помилкового правила фаєрвола.
google.protobuf.Duration confirm_timeout = 9;
// Зупинитись на першій помилці (за замовчуванням) чи виконати все.
bool continue_on_error = 10;
google.protobuf.Duration timeout = 11;
}
message ConfigApplyResult {
string rollback_id = 1;
bool success = 2;
// Результат кожної команди саме тут видно, на якій усе стало.
repeated CommandOutcome outcomes = 3;
bool committed = 4;
string transcript = 5;
Error error = 6;
google.protobuf.Duration duration = 7;
}
message CommandOutcome {
uint32 index = 1;
string command = 2;
string output = 3;
bool success = 4;
string error_line = 5;
}

View file

@ -0,0 +1,213 @@
// =====================================================================
// NetPulse :: telemetry.proto
// Гарячий шлях: метрики від агента до сервера.
//
// Три типи навантаження, свідомо різні:
// 1. IcmpResult ts.icmp_samples (широка таблиця, колір вузла)
// 2. InterfaceCounters ts.if_counters (широка таблиця, анімація лінії)
// 3. MetricSample ts.series/samples (узагальнена, будь-який плагін)
//
// Перші два денормалізовані, бо їх читає мапа на кожному тику WebSocket.
// Третій розширюваний без зміни контракту: плагін реєструє свій
// metric_key і працює.
// =====================================================================
syntax = "proto3";
package netpulse.v1;
import "google/protobuf/duration.proto";
import "google/protobuf/timestamp.proto";
import "netpulse/v1/common.proto";
option go_package = "github.com/netpulse/netpulse/gen/go/netpulse/v1;netpulsev1";
// ---------------------------------------------------------------------
// Інтернування серій
//
// Проблема: у кожному семплі повторювати device_id (36 байт) + metric_key
// + labels це десятки байт службових даних на одне число float64.
// При 50 000 пристроїв × метрика/хв це помітний трафік.
//
// Рішення: агент один раз реєструє серію під локальним номером
// (series_ref, унікальний у межах СЕСІЇ), далі шле лише номер + значення.
// Сервер тримає мапу series_ref ts.series.id на час сесії.
//
// Після реконекту таблиця недійсна: агент починає нумерацію з 1 і
// реєструє все заново. Сервер може примусово скинути її директивою
// RESET_SERIES_TABLE, якщо втратив стан.
// ---------------------------------------------------------------------
message SeriesDescriptor {
// Локальний номер у межах сесії. Нумерація з 1; 0 недійсний.
uint32 series_ref = 1;
string device_id = 2;
// Заповнюється для метрик рівня інтерфейсу.
string interface_id = 3;
// Плагін-джерело: snmp, modbus, http...
string plugin_key = 4;
// cpu.util, mem.used, ups.battery.pct, sensor.temp
string metric_key = 5;
// pct, bps, C, V, ms
string unit = 6;
// Додаткові виміри: {"core":"0","phase":"L1"}
map<string, string> labels = 7;
}
// Один вимір узагальненої метрики.
message MetricSample {
uint32 series_ref = 1;
google.protobuf.Timestamp ts = 2;
double value = 3;
}
// ---------------------------------------------------------------------
// ICMP ts.icmp_samples
// ---------------------------------------------------------------------
message IcmpResult {
string device_id = 1;
string check_id = 2;
google.protobuf.Timestamp ts = 3;
float rtt_avg_ms = 4;
float rtt_min_ms = 5;
float rtt_max_ms = 6;
float jitter_ms = 7;
float loss_pct = 8;
uint32 packets_sent = 9;
uint32 packets_recv = 10;
bool reachable = 11;
}
// ---------------------------------------------------------------------
// Лічильники інтерфейсів ts.if_counters
//
// Агент шле І сирі 64-бітні лічильники, І вже пораховані швидкості.
// Сирі потрібні, щоб сервер міг перерахувати заднім числом; швидкості
// рахує агент, бо лише він знає точний інтервал між двома опитуваннями
// (мережева затримка робить серверний розрахунок неточним).
//
// Обробку wrap/reset лічильника робить агент: якщо пристрій
// перезавантажився, counter_reset = true, і швидкості не заповнюються.
// ---------------------------------------------------------------------
message InterfaceCounters {
string device_id = 1;
string interface_id = 2;
google.protobuf.Timestamp ts = 3;
uint64 in_octets = 4;
uint64 out_octets = 5;
uint64 in_ucast_pkts = 6;
uint64 out_ucast_pkts = 7;
uint64 in_errors = 8;
uint64 out_errors = 9;
uint64 in_discards = 10;
uint64 out_discards = 11;
// Похідні швидкості за фактичний інтервал між опитуваннями.
double in_bps = 12;
double out_bps = 13;
double in_pps = 14;
double out_pps = 15;
// % від номінальної швидкості порту джерело анімації трафіку на мапі.
float util_in_pct = 16;
float util_out_pct = 17;
bool oper_up = 18;
bool admin_up = 19;
// Лічильник обнулився (reboot/wrap) швидкості цього разу недійсні.
bool counter_reset = 20;
// Фактичний інтервал, за який пораховані швидкості.
google.protobuf.Duration interval = 21;
}
// ---------------------------------------------------------------------
// Зміна стану пристрою ts.device_status_history + подія на мапу
// Надсилається одразу при переході, не чекаючи наступного батчу.
// ---------------------------------------------------------------------
message StatusChange {
string device_id = 1;
google.protobuf.Timestamp ts = 2;
Status status = 3;
Status previous_status = 4;
string reason = 5;
}
// ---------------------------------------------------------------------
// Результат довільного чека плагіна.
// Метрики вже розкладені у samples; сюди йде лише службова частина.
// ---------------------------------------------------------------------
message CheckResult {
string check_id = 1;
string device_id = 2;
string check_type = 3; // icmp.ping, snmp.if, http.status
google.protobuf.Timestamp ts = 4;
google.protobuf.Duration duration = 5;
bool success = 6;
Error error = 7;
// Довільне корисне навантаження плагіна (JSON), яке не є метрикою:
// напр. http.status {"status_code":200,"redirects":1}.
bytes payload_json = 8;
}
// ---------------------------------------------------------------------
// Батч
//
// Агент накопичує результати й шле пачками. Розмір і період батчу
// диктує сервер у Welcome так можна на льоту пригальмувати
// балакучого агента, не оновлюючи бінарник.
// ---------------------------------------------------------------------
message TelemetryBatch {
// Монотонний номер батчу в межах сесії. Основа для ack і ретраїв.
uint64 batch_id = 1;
string agent_id = 2;
// Час формування батчу на агенті (не час вимірів усередині).
google.protobuf.Timestamp created_at = 3;
// Нові серії, які вперше зустрічаються в цій сесії.
// Мають бути в тому ж батчі, що й перші семпли, які на них посилаються.
repeated SeriesDescriptor new_series = 4;
repeated MetricSample samples = 5;
repeated IcmpResult icmp = 6;
repeated InterfaceCounters interfaces = 7;
repeated StatusChange status_changes = 8;
repeated CheckResult check_results = 9;
// Батч повтор раніше не підтвердженого (після реконекту).
// Сервер робить upsert: PK (ts, device_id) робить це безпечним.
bool is_retransmit = 10;
}
// ---------------------------------------------------------------------
// Підтвердження + зворотний тиск
//
// Семантика доставки at-least-once. Дедуплікацію забезпечує сама
// схема: PK (ts, device_id) / (ts, interface_id) / (ts, series_id),
// тому повторний батч не псує дані.
// ---------------------------------------------------------------------
message TelemetryAck {
// Усі батчі з номером <= цього прийняті й записані.
uint64 acked_through_batch_id = 1;
// Батчі, які треба переслати (напр. частковий збій запису).
repeated uint64 nack_batch_ids = 2;
// Скільки неacknowledged батчів агенту дозволено тримати в польоті.
uint32 max_in_flight = 3;
// Сервер під навантаженням: пригальмувати на цей час.
google.protobuf.Duration retry_after = 4;
// Сервер втратив мапу series_ref id. Агент має обнулити локальну
// таблицю й перереєструвати всі серії наступним батчем.
bool reset_series_table = 5;
Error error = 6;
}

View file

@ -0,0 +1,805 @@
// =====================================================================
// NetPulse :: contract_test.go
//
// Наскрізна перевірка контракту агент↔сервер на справжньому gRPC
// (bufconn, без мережі). Мета — не покрити код тестами, а довести,
// що модель із .proto реально працює:
//
// 1. Рукостискання: агент відкриває стрім, шле Hello, отримує Welcome.
// 2. Сервер віддає план задач у зустрічному напрямку вже відкритого
// агентом стріму — тобто "команда з сервера" працює без жодного
// вхідного з'єднання в мережу клієнта.
// 3. Інтернування серій: агент реєструє серію один раз, далі шле
// лише номер; сервер розв'язує його назад у метрику.
// 4. Зворотний тиск: сервер підтверджує батчі й диктує max_in_flight.
// 5. Ping/Pong для вимірювання розсинхронізації годинника.
// 6. Вивантаження конфігу чанками зі звіркою sha256.
//
// Запуск: go test ./test/contract/... -v
// =====================================================================
package contract_test
import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"strings"
"testing"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/test/bufconn"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/durationpb"
"google.golang.org/protobuf/types/known/timestamppb"
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
)
// ---------------------------------------------------------------------
// Мінімальна реалізація серверного боку
// ---------------------------------------------------------------------
// resolvedSample — семпл, уже розв'язаний із series_ref у повний опис.
// Саме це сервер записав би в ts.series + ts.samples.
type resolvedSample struct {
deviceID string
metricKey string
unit string
labels map[string]string
value float64
}
type storedConfig struct {
jobID string
body []byte
}
type testServer struct {
npv1.UnimplementedAgentServiceServer
hello chan *npv1.Hello
pong chan *npv1.Pong
samples chan resolvedSample
icmp chan *npv1.IcmpResult
ifc chan *npv1.InterfaceCounters
acks chan uint64
config chan storedConfig
planSent chan struct{}
}
func newTestServer() *testServer {
return &testServer{
hello: make(chan *npv1.Hello, 1),
pong: make(chan *npv1.Pong, 1),
samples: make(chan resolvedSample, 16),
icmp: make(chan *npv1.IcmpResult, 16),
ifc: make(chan *npv1.InterfaceCounters, 16),
acks: make(chan uint64, 16),
config: make(chan storedConfig, 1),
planSent: make(chan struct{}, 1),
}
}
func (s *testServer) Control(stream npv1.AgentService_ControlServer) error {
// Перше повідомлення зобов'язане бути Hello — інакше сесії немає.
first, err := stream.Recv()
if err != nil {
return err
}
hello := first.GetHello()
if hello == nil {
return errors.New("перше повідомлення в Control має бути Hello")
}
s.hello <- hello
if err := stream.Send(&npv1.ControlDown{
Seq: 1,
Payload: &npv1.ControlDown_Welcome{Welcome: &npv1.Welcome{
SessionId: "sess-0001",
ServerTime: timestamppb.Now(),
HeartbeatInterval: durationpb.New(30 * time.Second),
TelemetryMaxBatchSize: 500,
TelemetryMaxBatchInterval: durationpb.New(5 * time.Second),
TelemetryMaxInFlight: 4,
MaxConcurrentChecks: 256,
IcmpRatePps: 500,
TaskPlanFollows: true,
}},
}); err != nil {
return err
}
// Параметри чека — непрозорий JSON: ядро їх не тлумачить.
params, err := json.Marshal(map[string]any{"count": 3, "packet_size": 56})
if err != nil {
return err
}
if err := stream.Send(&npv1.ControlDown{
Seq: 2,
Payload: &npv1.ControlDown_TaskPlan{TaskPlan: &npv1.TaskPlan{
PlanHash: []byte("plan-v1"),
Devices: []*npv1.DeviceTarget{{
DeviceId: "dev-1", Name: "core-sw-01", Address: "10.0.0.1",
}},
Tasks: []*npv1.Task{{
CheckId: "chk-1",
DeviceId: "dev-1",
CheckType: "icmp.ping",
ParamsJson: params,
Interval: durationpb.New(60 * time.Second),
Timeout: durationpb.New(3 * time.Second),
Retries: 2,
Enabled: true,
ScheduleOffset: durationpb.New(7 * time.Second),
}},
Final: true,
}},
}); err != nil {
return err
}
s.planSent <- struct{}{}
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:
// На heartbeat відповідаємо Ping — так міряється clock skew.
if err := stream.Send(&npv1.ControlDown{
Seq: 3,
Payload: &npv1.ControlDown_Ping{Ping: &npv1.Ping{
PingId: 42, ServerTime: timestamppb.Now(),
}},
}); err != nil {
return err
}
case *npv1.ControlUp_Pong:
s.pong <- p.Pong
}
}
}
func (s *testServer) StreamTelemetry(stream npv1.AgentService_StreamTelemetryServer) error {
// Таблиця серій жива рівно стільки, скільки сесія. Після реконекту
// агент нумерує заново — тому вона локальна для цього виклику.
table := make(map[uint32]*npv1.SeriesDescriptor)
for {
batch, err := stream.Recv()
if errors.Is(err, io.EOF) {
return nil
}
if err != nil {
return err
}
for _, d := range batch.NewSeries {
table[d.SeriesRef] = d
}
for _, smp := range batch.Samples {
d, ok := table[smp.SeriesRef]
if !ok {
// Ми загубили стан — чесно просимо перереєструвати все,
// замість тихо викидати дані.
return stream.Send(&npv1.TelemetryAck{
AckedThroughBatchId: batch.BatchId - 1,
ResetSeriesTable: true,
Error: &npv1.Error{
Code: "unknown_series_ref",
Message: fmt.Sprintf("series_ref %d не зареєстровано", smp.SeriesRef),
Retryable: true,
},
})
}
s.samples <- resolvedSample{
deviceID: d.DeviceId,
metricKey: d.MetricKey,
unit: d.Unit,
labels: d.Labels,
value: smp.Value,
}
}
for _, r := range batch.Icmp {
s.icmp <- r
}
for _, r := range batch.Interfaces {
s.ifc <- r
}
if err := stream.Send(&npv1.TelemetryAck{
AckedThroughBatchId: batch.BatchId,
MaxInFlight: 4,
}); err != nil {
return err
}
s.acks <- batch.BatchId
}
}
func (s *testServer) UploadConfig(stream npv1.AgentService_UploadConfigServer) error {
var hdr *npv1.ConfigHeader
var body []byte
var wantSeq uint32
for {
msg, err := stream.Recv()
if errors.Is(err, io.EOF) {
return errors.New("стрім завершився без trailer")
}
if err != nil {
return err
}
switch p := msg.Part.(type) {
case *npv1.ConfigUpload_Header:
hdr = p.Header
case *npv1.ConfigUpload_Chunk:
if hdr == nil {
return errors.New("chunk прийшов раніше за header")
}
if p.Chunk.Sequence != wantSeq {
return fmt.Errorf("чанки не по порядку: чекали %d, отримали %d",
wantSeq, p.Chunk.Sequence)
}
wantSeq++
body = append(body, p.Chunk.Data...)
case *npv1.ConfigUpload_Trailer:
if hdr == nil {
return errors.New("trailer без header")
}
sum := sha256.Sum256(body)
if !bytes.Equal(sum[:], p.Trailer.ContentSha256) {
return stream.SendAndClose(&npv1.ConfigReceipt{
JobId: hdr.JobId,
Accepted: false,
Error: &npv1.Error{Code: "checksum_mismatch", Retryable: true},
})
}
if uint64(len(body)) != p.Trailer.SizeBytes {
return stream.SendAndClose(&npv1.ConfigReceipt{
JobId: hdr.JobId, Accepted: false,
Error: &npv1.Error{Code: "size_mismatch"},
})
}
s.config <- storedConfig{jobID: hdr.JobId, body: body}
return stream.SendAndClose(&npv1.ConfigReceipt{
JobId: hdr.JobId,
Accepted: true,
CommitSha: "3f1a2b8c9d0e",
})
}
}
}
// ---------------------------------------------------------------------
// Обв'язка
// ---------------------------------------------------------------------
func dial(t *testing.T, s *testServer) npv1.AgentServiceClient {
t.Helper()
lis := bufconn.Listen(1 << 20)
srv := grpc.NewServer()
npv1.RegisterAgentServiceServer(srv, s)
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()
})
return npv1.NewAgentServiceClient(conn)
}
// proto1Size — скільки байтів займе на дроті один семпл разом із
// дескриптором серії (якщо той ще не інтернований).
func proto1Size(t *testing.T, sample *npv1.MetricSample, desc *npv1.SeriesDescriptor) int {
t.Helper()
b, err := proto.Marshal(sample)
if err != nil {
t.Fatalf("Marshal(sample): %v", err)
}
n := len(b)
if desc != nil {
d, err := proto.Marshal(desc)
if err != nil {
t.Fatalf("Marshal(descriptor): %v", err)
}
n += len(d)
}
return n
}
func recvWithin[T any](t *testing.T, ch <-chan T, what string) T {
t.Helper()
select {
case v := <-ch:
return v
case <-time.After(5 * time.Second):
var zero T
t.Fatalf("не дочекались: %s", what)
return zero
}
}
// ---------------------------------------------------------------------
// 1. Контрольний канал: Hello → Welcome → TaskPlan → Heartbeat → Ping/Pong
// ---------------------------------------------------------------------
func TestControlHandshakeAndTaskPush(t *testing.T) {
srv := newTestServer()
client := dial(t, srv)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
stream, err := client.Control(ctx)
if err != nil {
t.Fatalf("Control: %v", err)
}
// Агент представляється.
agentStart := time.Now()
if err := stream.Send(&npv1.ControlUp{
Seq: 1,
Payload: &npv1.ControlUp_Hello{Hello: &npv1.Hello{
AgentId: "agent-1",
Hostname: "probe-kyv-01",
Build: &npv1.AgentBuild{
Version: "1.0.0", Os: "linux", Arch: "amd64",
CompiledModules: []string{"icmp", "snmp", "topology"},
},
LocalAddresses: []string{"10.0.0.250/24"},
StartedAt: timestamppb.New(agentStart),
TaskPlanHash: nil, // плану ще немає — чекаємо повний
LastAckedBatchId: 0,
}},
}); err != nil {
t.Fatalf("Send(Hello): %v", err)
}
hello := recvWithin(t, srv.hello, "Hello на сервері")
if hello.AgentId != "agent-1" {
t.Fatalf("agent_id = %q, очікували agent-1", hello.AgentId)
}
// Welcome
down, err := stream.Recv()
if err != nil {
t.Fatalf("Recv(Welcome): %v", err)
}
welcome := down.GetWelcome()
if welcome == nil {
t.Fatalf("перше повідомлення від сервера — не Welcome, а %T", down.Payload)
}
if welcome.SessionId == "" {
t.Fatal("Welcome без session_id")
}
if !welcome.TaskPlanFollows {
t.Fatal("сервер не попередив, що надішле план")
}
if got := welcome.TelemetryMaxBatchSize; got != 500 {
t.Fatalf("telemetry_max_batch_size = %d, очікували 500", got)
}
// Розсинхронізація годинника — те, заради чого в Welcome є server_time.
skew := welcome.ServerTime.AsTime().Sub(agentStart)
if skew > time.Minute {
t.Fatalf("підозріло великий clock skew: %v", skew)
}
// План задач приходить у зустрічному напрямку вже відкритого агентом
// стріму — жодного вхідного з'єднання в мережу клієнта.
down, err = stream.Recv()
if err != nil {
t.Fatalf("Recv(TaskPlan): %v", err)
}
plan := down.GetTaskPlan()
if plan == nil {
t.Fatalf("очікували TaskPlan, отримали %T", down.Payload)
}
if !plan.Final {
t.Fatal("план не позначений як final — агент не має права його застосовувати")
}
if len(plan.Tasks) != 1 || len(plan.Devices) != 1 {
t.Fatalf("план: %d задач, %d пристроїв", len(plan.Tasks), len(plan.Devices))
}
task := plan.Tasks[0]
if task.CheckType != "icmp.ping" {
t.Fatalf("check_type = %q", task.CheckType)
}
// Префікс до крапки визначає модуль-виконавця.
if module, _, ok := strings.Cut(task.CheckType, "."); !ok || module != "icmp" {
t.Fatalf("з check_type %q не виводиться модуль", task.CheckType)
}
// Непрозорі параметри розбирає сам модуль.
var p struct {
Count int `json:"count"`
PacketSize int `json:"packet_size"`
}
if err := json.Unmarshal(task.ParamsJson, &p); err != nil {
t.Fatalf("params_json не розбирається: %v", err)
}
if p.Count != 3 || p.PacketSize != 56 {
t.Fatalf("params_json спотворені: %+v", p)
}
if task.Interval.AsDuration() != time.Minute {
t.Fatalf("interval = %v", task.Interval.AsDuration())
}
if task.ScheduleOffset.AsDuration() != 7*time.Second {
t.Fatalf("schedule_offset = %v", task.ScheduleOffset.AsDuration())
}
<-srv.planSent
// Heartbeat із самометриками агента.
if err := stream.Send(&npv1.ControlUp{
Seq: 2,
Payload: &npv1.ControlUp_Heartbeat{Heartbeat: &npv1.Heartbeat{
Ts: timestamppb.Now(),
Health: &npv1.AgentHealth{
CpuPct: 1.5, RssBytes: 22 * 1024 * 1024,
Goroutines: 48, QueueDepth: 0, ChecksPerSec: 12.5,
Uptime: durationpb.New(time.Hour),
},
TasksRunning: 1,
}},
}); err != nil {
t.Fatalf("Send(Heartbeat): %v", err)
}
// Сервер відповідає Ping — агент має відповісти Pong.
down, err = stream.Recv()
if err != nil {
t.Fatalf("Recv(Ping): %v", err)
}
ping := down.GetPing()
if ping == nil {
t.Fatalf("очікували Ping, отримали %T", down.Payload)
}
if err := stream.Send(&npv1.ControlUp{
Seq: 3,
Payload: &npv1.ControlUp_Pong{Pong: &npv1.Pong{PingId: ping.PingId, AgentTime: timestamppb.Now()}},
}); err != nil {
t.Fatalf("Send(Pong): %v", err)
}
pong := recvWithin(t, srv.pong, "Pong на сервері")
if pong.PingId != ping.PingId {
t.Fatalf("ping_id не збігся: %d != %d", pong.PingId, ping.PingId)
}
if err := stream.CloseSend(); err != nil {
t.Fatalf("CloseSend: %v", err)
}
}
// ---------------------------------------------------------------------
// 2. Телеметрія: інтернування серій, гарячі шляхи, ack
// ---------------------------------------------------------------------
func TestTelemetrySeriesInterning(t *testing.T) {
srv := newTestServer()
client := dial(t, srv)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
stream, err := client.StreamTelemetry(ctx)
if err != nil {
t.Fatalf("StreamTelemetry: %v", err)
}
now := time.Now()
// Батч 1: реєструємо серію разом із першим семплом.
batch1 := &npv1.TelemetryBatch{
BatchId: 1,
AgentId: "agent-1",
CreatedAt: timestamppb.New(now),
NewSeries: []*npv1.SeriesDescriptor{{
SeriesRef: 1,
DeviceId: "dev-1",
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: "dev-1", CheckId: "chk-1", Ts: timestamppb.New(now),
RttAvgMs: 1.24, RttMinMs: 1.10, RttMaxMs: 1.55, JitterMs: 0.2,
LossPct: 0, PacketsSent: 3, PacketsRecv: 3, Reachable: true,
}},
Interfaces: []*npv1.InterfaceCounters{{
DeviceId: "dev-1", InterfaceId: "if-1", Ts: timestamppb.New(now),
InOctets: 1 << 40, OutOctets: 1 << 39,
InBps: 420e6, OutBps: 780e6,
UtilInPct: 42, UtilOutPct: 78,
OperUp: true, AdminUp: true,
Interval: durationpb.New(60 * time.Second),
}},
}
if err := stream.Send(batch1); err != nil {
t.Fatalf("Send(batch1): %v", err)
}
ack, err := stream.Recv()
if err != nil {
t.Fatalf("Recv(ack1): %v", err)
}
if ack.AckedThroughBatchId != 1 {
t.Fatalf("acked_through = %d, очікували 1", ack.AckedThroughBatchId)
}
if ack.ResetSeriesTable {
t.Fatal("сервер попросив скинути таблицю серій одразу після реєстрації")
}
if ack.MaxInFlight == 0 {
t.Fatal("сервер не повідомив max_in_flight — агент не знатиме межі зворотного тиску")
}
got := recvWithin(t, srv.samples, "розв'язаний семпл")
if got.metricKey != "cpu.util" || got.deviceID != "dev-1" || got.unit != "pct" {
t.Fatalf("семпл розв'язався неправильно: %+v", got)
}
if got.labels["core"] != "0" {
t.Fatalf("labels загубились: %+v", got.labels)
}
if got.value != 37.5 {
t.Fatalf("value = %v", got.value)
}
icmp := recvWithin(t, srv.icmp, "ICMP-результат")
if !icmp.Reachable || icmp.PacketsRecv != 3 {
t.Fatalf("icmp спотворений: %+v", icmp)
}
ifc := recvWithin(t, srv.ifc, "лічильники інтерфейсу")
if ifc.UtilOutPct != 78 {
t.Fatalf("util_out_pct = %v — це джерело швидкості анімації, воно має доїхати точно", ifc.UtilOutPct)
}
if ifc.Interval.AsDuration() != time.Minute {
t.Fatalf("interval = %v", ifc.Interval.AsDuration())
}
// Батч 2: серія вже відома — шлемо лише номер.
// Саме заради цього економія й затівалась.
batch2 := &npv1.TelemetryBatch{
BatchId: 2,
AgentId: "agent-1",
CreatedAt: timestamppb.New(now.Add(time.Minute)),
Samples: []*npv1.MetricSample{
{SeriesRef: 1, Ts: timestamppb.New(now.Add(time.Minute)), Value: 41.0},
},
}
if err := stream.Send(batch2); err != nil {
t.Fatalf("Send(batch2): %v", err)
}
ack, err = stream.Recv()
if err != nil {
t.Fatalf("Recv(ack2): %v", err)
}
if ack.AckedThroughBatchId != 2 {
t.Fatalf("acked_through = %d, очікували 2", ack.AckedThroughBatchId)
}
got = recvWithin(t, srv.samples, "семпл із батчу без дескриптора")
if got.metricKey != "cpu.util" || got.value != 41.0 {
t.Fatalf("серія не розв'язалась із таблиці сесії: %+v", got)
}
// Розмір на дроті: другий батч має бути помітно компактнішим,
// хоч і несе стільки ж корисних чисел.
size1 := proto1Size(t, batch1.Samples[0], batch1.NewSeries[0])
size2 := proto1Size(t, batch2.Samples[0], nil)
t.Logf("семпл із дескриптором: %d байт, семпл із series_ref: %d байт", size1, size2)
if size2 >= size1 {
t.Fatalf("інтернування не дає економії: %d >= %d", size2, size1)
}
if err := stream.CloseSend(); err != nil {
t.Fatalf("CloseSend: %v", err)
}
}
// ---------------------------------------------------------------------
// 3. Невідомий series_ref → сервер чесно просить перереєстрацію
// ---------------------------------------------------------------------
func TestTelemetryUnknownSeriesRefTriggersReset(t *testing.T) {
srv := newTestServer()
client := dial(t, srv)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
stream, err := client.StreamTelemetry(ctx)
if err != nil {
t.Fatalf("StreamTelemetry: %v", err)
}
// Шлемо семпл на серію, яку ніколи не реєстрували.
if err := stream.Send(&npv1.TelemetryBatch{
BatchId: 7,
AgentId: "agent-1",
Samples: []*npv1.MetricSample{
{SeriesRef: 999, 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)
}
if !ack.Error.Retryable {
t.Fatal("помилка має бути позначена як retryable — дані не втрачені, їх треба переслати")
}
}
// ---------------------------------------------------------------------
// 4. NCM: вивантаження конфігу чанками зі звіркою sha256
// ---------------------------------------------------------------------
func TestConfigUploadChunked(t *testing.T) {
srv := newTestServer()
client := dial(t, srv)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
stream, err := client.UploadConfig(ctx)
if err != nil {
t.Fatalf("UploadConfig: %v", err)
}
// Конфіг, більший за один чанк.
var sb strings.Builder
sb.WriteString("!\nversion 15.2\nhostname core-sw-01\n!\n")
for i := 0; i < 500; i++ {
fmt.Fprintf(&sb, "interface GigabitEthernet0/%d\n description port-%d\n!\n", i, i)
}
body := []byte(sb.String())
want := sha256.Sum256(body)
if err := stream.Send(&npv1.ConfigUpload{
Part: &npv1.ConfigUpload_Header{Header: &npv1.ConfigHeader{
JobId: "job-1",
AgentId: "agent-1",
DeviceId: "dev-1",
ConfigType: "running",
CollectedAt: timestamppb.Now(),
Encoding: "none",
}},
}); err != nil {
t.Fatalf("Send(header): %v", err)
}
const chunkSize = 4096
var seq uint32
for off := 0; off < len(body); off += chunkSize {
end := min(off+chunkSize, len(body))
if err := stream.Send(&npv1.ConfigUpload{
Part: &npv1.ConfigUpload_Chunk{Chunk: &npv1.ConfigChunk{
Sequence: seq, Data: body[off:end],
}},
}); err != nil {
t.Fatalf("Send(chunk %d): %v", seq, err)
}
seq++
}
if seq < 2 {
t.Fatalf("тест не перевіряє чанкування: вийшов %d чанк", seq)
}
if err := stream.Send(&npv1.ConfigUpload{
Part: &npv1.ConfigUpload_Trailer{Trailer: &npv1.ConfigTrailer{
Success: true,
ContentSha256: want[:],
SizeBytes: uint64(len(body)),
LineCount: uint32(bytes.Count(body, []byte("\n"))),
ChunkCount: seq,
Duration: durationpb.New(2 * time.Second),
}},
}); err != nil {
t.Fatalf("Send(trailer): %v", err)
}
receipt, err := stream.CloseAndRecv()
if err != nil {
t.Fatalf("CloseAndRecv: %v", err)
}
if !receipt.Accepted {
t.Fatalf("сервер відхилив конфіг: %+v", receipt.Error)
}
if receipt.CommitSha == "" {
t.Fatal("немає commit_sha — конфіг не потрапив у Git")
}
stored := recvWithin(t, srv.config, "збережений конфіг")
if !bytes.Equal(stored.body, body) {
t.Fatalf("тіло спотворилось: %d байт замість %d", len(stored.body), len(body))
}
}
// ---------------------------------------------------------------------
// 5. Пошкоджений конфіг має бути відхилений, а не тихо збережений
// ---------------------------------------------------------------------
func TestConfigUploadRejectsBadChecksum(t *testing.T) {
srv := newTestServer()
client := dial(t, srv)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
stream, err := 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-2", DeviceId: "dev-1", 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)
}
}

12
test/contract/go.mod Normal file
View file

@ -0,0 +1,12 @@
module github.com/netpulse/netpulse/test/contract
go 1.24
require (
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

38
test/contract/go.sum Normal file
View file

@ -0,0 +1,38 @@
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/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=
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/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
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=