From 15d22a8d8ac28543bacefe86641056d36768226d Mon Sep 17 00:00:00 2001 From: zotac Date: Fri, 14 Aug 2026 03:28:40 +0300 Subject: [PATCH] =?UTF-8?q?=D0=95=D1=82=D0=B0=D0=BF=201-2:=20=D1=81=D1=85?= =?UTF-8?q?=D0=B5=D0=BC=D0=B0=20=D0=91=D0=94=20=D1=82=D0=B0=20protobuf-?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=B0=D0=BA=D1=82=20=D0=B0=D0=B3?= =?UTF-8?q?=D0=B5=D0=BD=D1=82-=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Схема 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 --- .gitignore | 29 + HISTORY.md | 135 + buf.gen.yaml | 25 + buf.yaml | 19 + db/README.md | 141 ++ db/migrate.ps1 | 63 + db/migrations/0001_core.sql | 229 ++ db/migrations/0002_inventory.sql | 226 ++ db/migrations/0003_agents_plugins.sql | 129 + db/migrations/0004_topology.sql | 348 +++ db/migrations/0005_telemetry_timescale.sql | 392 +++ db/migrations/0006_ncm.sql | 205 ++ db/migrations/0007_alerting.sql | 233 ++ db/migrations/0008_dashboards.sql | 102 + db/migrations/0009_billing_licensing.sql | 290 +++ db/migrations/0010_seed.sql | 205 ++ db/migrations/0011_rls.sql | 112 + db/tests/smoke.sql | 302 +++ docker-compose.yml | 47 + gen/go/go.mod | 15 + gen/go/go.sum | 38 + gen/go/netpulse/v1/agent.pb.go | 2600 ++++++++++++++++++++ gen/go/netpulse/v1/agent_grpc.pb.go | 380 +++ gen/go/netpulse/v1/common.pb.go | 1072 ++++++++ gen/go/netpulse/v1/discovery.pb.go | 800 ++++++ gen/go/netpulse/v1/logs.pb.go | 539 ++++ gen/go/netpulse/v1/ncm.pb.go | 1093 ++++++++ gen/go/netpulse/v1/telemetry.pb.go | 1109 +++++++++ proto/README.md | 189 ++ proto/netpulse/v1/agent.proto | 355 +++ proto/netpulse/v1/common.proto | 153 ++ proto/netpulse/v1/discovery.proto | 124 + proto/netpulse/v1/logs.proto | 66 + proto/netpulse/v1/ncm.proto | 164 ++ proto/netpulse/v1/telemetry.proto | 213 ++ test/contract/contract_test.go | 805 ++++++ test/contract/go.mod | 12 + test/contract/go.sum | 38 + 38 files changed, 12997 insertions(+) create mode 100644 .gitignore create mode 100644 HISTORY.md create mode 100644 buf.gen.yaml create mode 100644 buf.yaml create mode 100644 db/README.md create mode 100644 db/migrate.ps1 create mode 100644 db/migrations/0001_core.sql create mode 100644 db/migrations/0002_inventory.sql create mode 100644 db/migrations/0003_agents_plugins.sql create mode 100644 db/migrations/0004_topology.sql create mode 100644 db/migrations/0005_telemetry_timescale.sql create mode 100644 db/migrations/0006_ncm.sql create mode 100644 db/migrations/0007_alerting.sql create mode 100644 db/migrations/0008_dashboards.sql create mode 100644 db/migrations/0009_billing_licensing.sql create mode 100644 db/migrations/0010_seed.sql create mode 100644 db/migrations/0011_rls.sql create mode 100644 db/tests/smoke.sql create mode 100644 docker-compose.yml create mode 100644 gen/go/go.mod create mode 100644 gen/go/go.sum create mode 100644 gen/go/netpulse/v1/agent.pb.go create mode 100644 gen/go/netpulse/v1/agent_grpc.pb.go create mode 100644 gen/go/netpulse/v1/common.pb.go create mode 100644 gen/go/netpulse/v1/discovery.pb.go create mode 100644 gen/go/netpulse/v1/logs.pb.go create mode 100644 gen/go/netpulse/v1/ncm.pb.go create mode 100644 gen/go/netpulse/v1/telemetry.pb.go create mode 100644 proto/README.md create mode 100644 proto/netpulse/v1/agent.proto create mode 100644 proto/netpulse/v1/common.proto create mode 100644 proto/netpulse/v1/discovery.proto create mode 100644 proto/netpulse/v1/logs.proto create mode 100644 proto/netpulse/v1/ncm.proto create mode 100644 proto/netpulse/v1/telemetry.proto create mode 100644 test/contract/contract_test.go create mode 100644 test/contract/go.mod create mode 100644 test/contract/go.sum diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..47566fc --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/HISTORY.md b/HISTORY.md new file mode 100644 index 0000000..fa87785 --- /dev/null +++ b/HISTORY.md @@ -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. diff --git a/buf.gen.yaml b/buf.gen.yaml new file mode 100644 index 0000000..3846529 --- /dev/null +++ b/buf.gen.yaml @@ -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 diff --git a/buf.yaml b/buf.yaml new file mode 100644 index 0000000..5d5321f --- /dev/null +++ b/buf.yaml @@ -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 diff --git a/db/README.md b/db/README.md new file mode 100644 index 0000000..7c8096f --- /dev/null +++ b/db/README.md @@ -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 = ''; +``` + +Політика `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 | diff --git a/db/migrate.ps1 b/db/migrate.ps1 new file mode 100644 index 0000000..f9f2f3f --- /dev/null +++ b/db/migrate.ps1 @@ -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 diff --git a/db/migrations/0001_core.sql b/db/migrations/0001_core.sql new file mode 100644 index 0000000..7b45a8c --- /dev/null +++ b/db/migrations/0001_core.sql @@ -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); diff --git a/db/migrations/0002_inventory.sql b/db/migrations/0002_inventory.sql new file mode 100644 index 0000000..7588e93 --- /dev/null +++ b/db/migrations/0002_inventory.sql @@ -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) +); diff --git a/db/migrations/0003_agents_plugins.sql b/db/migrations/0003_agents_plugins.sql new file mode 100644 index 0000000..2cf7c9f --- /dev/null +++ b/db/migrations/0003_agents_plugins.sql @@ -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 ( + -- Ключ у формі "." — крапка не проходить 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; diff --git a/db/migrations/0004_topology.sql b/db/migrations/0004_topology.sql new file mode 100644 index 0000000..dbda443 --- /dev/null +++ b/db/migrations/0004_topology.sql @@ -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:" + 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) +); diff --git a/db/migrations/0005_telemetry_timescale.sql b/db/migrations/0005_telemetry_timescale.sql new file mode 100644 index 0000000..cd33fa4 --- /dev/null +++ b/db/migrations/0005_telemetry_timescale.sql @@ -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; diff --git a/db/migrations/0006_ncm.sql b/db/migrations/0006_ncm.sql new file mode 100644 index 0000000..661b432 --- /dev/null +++ b/db/migrations/0006_ncm.sql @@ -0,0 +1,205 @@ +-- ===================================================================== +-- NetPulse :: 0006_ncm.sql +-- Network Config Management: репозиторії, бекапи конфігів, Git-версії, +-- diff, шаблони відповідності (compliance), відкат. +-- ===================================================================== + +-- Один Git-репозиторій на тенанта (bare repo під керуванням libgit2 на сервері). +-- Гілка на пристрій: refs/heads/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/.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); diff --git a/db/migrations/0007_alerting.sql b/db/migrations/0007_alerting.sql new file mode 100644 index 0000000..a0ca6e2 --- /dev/null +++ b/db/migrations/0007_alerting.sql @@ -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); diff --git a/db/migrations/0008_dashboards.sql b/db/migrations/0008_dashboards.sql new file mode 100644 index 0000000..650c504 --- /dev/null +++ b/db/migrations/0008_dashboards.sql @@ -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); diff --git a/db/migrations/0009_billing_licensing.sql b/db/migrations/0009_billing_licensing.sql new file mode 100644 index 0000000..6417671 --- /dev/null +++ b/db/migrations/0009_billing_licensing.sql @@ -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(); diff --git a/db/migrations/0010_seed.sql b/db/migrations/0010_seed.sql new file mode 100644 index 0000000..7e37851 --- /dev/null +++ b/db/migrations/0010_seed.sql @@ -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; diff --git a/db/migrations/0011_rls.sql b/db/migrations/0011_rls.sql new file mode 100644 index 0000000..2593cad --- /dev/null +++ b/db/migrations/0011_rls.sql @@ -0,0 +1,112 @@ +-- ===================================================================== +-- NetPulse :: 0011_rls.sql +-- Ізоляція тенантів через Row Level Security. +-- API виставляє SET LOCAL app.tenant_id = '' на кожній транзакції. +-- ===================================================================== + +-- Ролі: власник схеми (міграції) та робоча роль застосунку. +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 → жоден рядок не пройде політику. +-- Це навмисно: "порожній результат" безпечніший за "усі тенанти". +-- --------------------------------------------------------------------- diff --git a/db/tests/smoke.sql b/db/tests/smoke.sql new file mode 100644 index 0000000..c18f32a --- /dev/null +++ b/db/tests/smoke.sql @@ -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'); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..11fbb77 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/gen/go/go.mod b/gen/go/go.mod new file mode 100644 index 0000000..e2863fb --- /dev/null +++ b/gen/go/go.mod @@ -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 +) diff --git a/gen/go/go.sum b/gen/go/go.sum new file mode 100644 index 0000000..fcc6745 --- /dev/null +++ b/gen/go/go.sum @@ -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= diff --git a/gen/go/netpulse/v1/agent.pb.go b/gen/go/netpulse/v1/agent.pb.go new file mode 100644 index 0000000..6083b74 --- /dev/null +++ b/gen/go/netpulse/v1/agent.pb.go @@ -0,0 +1,2600 @@ +// ===================================================================== +// NetPulse :: agent.proto +// Головний контракт агент↔сервер. +// +// ФУНДАМЕНТАЛЬНЕ ОБМЕЖЕННЯ: усі з'єднання ініціює агент. +// Сервер ніколи не стукає в мережу клієнта — там немає ані відкритих +// портів, ані прокидання NAT. Тому "команда з сервера" фізично є +// повідомленням у зустрічному напрямку вже відкритого агентом +// bidi-стріму Control. +// ===================================================================== + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.12 +// protoc v3.21.12 +// source: netpulse/v1/agent.proto + +package netpulsev1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + durationpb "google.golang.org/protobuf/types/known/durationpb" + 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 TaskStatusUpdate_State int32 + +const ( + TaskStatusUpdate_STATE_UNSPECIFIED TaskStatusUpdate_State = 0 + TaskStatusUpdate_STATE_ACCEPTED TaskStatusUpdate_State = 1 + TaskStatusUpdate_STATE_RUNNING TaskStatusUpdate_State = 2 + TaskStatusUpdate_STATE_SUCCEEDED TaskStatusUpdate_State = 3 + TaskStatusUpdate_STATE_FAILED TaskStatusUpdate_State = 4 + TaskStatusUpdate_STATE_SKIPPED TaskStatusUpdate_State = 5 // не встиг у вікно інтервалу + TaskStatusUpdate_STATE_REJECTED TaskStatusUpdate_State = 6 // модуль не активний або параметри невалідні +) + +// Enum value maps for TaskStatusUpdate_State. +var ( + TaskStatusUpdate_State_name = map[int32]string{ + 0: "STATE_UNSPECIFIED", + 1: "STATE_ACCEPTED", + 2: "STATE_RUNNING", + 3: "STATE_SUCCEEDED", + 4: "STATE_FAILED", + 5: "STATE_SKIPPED", + 6: "STATE_REJECTED", + } + TaskStatusUpdate_State_value = map[string]int32{ + "STATE_UNSPECIFIED": 0, + "STATE_ACCEPTED": 1, + "STATE_RUNNING": 2, + "STATE_SUCCEEDED": 3, + "STATE_FAILED": 4, + "STATE_SKIPPED": 5, + "STATE_REJECTED": 6, + } +) + +func (x TaskStatusUpdate_State) Enum() *TaskStatusUpdate_State { + p := new(TaskStatusUpdate_State) + *p = x + return p +} + +func (x TaskStatusUpdate_State) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (TaskStatusUpdate_State) Descriptor() protoreflect.EnumDescriptor { + return file_netpulse_v1_agent_proto_enumTypes[0].Descriptor() +} + +func (TaskStatusUpdate_State) Type() protoreflect.EnumType { + return &file_netpulse_v1_agent_proto_enumTypes[0] +} + +func (x TaskStatusUpdate_State) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use TaskStatusUpdate_State.Descriptor instead. +func (TaskStatusUpdate_State) EnumDescriptor() ([]byte, []int) { + return file_netpulse_v1_agent_proto_rawDescGZIP(), []int{5, 0} +} + +type AgentEvent_Kind int32 + +const ( + AgentEvent_KIND_UNSPECIFIED AgentEvent_Kind = 0 + AgentEvent_KIND_STARTED AgentEvent_Kind = 1 + AgentEvent_KIND_STOPPING AgentEvent_Kind = 2 + AgentEvent_KIND_MODULE_CRASH AgentEvent_Kind = 3 + AgentEvent_KIND_BUFFER_OVERFLOW AgentEvent_Kind = 4 + AgentEvent_KIND_CLOCK_JUMP AgentEvent_Kind = 5 + AgentEvent_KIND_UPDATE_APPLIED AgentEvent_Kind = 6 + AgentEvent_KIND_CONFIG_REJECTED AgentEvent_Kind = 7 +) + +// Enum value maps for AgentEvent_Kind. +var ( + AgentEvent_Kind_name = map[int32]string{ + 0: "KIND_UNSPECIFIED", + 1: "KIND_STARTED", + 2: "KIND_STOPPING", + 3: "KIND_MODULE_CRASH", + 4: "KIND_BUFFER_OVERFLOW", + 5: "KIND_CLOCK_JUMP", + 6: "KIND_UPDATE_APPLIED", + 7: "KIND_CONFIG_REJECTED", + } + AgentEvent_Kind_value = map[string]int32{ + "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, + } +) + +func (x AgentEvent_Kind) Enum() *AgentEvent_Kind { + p := new(AgentEvent_Kind) + *p = x + return p +} + +func (x AgentEvent_Kind) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (AgentEvent_Kind) Descriptor() protoreflect.EnumDescriptor { + return file_netpulse_v1_agent_proto_enumTypes[1].Descriptor() +} + +func (AgentEvent_Kind) Type() protoreflect.EnumType { + return &file_netpulse_v1_agent_proto_enumTypes[1] +} + +func (x AgentEvent_Kind) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use AgentEvent_Kind.Descriptor instead. +func (AgentEvent_Kind) EnumDescriptor() ([]byte, []int) { + return file_netpulse_v1_agent_proto_rawDescGZIP(), []int{9, 0} +} + +type Directive_Action int32 + +const ( + Directive_ACTION_UNSPECIFIED Directive_Action = 0 + // Перечитати конфігурацію, не розриваючи сесію. + Directive_ACTION_RELOAD Directive_Action = 1 + // Дозбирати й відправити буфер, потім коректно завершитись. + Directive_ACTION_DRAIN Directive_Action = 2 + // Зупинити опитування, лишити канал (несплата → grace-період). + Directive_ACTION_PAUSE Directive_Action = 3 + Directive_ACTION_RESUME Directive_Action = 4 + // Доступне оновлення: деталі в update. + Directive_ACTION_UPDATE Directive_Action = 5 + // Перепідключитись (перебалансування сервера). Агент чекає + // reconnect_after і йде на новий endpoint. + Directive_ACTION_RECONNECT Directive_Action = 6 + // Скинути локальну таблицю series_ref і перереєструвати серії. + Directive_ACTION_RESET_SERIES_TABLE Directive_Action = 7 +) + +// Enum value maps for Directive_Action. +var ( + Directive_Action_name = map[int32]string{ + 0: "ACTION_UNSPECIFIED", + 1: "ACTION_RELOAD", + 2: "ACTION_DRAIN", + 3: "ACTION_PAUSE", + 4: "ACTION_RESUME", + 5: "ACTION_UPDATE", + 6: "ACTION_RECONNECT", + 7: "ACTION_RESET_SERIES_TABLE", + } + Directive_Action_value = map[string]int32{ + "ACTION_UNSPECIFIED": 0, + "ACTION_RELOAD": 1, + "ACTION_DRAIN": 2, + "ACTION_PAUSE": 3, + "ACTION_RESUME": 4, + "ACTION_UPDATE": 5, + "ACTION_RECONNECT": 6, + "ACTION_RESET_SERIES_TABLE": 7, + } +) + +func (x Directive_Action) Enum() *Directive_Action { + p := new(Directive_Action) + *p = x + return p +} + +func (x Directive_Action) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (Directive_Action) Descriptor() protoreflect.EnumDescriptor { + return file_netpulse_v1_agent_proto_enumTypes[2].Descriptor() +} + +func (Directive_Action) Type() protoreflect.EnumType { + return &file_netpulse_v1_agent_proto_enumTypes[2] +} + +func (x Directive_Action) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use Directive_Action.Descriptor instead. +func (Directive_Action) EnumDescriptor() ([]byte, []int) { + return file_netpulse_v1_agent_proto_rawDescGZIP(), []int{21, 0} +} + +type EnrollRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Одноразовий токен із UI: "np_enroll_...". Згорає після використання. + EnrollmentToken string `protobuf:"bytes,1,opt,name=enrollment_token,json=enrollmentToken,proto3" json:"enrollment_token,omitempty"` + // PKCS#10. Приватний ключ ніколи не залишає агента. + Csr []byte `protobuf:"bytes,2,opt,name=csr,proto3" json:"csr,omitempty"` + Hostname string `protobuf:"bytes,3,opt,name=hostname,proto3" json:"hostname,omitempty"` + Build *AgentBuild `protobuf:"bytes,4,opt,name=build,proto3" json:"build,omitempty"` + // Бажане ім'я зонда; сервер може змінити на унікальне. + RequestedName string `protobuf:"bytes,5,opt,name=requested_name,json=requestedName,proto3" json:"requested_name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EnrollRequest) Reset() { + *x = EnrollRequest{} + mi := &file_netpulse_v1_agent_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EnrollRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EnrollRequest) ProtoMessage() {} + +func (x *EnrollRequest) ProtoReflect() protoreflect.Message { + mi := &file_netpulse_v1_agent_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 EnrollRequest.ProtoReflect.Descriptor instead. +func (*EnrollRequest) Descriptor() ([]byte, []int) { + return file_netpulse_v1_agent_proto_rawDescGZIP(), []int{0} +} + +func (x *EnrollRequest) GetEnrollmentToken() string { + if x != nil { + return x.EnrollmentToken + } + return "" +} + +func (x *EnrollRequest) GetCsr() []byte { + if x != nil { + return x.Csr + } + return nil +} + +func (x *EnrollRequest) GetHostname() string { + if x != nil { + return x.Hostname + } + return "" +} + +func (x *EnrollRequest) GetBuild() *AgentBuild { + if x != nil { + return x.Build + } + return nil +} + +func (x *EnrollRequest) GetRequestedName() string { + if x != nil { + return x.RequestedName + } + return "" +} + +type EnrollResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + AgentId string `protobuf:"bytes,1,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"` + AgentName string `protobuf:"bytes,2,opt,name=agent_name,json=agentName,proto3" json:"agent_name,omitempty"` + // Підписаний клієнтський сертифікат + ланцюг CA сервера. + Certificate []byte `protobuf:"bytes,3,opt,name=certificate,proto3" json:"certificate,omitempty"` + CaChain []byte `protobuf:"bytes,4,opt,name=ca_chain,json=caChain,proto3" json:"ca_chain,omitempty"` + CertificateExpiresAt *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=certificate_expires_at,json=certificateExpiresAt,proto3" json:"certificate_expires_at,omitempty"` + // Куди підключатись далі (може відрізнятись від адреси реєстрації: + // балансувальник, регіональний шлюз). + ControlEndpoint string `protobuf:"bytes,6,opt,name=control_endpoint,json=controlEndpoint,proto3" json:"control_endpoint,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EnrollResponse) Reset() { + *x = EnrollResponse{} + mi := &file_netpulse_v1_agent_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EnrollResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EnrollResponse) ProtoMessage() {} + +func (x *EnrollResponse) ProtoReflect() protoreflect.Message { + mi := &file_netpulse_v1_agent_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 EnrollResponse.ProtoReflect.Descriptor instead. +func (*EnrollResponse) Descriptor() ([]byte, []int) { + return file_netpulse_v1_agent_proto_rawDescGZIP(), []int{1} +} + +func (x *EnrollResponse) GetAgentId() string { + if x != nil { + return x.AgentId + } + return "" +} + +func (x *EnrollResponse) GetAgentName() string { + if x != nil { + return x.AgentName + } + return "" +} + +func (x *EnrollResponse) GetCertificate() []byte { + if x != nil { + return x.Certificate + } + return nil +} + +func (x *EnrollResponse) GetCaChain() []byte { + if x != nil { + return x.CaChain + } + return nil +} + +func (x *EnrollResponse) GetCertificateExpiresAt() *timestamppb.Timestamp { + if x != nil { + return x.CertificateExpiresAt + } + return nil +} + +func (x *EnrollResponse) GetControlEndpoint() string { + if x != nil { + return x.ControlEndpoint + } + return "" +} + +type ControlUp struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Монотонний номер повідомлення в сесії — для трасування. + Seq uint64 `protobuf:"varint,1,opt,name=seq,proto3" json:"seq,omitempty"` + // Types that are valid to be assigned to Payload: + // + // *ControlUp_Hello + // *ControlUp_Heartbeat + // *ControlUp_TaskStatus + // *ControlUp_ModuleStatus + // *ControlUp_ConfigApplyResult + // *ControlUp_CredentialRequest + // *ControlUp_Pong + // *ControlUp_Event + Payload isControlUp_Payload `protobuf_oneof:"payload"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ControlUp) Reset() { + *x = ControlUp{} + mi := &file_netpulse_v1_agent_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ControlUp) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ControlUp) ProtoMessage() {} + +func (x *ControlUp) ProtoReflect() protoreflect.Message { + mi := &file_netpulse_v1_agent_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 ControlUp.ProtoReflect.Descriptor instead. +func (*ControlUp) Descriptor() ([]byte, []int) { + return file_netpulse_v1_agent_proto_rawDescGZIP(), []int{2} +} + +func (x *ControlUp) GetSeq() uint64 { + if x != nil { + return x.Seq + } + return 0 +} + +func (x *ControlUp) GetPayload() isControlUp_Payload { + if x != nil { + return x.Payload + } + return nil +} + +func (x *ControlUp) GetHello() *Hello { + if x != nil { + if x, ok := x.Payload.(*ControlUp_Hello); ok { + return x.Hello + } + } + return nil +} + +func (x *ControlUp) GetHeartbeat() *Heartbeat { + if x != nil { + if x, ok := x.Payload.(*ControlUp_Heartbeat); ok { + return x.Heartbeat + } + } + return nil +} + +func (x *ControlUp) GetTaskStatus() *TaskStatusUpdate { + if x != nil { + if x, ok := x.Payload.(*ControlUp_TaskStatus); ok { + return x.TaskStatus + } + } + return nil +} + +func (x *ControlUp) GetModuleStatus() *ModuleStatusUpdate { + if x != nil { + if x, ok := x.Payload.(*ControlUp_ModuleStatus); ok { + return x.ModuleStatus + } + } + return nil +} + +func (x *ControlUp) GetConfigApplyResult() *ConfigApplyResult { + if x != nil { + if x, ok := x.Payload.(*ControlUp_ConfigApplyResult); ok { + return x.ConfigApplyResult + } + } + return nil +} + +func (x *ControlUp) GetCredentialRequest() *CredentialRequest { + if x != nil { + if x, ok := x.Payload.(*ControlUp_CredentialRequest); ok { + return x.CredentialRequest + } + } + return nil +} + +func (x *ControlUp) GetPong() *Pong { + if x != nil { + if x, ok := x.Payload.(*ControlUp_Pong); ok { + return x.Pong + } + } + return nil +} + +func (x *ControlUp) GetEvent() *AgentEvent { + if x != nil { + if x, ok := x.Payload.(*ControlUp_Event); ok { + return x.Event + } + } + return nil +} + +type isControlUp_Payload interface { + isControlUp_Payload() +} + +type ControlUp_Hello struct { + Hello *Hello `protobuf:"bytes,2,opt,name=hello,proto3,oneof"` +} + +type ControlUp_Heartbeat struct { + Heartbeat *Heartbeat `protobuf:"bytes,3,opt,name=heartbeat,proto3,oneof"` +} + +type ControlUp_TaskStatus struct { + TaskStatus *TaskStatusUpdate `protobuf:"bytes,4,opt,name=task_status,json=taskStatus,proto3,oneof"` +} + +type ControlUp_ModuleStatus struct { + ModuleStatus *ModuleStatusUpdate `protobuf:"bytes,5,opt,name=module_status,json=moduleStatus,proto3,oneof"` +} + +type ControlUp_ConfigApplyResult struct { + ConfigApplyResult *ConfigApplyResult `protobuf:"bytes,6,opt,name=config_apply_result,json=configApplyResult,proto3,oneof"` +} + +type ControlUp_CredentialRequest struct { + CredentialRequest *CredentialRequest `protobuf:"bytes,7,opt,name=credential_request,json=credentialRequest,proto3,oneof"` +} + +type ControlUp_Pong struct { + Pong *Pong `protobuf:"bytes,8,opt,name=pong,proto3,oneof"` +} + +type ControlUp_Event struct { + Event *AgentEvent `protobuf:"bytes,9,opt,name=event,proto3,oneof"` +} + +func (*ControlUp_Hello) isControlUp_Payload() {} + +func (*ControlUp_Heartbeat) isControlUp_Payload() {} + +func (*ControlUp_TaskStatus) isControlUp_Payload() {} + +func (*ControlUp_ModuleStatus) isControlUp_Payload() {} + +func (*ControlUp_ConfigApplyResult) isControlUp_Payload() {} + +func (*ControlUp_CredentialRequest) isControlUp_Payload() {} + +func (*ControlUp_Pong) isControlUp_Payload() {} + +func (*ControlUp_Event) isControlUp_Payload() {} + +// Перше повідомлення в стрімі. Сервер відповідає Welcome. +type Hello struct { + state protoimpl.MessageState `protogen:"open.v1"` + AgentId string `protobuf:"bytes,1,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"` + Build *AgentBuild `protobuf:"bytes,2,opt,name=build,proto3" json:"build,omitempty"` + Hostname string `protobuf:"bytes,3,opt,name=hostname,proto3" json:"hostname,omitempty"` + // Локальні адреси зонда — корисно для L2-виявлення й діагностики NAT. + LocalAddresses []string `protobuf:"bytes,4,rep,name=local_addresses,json=localAddresses,proto3" json:"local_addresses,omitempty"` + StartedAt *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=started_at,json=startedAt,proto3" json:"started_at,omitempty"` + // Хеш конфігурації задач, яку агент має локально. Якщо збігається + // з серверним — сервер не шле повний план, лише дельти. + TaskPlanHash []byte `protobuf:"bytes,6,opt,name=task_plan_hash,json=taskPlanHash,proto3" json:"task_plan_hash,omitempty"` + // Останній батч, який агент вважає підтвердженим. Дозволяє + // продовжити з місця розриву замість повного перезливу. + LastAckedBatchId uint64 `protobuf:"varint,7,opt,name=last_acked_batch_id,json=lastAckedBatchId,proto3" json:"last_acked_batch_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Hello) Reset() { + *x = Hello{} + mi := &file_netpulse_v1_agent_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Hello) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Hello) ProtoMessage() {} + +func (x *Hello) ProtoReflect() protoreflect.Message { + mi := &file_netpulse_v1_agent_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 Hello.ProtoReflect.Descriptor instead. +func (*Hello) Descriptor() ([]byte, []int) { + return file_netpulse_v1_agent_proto_rawDescGZIP(), []int{3} +} + +func (x *Hello) GetAgentId() string { + if x != nil { + return x.AgentId + } + return "" +} + +func (x *Hello) GetBuild() *AgentBuild { + if x != nil { + return x.Build + } + return nil +} + +func (x *Hello) GetHostname() string { + if x != nil { + return x.Hostname + } + return "" +} + +func (x *Hello) GetLocalAddresses() []string { + if x != nil { + return x.LocalAddresses + } + return nil +} + +func (x *Hello) GetStartedAt() *timestamppb.Timestamp { + if x != nil { + return x.StartedAt + } + return nil +} + +func (x *Hello) GetTaskPlanHash() []byte { + if x != nil { + return x.TaskPlanHash + } + return nil +} + +func (x *Hello) GetLastAckedBatchId() uint64 { + if x != nil { + return x.LastAckedBatchId + } + return 0 +} + +type Heartbeat struct { + state protoimpl.MessageState `protogen:"open.v1"` + Ts *timestamppb.Timestamp `protobuf:"bytes,1,opt,name=ts,proto3" json:"ts,omitempty"` + Health *AgentHealth `protobuf:"bytes,2,opt,name=health,proto3" json:"health,omitempty"` + // Скільки задач зараз виконується / чекає в черзі. + TasksRunning uint32 `protobuf:"varint,3,opt,name=tasks_running,json=tasksRunning,proto3" json:"tasks_running,omitempty"` + TasksQueued uint32 `protobuf:"varint,4,opt,name=tasks_queued,json=tasksQueued,proto3" json:"tasks_queued,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Heartbeat) Reset() { + *x = Heartbeat{} + mi := &file_netpulse_v1_agent_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Heartbeat) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Heartbeat) ProtoMessage() {} + +func (x *Heartbeat) ProtoReflect() protoreflect.Message { + mi := &file_netpulse_v1_agent_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 Heartbeat.ProtoReflect.Descriptor instead. +func (*Heartbeat) Descriptor() ([]byte, []int) { + return file_netpulse_v1_agent_proto_rawDescGZIP(), []int{4} +} + +func (x *Heartbeat) GetTs() *timestamppb.Timestamp { + if x != nil { + return x.Ts + } + return nil +} + +func (x *Heartbeat) GetHealth() *AgentHealth { + if x != nil { + return x.Health + } + return nil +} + +func (x *Heartbeat) GetTasksRunning() uint32 { + if x != nil { + return x.TasksRunning + } + return 0 +} + +func (x *Heartbeat) GetTasksQueued() uint32 { + if x != nil { + return x.TasksQueued + } + return 0 +} + +// Життєвий цикл задачі. Сервер оновлює core.checks.last_run_at/last_error. +type TaskStatusUpdate struct { + state protoimpl.MessageState `protogen:"open.v1"` + CheckId string `protobuf:"bytes,1,opt,name=check_id,json=checkId,proto3" json:"check_id,omitempty"` + State TaskStatusUpdate_State `protobuf:"varint,2,opt,name=state,proto3,enum=netpulse.v1.TaskStatusUpdate_State" json:"state,omitempty"` + Ts *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=ts,proto3" json:"ts,omitempty"` + Error *Error `protobuf:"bytes,4,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TaskStatusUpdate) Reset() { + *x = TaskStatusUpdate{} + mi := &file_netpulse_v1_agent_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TaskStatusUpdate) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TaskStatusUpdate) ProtoMessage() {} + +func (x *TaskStatusUpdate) ProtoReflect() protoreflect.Message { + mi := &file_netpulse_v1_agent_proto_msgTypes[5] + 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 TaskStatusUpdate.ProtoReflect.Descriptor instead. +func (*TaskStatusUpdate) Descriptor() ([]byte, []int) { + return file_netpulse_v1_agent_proto_rawDescGZIP(), []int{5} +} + +func (x *TaskStatusUpdate) GetCheckId() string { + if x != nil { + return x.CheckId + } + return "" +} + +func (x *TaskStatusUpdate) GetState() TaskStatusUpdate_State { + if x != nil { + return x.State + } + return TaskStatusUpdate_STATE_UNSPECIFIED +} + +func (x *TaskStatusUpdate) GetTs() *timestamppb.Timestamp { + if x != nil { + return x.Ts + } + return nil +} + +func (x *TaskStatusUpdate) GetError() *Error { + if x != nil { + return x.Error + } + return nil +} + +type ModuleStatusUpdate struct { + state protoimpl.MessageState `protogen:"open.v1"` + ModuleKey string `protobuf:"bytes,1,opt,name=module_key,json=moduleKey,proto3" json:"module_key,omitempty"` + Active bool `protobuf:"varint,2,opt,name=active,proto3" json:"active,omitempty"` + Version string `protobuf:"bytes,3,opt,name=version,proto3" json:"version,omitempty"` + Error *Error `protobuf:"bytes,4,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ModuleStatusUpdate) Reset() { + *x = ModuleStatusUpdate{} + mi := &file_netpulse_v1_agent_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ModuleStatusUpdate) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ModuleStatusUpdate) ProtoMessage() {} + +func (x *ModuleStatusUpdate) ProtoReflect() protoreflect.Message { + mi := &file_netpulse_v1_agent_proto_msgTypes[6] + 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 ModuleStatusUpdate.ProtoReflect.Descriptor instead. +func (*ModuleStatusUpdate) Descriptor() ([]byte, []int) { + return file_netpulse_v1_agent_proto_rawDescGZIP(), []int{6} +} + +func (x *ModuleStatusUpdate) GetModuleKey() string { + if x != nil { + return x.ModuleKey + } + return "" +} + +func (x *ModuleStatusUpdate) GetActive() bool { + if x != nil { + return x.Active + } + return false +} + +func (x *ModuleStatusUpdate) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +func (x *ModuleStatusUpdate) GetError() *Error { + if x != nil { + return x.Error + } + return nil +} + +// Агент просить креденшели: або вперше, або бо старі протермінувались. +type CredentialRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + DeviceIds []string `protobuf:"bytes,1,rep,name=device_ids,json=deviceIds,proto3" json:"device_ids,omitempty"` + Reason string `protobuf:"bytes,2,opt,name=reason,proto3" json:"reason,omitempty"` // "expired" | "missing" | "auth_failed" + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CredentialRequest) Reset() { + *x = CredentialRequest{} + mi := &file_netpulse_v1_agent_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CredentialRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CredentialRequest) ProtoMessage() {} + +func (x *CredentialRequest) ProtoReflect() protoreflect.Message { + mi := &file_netpulse_v1_agent_proto_msgTypes[7] + 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 CredentialRequest.ProtoReflect.Descriptor instead. +func (*CredentialRequest) Descriptor() ([]byte, []int) { + return file_netpulse_v1_agent_proto_rawDescGZIP(), []int{7} +} + +func (x *CredentialRequest) GetDeviceIds() []string { + if x != nil { + return x.DeviceIds + } + return nil +} + +func (x *CredentialRequest) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +type Pong struct { + state protoimpl.MessageState `protogen:"open.v1"` + PingId uint64 `protobuf:"varint,1,opt,name=ping_id,json=pingId,proto3" json:"ping_id,omitempty"` + AgentTime *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=agent_time,json=agentTime,proto3" json:"agent_time,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Pong) Reset() { + *x = Pong{} + mi := &file_netpulse_v1_agent_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Pong) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Pong) ProtoMessage() {} + +func (x *Pong) ProtoReflect() protoreflect.Message { + mi := &file_netpulse_v1_agent_proto_msgTypes[8] + 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 Pong.ProtoReflect.Descriptor instead. +func (*Pong) Descriptor() ([]byte, []int) { + return file_netpulse_v1_agent_proto_rawDescGZIP(), []int{8} +} + +func (x *Pong) GetPingId() uint64 { + if x != nil { + return x.PingId + } + return 0 +} + +func (x *Pong) GetAgentTime() *timestamppb.Timestamp { + if x != nil { + return x.AgentTime + } + return nil +} + +// Позапланова подія самого зонда (не пристрою). +type AgentEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + Kind AgentEvent_Kind `protobuf:"varint,1,opt,name=kind,proto3,enum=netpulse.v1.AgentEvent_Kind" json:"kind,omitempty"` + Ts *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=ts,proto3" json:"ts,omitempty"` + Message string `protobuf:"bytes,3,opt,name=message,proto3" json:"message,omitempty"` + Details map[string]string `protobuf:"bytes,4,rep,name=details,proto3" json:"details,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AgentEvent) Reset() { + *x = AgentEvent{} + mi := &file_netpulse_v1_agent_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AgentEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AgentEvent) ProtoMessage() {} + +func (x *AgentEvent) ProtoReflect() protoreflect.Message { + mi := &file_netpulse_v1_agent_proto_msgTypes[9] + 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 AgentEvent.ProtoReflect.Descriptor instead. +func (*AgentEvent) Descriptor() ([]byte, []int) { + return file_netpulse_v1_agent_proto_rawDescGZIP(), []int{9} +} + +func (x *AgentEvent) GetKind() AgentEvent_Kind { + if x != nil { + return x.Kind + } + return AgentEvent_KIND_UNSPECIFIED +} + +func (x *AgentEvent) GetTs() *timestamppb.Timestamp { + if x != nil { + return x.Ts + } + return nil +} + +func (x *AgentEvent) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *AgentEvent) GetDetails() map[string]string { + if x != nil { + return x.Details + } + return nil +} + +type ControlDown struct { + state protoimpl.MessageState `protogen:"open.v1"` + Seq uint64 `protobuf:"varint,1,opt,name=seq,proto3" json:"seq,omitempty"` + // Types that are valid to be assigned to Payload: + // + // *ControlDown_Welcome + // *ControlDown_TaskPlan + // *ControlDown_TaskDelta + // *ControlDown_ModuleControl + // *ControlDown_Credentials + // *ControlDown_ConfigJob + // *ControlDown_ConfigApplyJob + // *ControlDown_DiscoveryRequest + // *ControlDown_Ping + // *ControlDown_Directive + Payload isControlDown_Payload `protobuf_oneof:"payload"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ControlDown) Reset() { + *x = ControlDown{} + mi := &file_netpulse_v1_agent_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ControlDown) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ControlDown) ProtoMessage() {} + +func (x *ControlDown) ProtoReflect() protoreflect.Message { + mi := &file_netpulse_v1_agent_proto_msgTypes[10] + 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 ControlDown.ProtoReflect.Descriptor instead. +func (*ControlDown) Descriptor() ([]byte, []int) { + return file_netpulse_v1_agent_proto_rawDescGZIP(), []int{10} +} + +func (x *ControlDown) GetSeq() uint64 { + if x != nil { + return x.Seq + } + return 0 +} + +func (x *ControlDown) GetPayload() isControlDown_Payload { + if x != nil { + return x.Payload + } + return nil +} + +func (x *ControlDown) GetWelcome() *Welcome { + if x != nil { + if x, ok := x.Payload.(*ControlDown_Welcome); ok { + return x.Welcome + } + } + return nil +} + +func (x *ControlDown) GetTaskPlan() *TaskPlan { + if x != nil { + if x, ok := x.Payload.(*ControlDown_TaskPlan); ok { + return x.TaskPlan + } + } + return nil +} + +func (x *ControlDown) GetTaskDelta() *TaskDelta { + if x != nil { + if x, ok := x.Payload.(*ControlDown_TaskDelta); ok { + return x.TaskDelta + } + } + return nil +} + +func (x *ControlDown) GetModuleControl() *ModuleControl { + if x != nil { + if x, ok := x.Payload.(*ControlDown_ModuleControl); ok { + return x.ModuleControl + } + } + return nil +} + +func (x *ControlDown) GetCredentials() *CredentialBundle { + if x != nil { + if x, ok := x.Payload.(*ControlDown_Credentials); ok { + return x.Credentials + } + } + return nil +} + +func (x *ControlDown) GetConfigJob() *ConfigJob { + if x != nil { + if x, ok := x.Payload.(*ControlDown_ConfigJob); ok { + return x.ConfigJob + } + } + return nil +} + +func (x *ControlDown) GetConfigApplyJob() *ConfigApplyJob { + if x != nil { + if x, ok := x.Payload.(*ControlDown_ConfigApplyJob); ok { + return x.ConfigApplyJob + } + } + return nil +} + +func (x *ControlDown) GetDiscoveryRequest() *DiscoveryRequest { + if x != nil { + if x, ok := x.Payload.(*ControlDown_DiscoveryRequest); ok { + return x.DiscoveryRequest + } + } + return nil +} + +func (x *ControlDown) GetPing() *Ping { + if x != nil { + if x, ok := x.Payload.(*ControlDown_Ping); ok { + return x.Ping + } + } + return nil +} + +func (x *ControlDown) GetDirective() *Directive { + if x != nil { + if x, ok := x.Payload.(*ControlDown_Directive); ok { + return x.Directive + } + } + return nil +} + +type isControlDown_Payload interface { + isControlDown_Payload() +} + +type ControlDown_Welcome struct { + Welcome *Welcome `protobuf:"bytes,2,opt,name=welcome,proto3,oneof"` +} + +type ControlDown_TaskPlan struct { + TaskPlan *TaskPlan `protobuf:"bytes,3,opt,name=task_plan,json=taskPlan,proto3,oneof"` +} + +type ControlDown_TaskDelta struct { + TaskDelta *TaskDelta `protobuf:"bytes,4,opt,name=task_delta,json=taskDelta,proto3,oneof"` +} + +type ControlDown_ModuleControl struct { + ModuleControl *ModuleControl `protobuf:"bytes,5,opt,name=module_control,json=moduleControl,proto3,oneof"` +} + +type ControlDown_Credentials struct { + Credentials *CredentialBundle `protobuf:"bytes,6,opt,name=credentials,proto3,oneof"` +} + +type ControlDown_ConfigJob struct { + ConfigJob *ConfigJob `protobuf:"bytes,7,opt,name=config_job,json=configJob,proto3,oneof"` +} + +type ControlDown_ConfigApplyJob struct { + ConfigApplyJob *ConfigApplyJob `protobuf:"bytes,8,opt,name=config_apply_job,json=configApplyJob,proto3,oneof"` +} + +type ControlDown_DiscoveryRequest struct { + DiscoveryRequest *DiscoveryRequest `protobuf:"bytes,9,opt,name=discovery_request,json=discoveryRequest,proto3,oneof"` +} + +type ControlDown_Ping struct { + Ping *Ping `protobuf:"bytes,10,opt,name=ping,proto3,oneof"` +} + +type ControlDown_Directive struct { + Directive *Directive `protobuf:"bytes,11,opt,name=directive,proto3,oneof"` +} + +func (*ControlDown_Welcome) isControlDown_Payload() {} + +func (*ControlDown_TaskPlan) isControlDown_Payload() {} + +func (*ControlDown_TaskDelta) isControlDown_Payload() {} + +func (*ControlDown_ModuleControl) isControlDown_Payload() {} + +func (*ControlDown_Credentials) isControlDown_Payload() {} + +func (*ControlDown_ConfigJob) isControlDown_Payload() {} + +func (*ControlDown_ConfigApplyJob) isControlDown_Payload() {} + +func (*ControlDown_DiscoveryRequest) isControlDown_Payload() {} + +func (*ControlDown_Ping) isControlDown_Payload() {} + +func (*ControlDown_Directive) isControlDown_Payload() {} + +type Welcome struct { + state protoimpl.MessageState `protogen:"open.v1"` + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + // Час сервера — агент рахує з нього clock_skew. Мітки часу в + // телеметрії лишаються агентськими, але сервер знає поправку. + ServerTime *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=server_time,json=serverTime,proto3" json:"server_time,omitempty"` + HeartbeatInterval *durationpb.Duration `protobuf:"bytes,3,opt,name=heartbeat_interval,json=heartbeatInterval,proto3" json:"heartbeat_interval,omitempty"` + // Параметри батчингу телеметрії. Сервер диктує їх на льоту — + // так можна пригальмувати балакучого агента без оновлення бінарника. + TelemetryMaxBatchSize uint32 `protobuf:"varint,4,opt,name=telemetry_max_batch_size,json=telemetryMaxBatchSize,proto3" json:"telemetry_max_batch_size,omitempty"` + TelemetryMaxBatchInterval *durationpb.Duration `protobuf:"bytes,5,opt,name=telemetry_max_batch_interval,json=telemetryMaxBatchInterval,proto3" json:"telemetry_max_batch_interval,omitempty"` + TelemetryMaxInFlight uint32 `protobuf:"varint,6,opt,name=telemetry_max_in_flight,json=telemetryMaxInFlight,proto3" json:"telemetry_max_in_flight,omitempty"` + // Скільки чеків агенту дозволено виконувати паралельно. + MaxConcurrentChecks uint32 `protobuf:"varint,7,opt,name=max_concurrent_checks,json=maxConcurrentChecks,proto3" json:"max_concurrent_checks,omitempty"` + // Ліміт ICMP, щоб зонд не виглядав як сканер для IDS клієнта. + IcmpRatePps uint32 `protobuf:"varint,8,opt,name=icmp_rate_pps,json=icmpRatePps,proto3" json:"icmp_rate_pps,omitempty"` + // Повний план задач надійде окремим TaskPlan, якщо хеш не збігся. + TaskPlanFollows bool `protobuf:"varint,9,opt,name=task_plan_follows,json=taskPlanFollows,proto3" json:"task_plan_follows,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Welcome) Reset() { + *x = Welcome{} + mi := &file_netpulse_v1_agent_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Welcome) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Welcome) ProtoMessage() {} + +func (x *Welcome) ProtoReflect() protoreflect.Message { + mi := &file_netpulse_v1_agent_proto_msgTypes[11] + 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 Welcome.ProtoReflect.Descriptor instead. +func (*Welcome) Descriptor() ([]byte, []int) { + return file_netpulse_v1_agent_proto_rawDescGZIP(), []int{11} +} + +func (x *Welcome) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *Welcome) GetServerTime() *timestamppb.Timestamp { + if x != nil { + return x.ServerTime + } + return nil +} + +func (x *Welcome) GetHeartbeatInterval() *durationpb.Duration { + if x != nil { + return x.HeartbeatInterval + } + return nil +} + +func (x *Welcome) GetTelemetryMaxBatchSize() uint32 { + if x != nil { + return x.TelemetryMaxBatchSize + } + return 0 +} + +func (x *Welcome) GetTelemetryMaxBatchInterval() *durationpb.Duration { + if x != nil { + return x.TelemetryMaxBatchInterval + } + return nil +} + +func (x *Welcome) GetTelemetryMaxInFlight() uint32 { + if x != nil { + return x.TelemetryMaxInFlight + } + return 0 +} + +func (x *Welcome) GetMaxConcurrentChecks() uint32 { + if x != nil { + return x.MaxConcurrentChecks + } + return 0 +} + +func (x *Welcome) GetIcmpRatePps() uint32 { + if x != nil { + return x.IcmpRatePps + } + return 0 +} + +func (x *Welcome) GetTaskPlanFollows() bool { + if x != nil { + return x.TaskPlanFollows + } + return false +} + +// Повна синхронізація: те, що агент має виконувати. Заміщає все. +type TaskPlan struct { + state protoimpl.MessageState `protogen:"open.v1"` + PlanHash []byte `protobuf:"bytes,1,opt,name=plan_hash,json=planHash,proto3" json:"plan_hash,omitempty"` + Tasks []*Task `protobuf:"bytes,2,rep,name=tasks,proto3" json:"tasks,omitempty"` + Devices []*DeviceTarget `protobuf:"bytes,3,rep,name=devices,proto3" json:"devices,omitempty"` + // План може прийти частинами; агент застосовує його атомарно + // лише після final = true. + Final bool `protobuf:"varint,4,opt,name=final,proto3" json:"final,omitempty"` + Part uint32 `protobuf:"varint,5,opt,name=part,proto3" json:"part,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TaskPlan) Reset() { + *x = TaskPlan{} + mi := &file_netpulse_v1_agent_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TaskPlan) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TaskPlan) ProtoMessage() {} + +func (x *TaskPlan) ProtoReflect() protoreflect.Message { + mi := &file_netpulse_v1_agent_proto_msgTypes[12] + 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 TaskPlan.ProtoReflect.Descriptor instead. +func (*TaskPlan) Descriptor() ([]byte, []int) { + return file_netpulse_v1_agent_proto_rawDescGZIP(), []int{12} +} + +func (x *TaskPlan) GetPlanHash() []byte { + if x != nil { + return x.PlanHash + } + return nil +} + +func (x *TaskPlan) GetTasks() []*Task { + if x != nil { + return x.Tasks + } + return nil +} + +func (x *TaskPlan) GetDevices() []*DeviceTarget { + if x != nil { + return x.Devices + } + return nil +} + +func (x *TaskPlan) GetFinal() bool { + if x != nil { + return x.Final + } + return false +} + +func (x *TaskPlan) GetPart() uint32 { + if x != nil { + return x.Part + } + return 0 +} + +// Інкрементальна зміна плану — типовий випадок після додавання +// одного пристрою в UI. Перезаливати 50 000 задач заради цього не треба. +type TaskDelta struct { + state protoimpl.MessageState `protogen:"open.v1"` + PlanHash []byte `protobuf:"bytes,1,opt,name=plan_hash,json=planHash,proto3" json:"plan_hash,omitempty"` // хеш плану ПІСЛЯ застосування дельти + Upsert []*Task `protobuf:"bytes,2,rep,name=upsert,proto3" json:"upsert,omitempty"` + RemoveCheckIds []string `protobuf:"bytes,3,rep,name=remove_check_ids,json=removeCheckIds,proto3" json:"remove_check_ids,omitempty"` + UpsertDevices []*DeviceTarget `protobuf:"bytes,4,rep,name=upsert_devices,json=upsertDevices,proto3" json:"upsert_devices,omitempty"` + RemoveDeviceIds []string `protobuf:"bytes,5,rep,name=remove_device_ids,json=removeDeviceIds,proto3" json:"remove_device_ids,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TaskDelta) Reset() { + *x = TaskDelta{} + mi := &file_netpulse_v1_agent_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TaskDelta) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TaskDelta) ProtoMessage() {} + +func (x *TaskDelta) ProtoReflect() protoreflect.Message { + mi := &file_netpulse_v1_agent_proto_msgTypes[13] + 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 TaskDelta.ProtoReflect.Descriptor instead. +func (*TaskDelta) Descriptor() ([]byte, []int) { + return file_netpulse_v1_agent_proto_rawDescGZIP(), []int{13} +} + +func (x *TaskDelta) GetPlanHash() []byte { + if x != nil { + return x.PlanHash + } + return nil +} + +func (x *TaskDelta) GetUpsert() []*Task { + if x != nil { + return x.Upsert + } + return nil +} + +func (x *TaskDelta) GetRemoveCheckIds() []string { + if x != nil { + return x.RemoveCheckIds + } + return nil +} + +func (x *TaskDelta) GetUpsertDevices() []*DeviceTarget { + if x != nil { + return x.UpsertDevices + } + return nil +} + +func (x *TaskDelta) GetRemoveDeviceIds() []string { + if x != nil { + return x.RemoveDeviceIds + } + return nil +} + +// Одна задача опитування. Дзеркалить core.checks. +type Task struct { + state protoimpl.MessageState `protogen:"open.v1"` + CheckId string `protobuf:"bytes,1,opt,name=check_id,json=checkId,proto3" json:"check_id,omitempty"` + DeviceId string `protobuf:"bytes,2,opt,name=device_id,json=deviceId,proto3" json:"device_id,omitempty"` + // Заповнюється для чеків рівня інтерфейсу. + InterfaceId string `protobuf:"bytes,3,opt,name=interface_id,json=interfaceId,proto3" json:"interface_id,omitempty"` + // ".": icmp.ping, snmp.if, http.status, ncm.backup. + // Модуль-виконавець визначається префіксом до крапки. + CheckType string `protobuf:"bytes,4,opt,name=check_type,json=checkType,proto3" json:"check_type,omitempty"` + // Параметри чека — непрозорий для ядра JSON. Валідність гарантує + // params_schema з core.check_types; розбирає їх сам модуль. + // Так новий плагін не потребує зміни .proto. + ParamsJson []byte `protobuf:"bytes,5,opt,name=params_json,json=paramsJson,proto3" json:"params_json,omitempty"` + Interval *durationpb.Duration `protobuf:"bytes,6,opt,name=interval,proto3" json:"interval,omitempty"` + Timeout *durationpb.Duration `protobuf:"bytes,7,opt,name=timeout,proto3" json:"timeout,omitempty"` + Retries uint32 `protobuf:"varint,8,opt,name=retries,proto3" json:"retries,omitempty"` + Enabled bool `protobuf:"varint,9,opt,name=enabled,proto3" json:"enabled,omitempty"` + // Зсув у межах інтервалу, щоб 5000 чеків не стартували одночасно. + // Розраховує сервер — детерміновано від check_id, щоб зберігався + // між перезапусками. + ScheduleOffset *durationpb.Duration `protobuf:"bytes,10,opt,name=schedule_offset,json=scheduleOffset,proto3" json:"schedule_offset,omitempty"` + CredentialId string `protobuf:"bytes,11,opt,name=credential_id,json=credentialId,proto3" json:"credential_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Task) Reset() { + *x = Task{} + mi := &file_netpulse_v1_agent_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Task) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Task) ProtoMessage() {} + +func (x *Task) ProtoReflect() protoreflect.Message { + mi := &file_netpulse_v1_agent_proto_msgTypes[14] + 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 Task.ProtoReflect.Descriptor instead. +func (*Task) Descriptor() ([]byte, []int) { + return file_netpulse_v1_agent_proto_rawDescGZIP(), []int{14} +} + +func (x *Task) GetCheckId() string { + if x != nil { + return x.CheckId + } + return "" +} + +func (x *Task) GetDeviceId() string { + if x != nil { + return x.DeviceId + } + return "" +} + +func (x *Task) GetInterfaceId() string { + if x != nil { + return x.InterfaceId + } + return "" +} + +func (x *Task) GetCheckType() string { + if x != nil { + return x.CheckType + } + return "" +} + +func (x *Task) GetParamsJson() []byte { + if x != nil { + return x.ParamsJson + } + return nil +} + +func (x *Task) GetInterval() *durationpb.Duration { + if x != nil { + return x.Interval + } + return nil +} + +func (x *Task) GetTimeout() *durationpb.Duration { + if x != nil { + return x.Timeout + } + return nil +} + +func (x *Task) GetRetries() uint32 { + if x != nil { + return x.Retries + } + return 0 +} + +func (x *Task) GetEnabled() bool { + if x != nil { + return x.Enabled + } + return false +} + +func (x *Task) GetScheduleOffset() *durationpb.Duration { + if x != nil { + return x.ScheduleOffset + } + return nil +} + +func (x *Task) GetCredentialId() string { + if x != nil { + return x.CredentialId + } + return "" +} + +// Активація/деактивація модулів на зонді. +type ModuleControl struct { + state protoimpl.MessageState `protogen:"open.v1"` + Modules []*ModuleSpec `protobuf:"bytes,1,rep,name=modules,proto3" json:"modules,omitempty"` + // Модулі, відсутні в списку, вимкнути. + Exclusive bool `protobuf:"varint,2,opt,name=exclusive,proto3" json:"exclusive,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ModuleControl) Reset() { + *x = ModuleControl{} + mi := &file_netpulse_v1_agent_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ModuleControl) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ModuleControl) ProtoMessage() {} + +func (x *ModuleControl) ProtoReflect() protoreflect.Message { + mi := &file_netpulse_v1_agent_proto_msgTypes[15] + 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 ModuleControl.ProtoReflect.Descriptor instead. +func (*ModuleControl) Descriptor() ([]byte, []int) { + return file_netpulse_v1_agent_proto_rawDescGZIP(), []int{15} +} + +func (x *ModuleControl) GetModules() []*ModuleSpec { + if x != nil { + return x.Modules + } + return nil +} + +func (x *ModuleControl) GetExclusive() bool { + if x != nil { + return x.Exclusive + } + return false +} + +type ModuleSpec struct { + state protoimpl.MessageState `protogen:"open.v1"` + Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` // icmp, snmp, topology, ncm, modbus + Enabled bool `protobuf:"varint,2,opt,name=enabled,proto3" json:"enabled,omitempty"` + MinVersion string `protobuf:"bytes,3,opt,name=min_version,json=minVersion,proto3" json:"min_version,omitempty"` + // Налаштування модуля (JSON), напр. розмір SNMP-пулу. + ConfigJson []byte `protobuf:"bytes,4,opt,name=config_json,json=configJson,proto3" json:"config_json,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ModuleSpec) Reset() { + *x = ModuleSpec{} + mi := &file_netpulse_v1_agent_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ModuleSpec) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ModuleSpec) ProtoMessage() {} + +func (x *ModuleSpec) ProtoReflect() protoreflect.Message { + mi := &file_netpulse_v1_agent_proto_msgTypes[16] + 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 ModuleSpec.ProtoReflect.Descriptor instead. +func (*ModuleSpec) Descriptor() ([]byte, []int) { + return file_netpulse_v1_agent_proto_rawDescGZIP(), []int{16} +} + +func (x *ModuleSpec) GetKey() string { + if x != nil { + return x.Key + } + return "" +} + +func (x *ModuleSpec) GetEnabled() bool { + if x != nil { + return x.Enabled + } + return false +} + +func (x *ModuleSpec) GetMinVersion() string { + if x != nil { + return x.MinVersion + } + return "" +} + +func (x *ModuleSpec) GetConfigJson() []byte { + if x != nil { + return x.ConfigJson + } + return nil +} + +type CredentialBundle struct { + state protoimpl.MessageState `protogen:"open.v1"` + // device_id → креденшели, які до нього застосовні, за пріоритетом. + ByDevice map[string]*CredentialList `protobuf:"bytes,1,rep,name=by_device,json=byDevice,proto3" json:"by_device,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // Спільний строк придатності комплекту. + ExpiresAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CredentialBundle) Reset() { + *x = CredentialBundle{} + mi := &file_netpulse_v1_agent_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CredentialBundle) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CredentialBundle) ProtoMessage() {} + +func (x *CredentialBundle) ProtoReflect() protoreflect.Message { + mi := &file_netpulse_v1_agent_proto_msgTypes[17] + 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 CredentialBundle.ProtoReflect.Descriptor instead. +func (*CredentialBundle) Descriptor() ([]byte, []int) { + return file_netpulse_v1_agent_proto_rawDescGZIP(), []int{17} +} + +func (x *CredentialBundle) GetByDevice() map[string]*CredentialList { + if x != nil { + return x.ByDevice + } + return nil +} + +func (x *CredentialBundle) GetExpiresAt() *timestamppb.Timestamp { + if x != nil { + return x.ExpiresAt + } + return nil +} + +type CredentialList struct { + state protoimpl.MessageState `protogen:"open.v1"` + Credentials []*Credential `protobuf:"bytes,1,rep,name=credentials,proto3" json:"credentials,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CredentialList) Reset() { + *x = CredentialList{} + mi := &file_netpulse_v1_agent_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CredentialList) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CredentialList) ProtoMessage() {} + +func (x *CredentialList) ProtoReflect() protoreflect.Message { + mi := &file_netpulse_v1_agent_proto_msgTypes[18] + 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 CredentialList.ProtoReflect.Descriptor instead. +func (*CredentialList) Descriptor() ([]byte, []int) { + return file_netpulse_v1_agent_proto_rawDescGZIP(), []int{18} +} + +func (x *CredentialList) GetCredentials() []*Credential { + if x != nil { + return x.Credentials + } + return nil +} + +type DiscoveryRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + RunId string `protobuf:"bytes,1,opt,name=run_id,json=runId,proto3" json:"run_id,omitempty"` + Protocols []DiscoveryProto `protobuf:"varint,2,rep,packed,name=protocols,proto3,enum=netpulse.v1.DiscoveryProto" json:"protocols,omitempty"` + // Пристрої, які опитати на предмет сусідів. + DeviceIds []string `protobuf:"bytes,3,rep,name=device_ids,json=deviceIds,proto3" json:"device_ids,omitempty"` + // Підмережі для сканування (CIDR). Порожньо — не сканувати. + Subnets []string `protobuf:"bytes,4,rep,name=subnets,proto3" json:"subnets,omitempty"` + // Обмеження темпу сканування, щоб не збурювати мережу клієнта. + ScanRatePps uint32 `protobuf:"varint,5,opt,name=scan_rate_pps,json=scanRatePps,proto3" json:"scan_rate_pps,omitempty"` + Timeout *durationpb.Duration `protobuf:"bytes,6,opt,name=timeout,proto3" json:"timeout,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DiscoveryRequest) Reset() { + *x = DiscoveryRequest{} + mi := &file_netpulse_v1_agent_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DiscoveryRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DiscoveryRequest) ProtoMessage() {} + +func (x *DiscoveryRequest) ProtoReflect() protoreflect.Message { + mi := &file_netpulse_v1_agent_proto_msgTypes[19] + 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 DiscoveryRequest.ProtoReflect.Descriptor instead. +func (*DiscoveryRequest) Descriptor() ([]byte, []int) { + return file_netpulse_v1_agent_proto_rawDescGZIP(), []int{19} +} + +func (x *DiscoveryRequest) GetRunId() string { + if x != nil { + return x.RunId + } + return "" +} + +func (x *DiscoveryRequest) GetProtocols() []DiscoveryProto { + if x != nil { + return x.Protocols + } + return nil +} + +func (x *DiscoveryRequest) GetDeviceIds() []string { + if x != nil { + return x.DeviceIds + } + return nil +} + +func (x *DiscoveryRequest) GetSubnets() []string { + if x != nil { + return x.Subnets + } + return nil +} + +func (x *DiscoveryRequest) GetScanRatePps() uint32 { + if x != nil { + return x.ScanRatePps + } + return 0 +} + +func (x *DiscoveryRequest) GetTimeout() *durationpb.Duration { + if x != nil { + return x.Timeout + } + return nil +} + +type Ping struct { + state protoimpl.MessageState `protogen:"open.v1"` + PingId uint64 `protobuf:"varint,1,opt,name=ping_id,json=pingId,proto3" json:"ping_id,omitempty"` + ServerTime *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=server_time,json=serverTime,proto3" json:"server_time,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Ping) Reset() { + *x = Ping{} + mi := &file_netpulse_v1_agent_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Ping) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Ping) ProtoMessage() {} + +func (x *Ping) ProtoReflect() protoreflect.Message { + mi := &file_netpulse_v1_agent_proto_msgTypes[20] + 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 Ping.ProtoReflect.Descriptor instead. +func (*Ping) Descriptor() ([]byte, []int) { + return file_netpulse_v1_agent_proto_rawDescGZIP(), []int{20} +} + +func (x *Ping) GetPingId() uint64 { + if x != nil { + return x.PingId + } + return 0 +} + +func (x *Ping) GetServerTime() *timestamppb.Timestamp { + if x != nil { + return x.ServerTime + } + return nil +} + +// Команди життєвого циклу самого агента. +type Directive struct { + state protoimpl.MessageState `protogen:"open.v1"` + Action Directive_Action `protobuf:"varint,1,opt,name=action,proto3,enum=netpulse.v1.Directive_Action" json:"action,omitempty"` + Reason string `protobuf:"bytes,2,opt,name=reason,proto3" json:"reason,omitempty"` + ReconnectAfter *durationpb.Duration `protobuf:"bytes,3,opt,name=reconnect_after,json=reconnectAfter,proto3" json:"reconnect_after,omitempty"` + NewEndpoint string `protobuf:"bytes,4,opt,name=new_endpoint,json=newEndpoint,proto3" json:"new_endpoint,omitempty"` + Update *UpdateInfo `protobuf:"bytes,5,opt,name=update,proto3" json:"update,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Directive) Reset() { + *x = Directive{} + mi := &file_netpulse_v1_agent_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Directive) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Directive) ProtoMessage() {} + +func (x *Directive) ProtoReflect() protoreflect.Message { + mi := &file_netpulse_v1_agent_proto_msgTypes[21] + 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 Directive.ProtoReflect.Descriptor instead. +func (*Directive) Descriptor() ([]byte, []int) { + return file_netpulse_v1_agent_proto_rawDescGZIP(), []int{21} +} + +func (x *Directive) GetAction() Directive_Action { + if x != nil { + return x.Action + } + return Directive_ACTION_UNSPECIFIED +} + +func (x *Directive) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +func (x *Directive) GetReconnectAfter() *durationpb.Duration { + if x != nil { + return x.ReconnectAfter + } + return nil +} + +func (x *Directive) GetNewEndpoint() string { + if x != nil { + return x.NewEndpoint + } + return "" +} + +func (x *Directive) GetUpdate() *UpdateInfo { + if x != nil { + return x.Update + } + return nil +} + +type UpdateInfo struct { + state protoimpl.MessageState `protogen:"open.v1"` + Version string `protobuf:"bytes,1,opt,name=version,proto3" json:"version,omitempty"` + DownloadUrl string `protobuf:"bytes,2,opt,name=download_url,json=downloadUrl,proto3" json:"download_url,omitempty"` + // sha256 бінарника — агент зобов'язаний звірити перед запуском. + Sha256 []byte `protobuf:"bytes,3,opt,name=sha256,proto3" json:"sha256,omitempty"` + // Підпис постачальника (Ed25519) над sha256. Без валідного підпису + // оновлення не застосовується: інакше компрометація CDN + // перетворюється на RCE в мережі кожного клієнта. + Signature []byte `protobuf:"bytes,4,opt,name=signature,proto3" json:"signature,omitempty"` + Mandatory bool `protobuf:"varint,5,opt,name=mandatory,proto3" json:"mandatory,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateInfo) Reset() { + *x = UpdateInfo{} + mi := &file_netpulse_v1_agent_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateInfo) ProtoMessage() {} + +func (x *UpdateInfo) ProtoReflect() protoreflect.Message { + mi := &file_netpulse_v1_agent_proto_msgTypes[22] + 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 UpdateInfo.ProtoReflect.Descriptor instead. +func (*UpdateInfo) Descriptor() ([]byte, []int) { + return file_netpulse_v1_agent_proto_rawDescGZIP(), []int{22} +} + +func (x *UpdateInfo) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +func (x *UpdateInfo) GetDownloadUrl() string { + if x != nil { + return x.DownloadUrl + } + return "" +} + +func (x *UpdateInfo) GetSha256() []byte { + if x != nil { + return x.Sha256 + } + return nil +} + +func (x *UpdateInfo) GetSignature() []byte { + if x != nil { + return x.Signature + } + return nil +} + +func (x *UpdateInfo) GetMandatory() bool { + if x != nil { + return x.Mandatory + } + return false +} + +var File_netpulse_v1_agent_proto protoreflect.FileDescriptor + +const file_netpulse_v1_agent_proto_rawDesc = "" + + "\n" + + "\x17netpulse/v1/agent.proto\x12\vnetpulse.v1\x1a\x1egoogle/protobuf/duration.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x18netpulse/v1/common.proto\x1a\x1bnetpulse/v1/discovery.proto\x1a\x16netpulse/v1/logs.proto\x1a\x15netpulse/v1/ncm.proto\x1a\x1bnetpulse/v1/telemetry.proto\"\xbe\x01\n" + + "\rEnrollRequest\x12)\n" + + "\x10enrollment_token\x18\x01 \x01(\tR\x0fenrollmentToken\x12\x10\n" + + "\x03csr\x18\x02 \x01(\fR\x03csr\x12\x1a\n" + + "\bhostname\x18\x03 \x01(\tR\bhostname\x12-\n" + + "\x05build\x18\x04 \x01(\v2\x17.netpulse.v1.AgentBuildR\x05build\x12%\n" + + "\x0erequested_name\x18\x05 \x01(\tR\rrequestedName\"\x84\x02\n" + + "\x0eEnrollResponse\x12\x19\n" + + "\bagent_id\x18\x01 \x01(\tR\aagentId\x12\x1d\n" + + "\n" + + "agent_name\x18\x02 \x01(\tR\tagentName\x12 \n" + + "\vcertificate\x18\x03 \x01(\fR\vcertificate\x12\x19\n" + + "\bca_chain\x18\x04 \x01(\fR\acaChain\x12P\n" + + "\x16certificate_expires_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\x14certificateExpiresAt\x12)\n" + + "\x10control_endpoint\x18\x06 \x01(\tR\x0fcontrolEndpoint\"\x93\x04\n" + + "\tControlUp\x12\x10\n" + + "\x03seq\x18\x01 \x01(\x04R\x03seq\x12*\n" + + "\x05hello\x18\x02 \x01(\v2\x12.netpulse.v1.HelloH\x00R\x05hello\x126\n" + + "\theartbeat\x18\x03 \x01(\v2\x16.netpulse.v1.HeartbeatH\x00R\theartbeat\x12@\n" + + "\vtask_status\x18\x04 \x01(\v2\x1d.netpulse.v1.TaskStatusUpdateH\x00R\n" + + "taskStatus\x12F\n" + + "\rmodule_status\x18\x05 \x01(\v2\x1f.netpulse.v1.ModuleStatusUpdateH\x00R\fmoduleStatus\x12P\n" + + "\x13config_apply_result\x18\x06 \x01(\v2\x1e.netpulse.v1.ConfigApplyResultH\x00R\x11configApplyResult\x12O\n" + + "\x12credential_request\x18\a \x01(\v2\x1e.netpulse.v1.CredentialRequestH\x00R\x11credentialRequest\x12'\n" + + "\x04pong\x18\b \x01(\v2\x11.netpulse.v1.PongH\x00R\x04pong\x12/\n" + + "\x05event\x18\t \x01(\v2\x17.netpulse.v1.AgentEventH\x00R\x05eventB\t\n" + + "\apayload\"\xa6\x02\n" + + "\x05Hello\x12\x19\n" + + "\bagent_id\x18\x01 \x01(\tR\aagentId\x12-\n" + + "\x05build\x18\x02 \x01(\v2\x17.netpulse.v1.AgentBuildR\x05build\x12\x1a\n" + + "\bhostname\x18\x03 \x01(\tR\bhostname\x12'\n" + + "\x0flocal_addresses\x18\x04 \x03(\tR\x0elocalAddresses\x129\n" + + "\n" + + "started_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\tstartedAt\x12$\n" + + "\x0etask_plan_hash\x18\x06 \x01(\fR\ftaskPlanHash\x12-\n" + + "\x13last_acked_batch_id\x18\a \x01(\x04R\x10lastAckedBatchId\"\xb1\x01\n" + + "\tHeartbeat\x12*\n" + + "\x02ts\x18\x01 \x01(\v2\x1a.google.protobuf.TimestampR\x02ts\x120\n" + + "\x06health\x18\x02 \x01(\v2\x18.netpulse.v1.AgentHealthR\x06health\x12#\n" + + "\rtasks_running\x18\x03 \x01(\rR\ftasksRunning\x12!\n" + + "\ftasks_queued\x18\x04 \x01(\rR\vtasksQueued\"\xd4\x02\n" + + "\x10TaskStatusUpdate\x12\x19\n" + + "\bcheck_id\x18\x01 \x01(\tR\acheckId\x129\n" + + "\x05state\x18\x02 \x01(\x0e2#.netpulse.v1.TaskStatusUpdate.StateR\x05state\x12*\n" + + "\x02ts\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\x02ts\x12(\n" + + "\x05error\x18\x04 \x01(\v2\x12.netpulse.v1.ErrorR\x05error\"\x93\x01\n" + + "\x05State\x12\x15\n" + + "\x11STATE_UNSPECIFIED\x10\x00\x12\x12\n" + + "\x0eSTATE_ACCEPTED\x10\x01\x12\x11\n" + + "\rSTATE_RUNNING\x10\x02\x12\x13\n" + + "\x0fSTATE_SUCCEEDED\x10\x03\x12\x10\n" + + "\fSTATE_FAILED\x10\x04\x12\x11\n" + + "\rSTATE_SKIPPED\x10\x05\x12\x12\n" + + "\x0eSTATE_REJECTED\x10\x06\"\x8f\x01\n" + + "\x12ModuleStatusUpdate\x12\x1d\n" + + "\n" + + "module_key\x18\x01 \x01(\tR\tmoduleKey\x12\x16\n" + + "\x06active\x18\x02 \x01(\bR\x06active\x12\x18\n" + + "\aversion\x18\x03 \x01(\tR\aversion\x12(\n" + + "\x05error\x18\x04 \x01(\v2\x12.netpulse.v1.ErrorR\x05error\"J\n" + + "\x11CredentialRequest\x12\x1d\n" + + "\n" + + "device_ids\x18\x01 \x03(\tR\tdeviceIds\x12\x16\n" + + "\x06reason\x18\x02 \x01(\tR\x06reason\"Z\n" + + "\x04Pong\x12\x17\n" + + "\aping_id\x18\x01 \x01(\x04R\x06pingId\x129\n" + + "\n" + + "agent_time\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\tagentTime\"\xbd\x03\n" + + "\n" + + "AgentEvent\x120\n" + + "\x04kind\x18\x01 \x01(\x0e2\x1c.netpulse.v1.AgentEvent.KindR\x04kind\x12*\n" + + "\x02ts\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\x02ts\x12\x18\n" + + "\amessage\x18\x03 \x01(\tR\amessage\x12>\n" + + "\adetails\x18\x04 \x03(\v2$.netpulse.v1.AgentEvent.DetailsEntryR\adetails\x1a:\n" + + "\fDetailsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xba\x01\n" + + "\x04Kind\x12\x14\n" + + "\x10KIND_UNSPECIFIED\x10\x00\x12\x10\n" + + "\fKIND_STARTED\x10\x01\x12\x11\n" + + "\rKIND_STOPPING\x10\x02\x12\x15\n" + + "\x11KIND_MODULE_CRASH\x10\x03\x12\x18\n" + + "\x14KIND_BUFFER_OVERFLOW\x10\x04\x12\x13\n" + + "\x0fKIND_CLOCK_JUMP\x10\x05\x12\x17\n" + + "\x13KIND_UPDATE_APPLIED\x10\x06\x12\x18\n" + + "\x14KIND_CONFIG_REJECTED\x10\a\"\x84\x05\n" + + "\vControlDown\x12\x10\n" + + "\x03seq\x18\x01 \x01(\x04R\x03seq\x120\n" + + "\awelcome\x18\x02 \x01(\v2\x14.netpulse.v1.WelcomeH\x00R\awelcome\x124\n" + + "\ttask_plan\x18\x03 \x01(\v2\x15.netpulse.v1.TaskPlanH\x00R\btaskPlan\x127\n" + + "\n" + + "task_delta\x18\x04 \x01(\v2\x16.netpulse.v1.TaskDeltaH\x00R\ttaskDelta\x12C\n" + + "\x0emodule_control\x18\x05 \x01(\v2\x1a.netpulse.v1.ModuleControlH\x00R\rmoduleControl\x12A\n" + + "\vcredentials\x18\x06 \x01(\v2\x1d.netpulse.v1.CredentialBundleH\x00R\vcredentials\x127\n" + + "\n" + + "config_job\x18\a \x01(\v2\x16.netpulse.v1.ConfigJobH\x00R\tconfigJob\x12G\n" + + "\x10config_apply_job\x18\b \x01(\v2\x1b.netpulse.v1.ConfigApplyJobH\x00R\x0econfigApplyJob\x12L\n" + + "\x11discovery_request\x18\t \x01(\v2\x1d.netpulse.v1.DiscoveryRequestH\x00R\x10discoveryRequest\x12'\n" + + "\x04ping\x18\n" + + " \x01(\v2\x11.netpulse.v1.PingH\x00R\x04ping\x126\n" + + "\tdirective\x18\v \x01(\v2\x16.netpulse.v1.DirectiveH\x00R\tdirectiveB\t\n" + + "\apayload\"\xff\x03\n" + + "\aWelcome\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\x12;\n" + + "\vserver_time\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\n" + + "serverTime\x12H\n" + + "\x12heartbeat_interval\x18\x03 \x01(\v2\x19.google.protobuf.DurationR\x11heartbeatInterval\x127\n" + + "\x18telemetry_max_batch_size\x18\x04 \x01(\rR\x15telemetryMaxBatchSize\x12Z\n" + + "\x1ctelemetry_max_batch_interval\x18\x05 \x01(\v2\x19.google.protobuf.DurationR\x19telemetryMaxBatchInterval\x125\n" + + "\x17telemetry_max_in_flight\x18\x06 \x01(\rR\x14telemetryMaxInFlight\x122\n" + + "\x15max_concurrent_checks\x18\a \x01(\rR\x13maxConcurrentChecks\x12\"\n" + + "\ricmp_rate_pps\x18\b \x01(\rR\vicmpRatePps\x12*\n" + + "\x11task_plan_follows\x18\t \x01(\bR\x0ftaskPlanFollows\"\xaf\x01\n" + + "\bTaskPlan\x12\x1b\n" + + "\tplan_hash\x18\x01 \x01(\fR\bplanHash\x12'\n" + + "\x05tasks\x18\x02 \x03(\v2\x11.netpulse.v1.TaskR\x05tasks\x123\n" + + "\adevices\x18\x03 \x03(\v2\x19.netpulse.v1.DeviceTargetR\adevices\x12\x14\n" + + "\x05final\x18\x04 \x01(\bR\x05final\x12\x12\n" + + "\x04part\x18\x05 \x01(\rR\x04part\"\xeb\x01\n" + + "\tTaskDelta\x12\x1b\n" + + "\tplan_hash\x18\x01 \x01(\fR\bplanHash\x12)\n" + + "\x06upsert\x18\x02 \x03(\v2\x11.netpulse.v1.TaskR\x06upsert\x12(\n" + + "\x10remove_check_ids\x18\x03 \x03(\tR\x0eremoveCheckIds\x12@\n" + + "\x0eupsert_devices\x18\x04 \x03(\v2\x19.netpulse.v1.DeviceTargetR\rupsertDevices\x12*\n" + + "\x11remove_device_ids\x18\x05 \x03(\tR\x0fremoveDeviceIds\"\xaa\x03\n" + + "\x04Task\x12\x19\n" + + "\bcheck_id\x18\x01 \x01(\tR\acheckId\x12\x1b\n" + + "\tdevice_id\x18\x02 \x01(\tR\bdeviceId\x12!\n" + + "\finterface_id\x18\x03 \x01(\tR\vinterfaceId\x12\x1d\n" + + "\n" + + "check_type\x18\x04 \x01(\tR\tcheckType\x12\x1f\n" + + "\vparams_json\x18\x05 \x01(\fR\n" + + "paramsJson\x125\n" + + "\binterval\x18\x06 \x01(\v2\x19.google.protobuf.DurationR\binterval\x123\n" + + "\atimeout\x18\a \x01(\v2\x19.google.protobuf.DurationR\atimeout\x12\x18\n" + + "\aretries\x18\b \x01(\rR\aretries\x12\x18\n" + + "\aenabled\x18\t \x01(\bR\aenabled\x12B\n" + + "\x0fschedule_offset\x18\n" + + " \x01(\v2\x19.google.protobuf.DurationR\x0escheduleOffset\x12#\n" + + "\rcredential_id\x18\v \x01(\tR\fcredentialId\"`\n" + + "\rModuleControl\x121\n" + + "\amodules\x18\x01 \x03(\v2\x17.netpulse.v1.ModuleSpecR\amodules\x12\x1c\n" + + "\texclusive\x18\x02 \x01(\bR\texclusive\"z\n" + + "\n" + + "ModuleSpec\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x18\n" + + "\aenabled\x18\x02 \x01(\bR\aenabled\x12\x1f\n" + + "\vmin_version\x18\x03 \x01(\tR\n" + + "minVersion\x12\x1f\n" + + "\vconfig_json\x18\x04 \x01(\fR\n" + + "configJson\"\xf1\x01\n" + + "\x10CredentialBundle\x12H\n" + + "\tby_device\x18\x01 \x03(\v2+.netpulse.v1.CredentialBundle.ByDeviceEntryR\bbyDevice\x129\n" + + "\n" + + "expires_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt\x1aX\n" + + "\rByDeviceEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x121\n" + + "\x05value\x18\x02 \x01(\v2\x1b.netpulse.v1.CredentialListR\x05value:\x028\x01\"K\n" + + "\x0eCredentialList\x129\n" + + "\vcredentials\x18\x01 \x03(\v2\x17.netpulse.v1.CredentialR\vcredentials\"\xf6\x01\n" + + "\x10DiscoveryRequest\x12\x15\n" + + "\x06run_id\x18\x01 \x01(\tR\x05runId\x129\n" + + "\tprotocols\x18\x02 \x03(\x0e2\x1b.netpulse.v1.DiscoveryProtoR\tprotocols\x12\x1d\n" + + "\n" + + "device_ids\x18\x03 \x03(\tR\tdeviceIds\x12\x18\n" + + "\asubnets\x18\x04 \x03(\tR\asubnets\x12\"\n" + + "\rscan_rate_pps\x18\x05 \x01(\rR\vscanRatePps\x123\n" + + "\atimeout\x18\x06 \x01(\v2\x19.google.protobuf.DurationR\atimeout\"\\\n" + + "\x04Ping\x12\x17\n" + + "\aping_id\x18\x01 \x01(\x04R\x06pingId\x12;\n" + + "\vserver_time\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\n" + + "serverTime\"\xa7\x03\n" + + "\tDirective\x125\n" + + "\x06action\x18\x01 \x01(\x0e2\x1d.netpulse.v1.Directive.ActionR\x06action\x12\x16\n" + + "\x06reason\x18\x02 \x01(\tR\x06reason\x12B\n" + + "\x0freconnect_after\x18\x03 \x01(\v2\x19.google.protobuf.DurationR\x0ereconnectAfter\x12!\n" + + "\fnew_endpoint\x18\x04 \x01(\tR\vnewEndpoint\x12/\n" + + "\x06update\x18\x05 \x01(\v2\x17.netpulse.v1.UpdateInfoR\x06update\"\xb2\x01\n" + + "\x06Action\x12\x16\n" + + "\x12ACTION_UNSPECIFIED\x10\x00\x12\x11\n" + + "\rACTION_RELOAD\x10\x01\x12\x10\n" + + "\fACTION_DRAIN\x10\x02\x12\x10\n" + + "\fACTION_PAUSE\x10\x03\x12\x11\n" + + "\rACTION_RESUME\x10\x04\x12\x11\n" + + "\rACTION_UPDATE\x10\x05\x12\x14\n" + + "\x10ACTION_RECONNECT\x10\x06\x12\x1d\n" + + "\x19ACTION_RESET_SERIES_TABLE\x10\a\"\x9d\x01\n" + + "\n" + + "UpdateInfo\x12\x18\n" + + "\aversion\x18\x01 \x01(\tR\aversion\x12!\n" + + "\fdownload_url\x18\x02 \x01(\tR\vdownloadUrl\x12\x16\n" + + "\x06sha256\x18\x03 \x01(\fR\x06sha256\x12\x1c\n" + + "\tsignature\x18\x04 \x01(\fR\tsignature\x12\x1c\n" + + "\tmandatory\x18\x05 \x01(\bR\tmandatory2V\n" + + "\x11EnrollmentService\x12A\n" + + "\x06Enroll\x12\x1a.netpulse.v1.EnrollRequest\x1a\x1b.netpulse.v1.EnrollResponse2\xf1\x02\n" + + "\fAgentService\x12?\n" + + "\aControl\x12\x16.netpulse.v1.ControlUp\x1a\x18.netpulse.v1.ControlDown(\x010\x01\x12M\n" + + "\x0fStreamTelemetry\x12\x1b.netpulse.v1.TelemetryBatch\x1a\x19.netpulse.v1.TelemetryAck(\x010\x01\x12<\n" + + "\n" + + "StreamLogs\x12\x15.netpulse.v1.LogBatch\x1a\x13.netpulse.v1.LogAck(\x010\x01\x12J\n" + + "\x0fReportDiscovery\x12\x1c.netpulse.v1.DiscoveryReport\x1a\x19.netpulse.v1.DiscoveryAck\x12G\n" + + "\fUploadConfig\x12\x19.netpulse.v1.ConfigUpload\x1a\x1a.netpulse.v1.ConfigReceipt(\x01B netpulse.v1.AgentBuild + 29, // 1: netpulse.v1.EnrollResponse.certificate_expires_at:type_name -> google.protobuf.Timestamp + 6, // 2: netpulse.v1.ControlUp.hello:type_name -> netpulse.v1.Hello + 7, // 3: netpulse.v1.ControlUp.heartbeat:type_name -> netpulse.v1.Heartbeat + 8, // 4: netpulse.v1.ControlUp.task_status:type_name -> netpulse.v1.TaskStatusUpdate + 9, // 5: netpulse.v1.ControlUp.module_status:type_name -> netpulse.v1.ModuleStatusUpdate + 30, // 6: netpulse.v1.ControlUp.config_apply_result:type_name -> netpulse.v1.ConfigApplyResult + 10, // 7: netpulse.v1.ControlUp.credential_request:type_name -> netpulse.v1.CredentialRequest + 11, // 8: netpulse.v1.ControlUp.pong:type_name -> netpulse.v1.Pong + 12, // 9: netpulse.v1.ControlUp.event:type_name -> netpulse.v1.AgentEvent + 28, // 10: netpulse.v1.Hello.build:type_name -> netpulse.v1.AgentBuild + 29, // 11: netpulse.v1.Hello.started_at:type_name -> google.protobuf.Timestamp + 29, // 12: netpulse.v1.Heartbeat.ts:type_name -> google.protobuf.Timestamp + 31, // 13: netpulse.v1.Heartbeat.health:type_name -> netpulse.v1.AgentHealth + 0, // 14: netpulse.v1.TaskStatusUpdate.state:type_name -> netpulse.v1.TaskStatusUpdate.State + 29, // 15: netpulse.v1.TaskStatusUpdate.ts:type_name -> google.protobuf.Timestamp + 32, // 16: netpulse.v1.TaskStatusUpdate.error:type_name -> netpulse.v1.Error + 32, // 17: netpulse.v1.ModuleStatusUpdate.error:type_name -> netpulse.v1.Error + 29, // 18: netpulse.v1.Pong.agent_time:type_name -> google.protobuf.Timestamp + 1, // 19: netpulse.v1.AgentEvent.kind:type_name -> netpulse.v1.AgentEvent.Kind + 29, // 20: netpulse.v1.AgentEvent.ts:type_name -> google.protobuf.Timestamp + 26, // 21: netpulse.v1.AgentEvent.details:type_name -> netpulse.v1.AgentEvent.DetailsEntry + 14, // 22: netpulse.v1.ControlDown.welcome:type_name -> netpulse.v1.Welcome + 15, // 23: netpulse.v1.ControlDown.task_plan:type_name -> netpulse.v1.TaskPlan + 16, // 24: netpulse.v1.ControlDown.task_delta:type_name -> netpulse.v1.TaskDelta + 18, // 25: netpulse.v1.ControlDown.module_control:type_name -> netpulse.v1.ModuleControl + 20, // 26: netpulse.v1.ControlDown.credentials:type_name -> netpulse.v1.CredentialBundle + 33, // 27: netpulse.v1.ControlDown.config_job:type_name -> netpulse.v1.ConfigJob + 34, // 28: netpulse.v1.ControlDown.config_apply_job:type_name -> netpulse.v1.ConfigApplyJob + 22, // 29: netpulse.v1.ControlDown.discovery_request:type_name -> netpulse.v1.DiscoveryRequest + 23, // 30: netpulse.v1.ControlDown.ping:type_name -> netpulse.v1.Ping + 24, // 31: netpulse.v1.ControlDown.directive:type_name -> netpulse.v1.Directive + 29, // 32: netpulse.v1.Welcome.server_time:type_name -> google.protobuf.Timestamp + 35, // 33: netpulse.v1.Welcome.heartbeat_interval:type_name -> google.protobuf.Duration + 35, // 34: netpulse.v1.Welcome.telemetry_max_batch_interval:type_name -> google.protobuf.Duration + 17, // 35: netpulse.v1.TaskPlan.tasks:type_name -> netpulse.v1.Task + 36, // 36: netpulse.v1.TaskPlan.devices:type_name -> netpulse.v1.DeviceTarget + 17, // 37: netpulse.v1.TaskDelta.upsert:type_name -> netpulse.v1.Task + 36, // 38: netpulse.v1.TaskDelta.upsert_devices:type_name -> netpulse.v1.DeviceTarget + 35, // 39: netpulse.v1.Task.interval:type_name -> google.protobuf.Duration + 35, // 40: netpulse.v1.Task.timeout:type_name -> google.protobuf.Duration + 35, // 41: netpulse.v1.Task.schedule_offset:type_name -> google.protobuf.Duration + 19, // 42: netpulse.v1.ModuleControl.modules:type_name -> netpulse.v1.ModuleSpec + 27, // 43: netpulse.v1.CredentialBundle.by_device:type_name -> netpulse.v1.CredentialBundle.ByDeviceEntry + 29, // 44: netpulse.v1.CredentialBundle.expires_at:type_name -> google.protobuf.Timestamp + 37, // 45: netpulse.v1.CredentialList.credentials:type_name -> netpulse.v1.Credential + 38, // 46: netpulse.v1.DiscoveryRequest.protocols:type_name -> netpulse.v1.DiscoveryProto + 35, // 47: netpulse.v1.DiscoveryRequest.timeout:type_name -> google.protobuf.Duration + 29, // 48: netpulse.v1.Ping.server_time:type_name -> google.protobuf.Timestamp + 2, // 49: netpulse.v1.Directive.action:type_name -> netpulse.v1.Directive.Action + 35, // 50: netpulse.v1.Directive.reconnect_after:type_name -> google.protobuf.Duration + 25, // 51: netpulse.v1.Directive.update:type_name -> netpulse.v1.UpdateInfo + 21, // 52: netpulse.v1.CredentialBundle.ByDeviceEntry.value:type_name -> netpulse.v1.CredentialList + 3, // 53: netpulse.v1.EnrollmentService.Enroll:input_type -> netpulse.v1.EnrollRequest + 5, // 54: netpulse.v1.AgentService.Control:input_type -> netpulse.v1.ControlUp + 39, // 55: netpulse.v1.AgentService.StreamTelemetry:input_type -> netpulse.v1.TelemetryBatch + 40, // 56: netpulse.v1.AgentService.StreamLogs:input_type -> netpulse.v1.LogBatch + 41, // 57: netpulse.v1.AgentService.ReportDiscovery:input_type -> netpulse.v1.DiscoveryReport + 42, // 58: netpulse.v1.AgentService.UploadConfig:input_type -> netpulse.v1.ConfigUpload + 4, // 59: netpulse.v1.EnrollmentService.Enroll:output_type -> netpulse.v1.EnrollResponse + 13, // 60: netpulse.v1.AgentService.Control:output_type -> netpulse.v1.ControlDown + 43, // 61: netpulse.v1.AgentService.StreamTelemetry:output_type -> netpulse.v1.TelemetryAck + 44, // 62: netpulse.v1.AgentService.StreamLogs:output_type -> netpulse.v1.LogAck + 45, // 63: netpulse.v1.AgentService.ReportDiscovery:output_type -> netpulse.v1.DiscoveryAck + 46, // 64: netpulse.v1.AgentService.UploadConfig:output_type -> netpulse.v1.ConfigReceipt + 59, // [59:65] is the sub-list for method output_type + 53, // [53:59] is the sub-list for method input_type + 53, // [53:53] is the sub-list for extension type_name + 53, // [53:53] is the sub-list for extension extendee + 0, // [0:53] is the sub-list for field type_name +} + +func init() { file_netpulse_v1_agent_proto_init() } +func file_netpulse_v1_agent_proto_init() { + if File_netpulse_v1_agent_proto != nil { + return + } + file_netpulse_v1_common_proto_init() + file_netpulse_v1_discovery_proto_init() + file_netpulse_v1_logs_proto_init() + file_netpulse_v1_ncm_proto_init() + file_netpulse_v1_telemetry_proto_init() + file_netpulse_v1_agent_proto_msgTypes[2].OneofWrappers = []any{ + (*ControlUp_Hello)(nil), + (*ControlUp_Heartbeat)(nil), + (*ControlUp_TaskStatus)(nil), + (*ControlUp_ModuleStatus)(nil), + (*ControlUp_ConfigApplyResult)(nil), + (*ControlUp_CredentialRequest)(nil), + (*ControlUp_Pong)(nil), + (*ControlUp_Event)(nil), + } + file_netpulse_v1_agent_proto_msgTypes[10].OneofWrappers = []any{ + (*ControlDown_Welcome)(nil), + (*ControlDown_TaskPlan)(nil), + (*ControlDown_TaskDelta)(nil), + (*ControlDown_ModuleControl)(nil), + (*ControlDown_Credentials)(nil), + (*ControlDown_ConfigJob)(nil), + (*ControlDown_ConfigApplyJob)(nil), + (*ControlDown_DiscoveryRequest)(nil), + (*ControlDown_Ping)(nil), + (*ControlDown_Directive)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_netpulse_v1_agent_proto_rawDesc), len(file_netpulse_v1_agent_proto_rawDesc)), + NumEnums: 3, + NumMessages: 25, + NumExtensions: 0, + NumServices: 2, + }, + GoTypes: file_netpulse_v1_agent_proto_goTypes, + DependencyIndexes: file_netpulse_v1_agent_proto_depIdxs, + EnumInfos: file_netpulse_v1_agent_proto_enumTypes, + MessageInfos: file_netpulse_v1_agent_proto_msgTypes, + }.Build() + File_netpulse_v1_agent_proto = out.File + file_netpulse_v1_agent_proto_goTypes = nil + file_netpulse_v1_agent_proto_depIdxs = nil +} diff --git a/gen/go/netpulse/v1/agent_grpc.pb.go b/gen/go/netpulse/v1/agent_grpc.pb.go new file mode 100644 index 0000000..750cf58 --- /dev/null +++ b/gen/go/netpulse/v1/agent_grpc.pb.go @@ -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", +} diff --git a/gen/go/netpulse/v1/common.pb.go b/gen/go/netpulse/v1/common.pb.go new file mode 100644 index 0000000..c14bcc7 --- /dev/null +++ b/gen/go/netpulse/v1/common.pb.go @@ -0,0 +1,1072 @@ +// ===================================================================== +// NetPulse :: common.proto +// Спільні типи для всіх сервісів контракту агент↔сервер. +// ===================================================================== + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.12 +// protoc v3.21.12 +// source: netpulse/v1/common.proto + +package netpulsev1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + durationpb "google.golang.org/protobuf/types/known/durationpb" + 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) +) + +// Статус об'єкта. Дзеркалить inv.device_status у БД. +type Status int32 + +const ( + Status_STATUS_UNSPECIFIED Status = 0 + Status_STATUS_UP Status = 1 + Status_STATUS_DOWN Status = 2 + Status_STATUS_WARNING Status = 3 + Status_STATUS_UNKNOWN Status = 4 + Status_STATUS_MAINTENANCE Status = 5 +) + +// Enum value maps for Status. +var ( + Status_name = map[int32]string{ + 0: "STATUS_UNSPECIFIED", + 1: "STATUS_UP", + 2: "STATUS_DOWN", + 3: "STATUS_WARNING", + 4: "STATUS_UNKNOWN", + 5: "STATUS_MAINTENANCE", + } + Status_value = map[string]int32{ + "STATUS_UNSPECIFIED": 0, + "STATUS_UP": 1, + "STATUS_DOWN": 2, + "STATUS_WARNING": 3, + "STATUS_UNKNOWN": 4, + "STATUS_MAINTENANCE": 5, + } +) + +func (x Status) Enum() *Status { + p := new(Status) + *p = x + return p +} + +func (x Status) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (Status) Descriptor() protoreflect.EnumDescriptor { + return file_netpulse_v1_common_proto_enumTypes[0].Descriptor() +} + +func (Status) Type() protoreflect.EnumType { + return &file_netpulse_v1_common_proto_enumTypes[0] +} + +func (x Status) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use Status.Descriptor instead. +func (Status) EnumDescriptor() ([]byte, []int) { + return file_netpulse_v1_common_proto_rawDescGZIP(), []int{0} +} + +// Транспорт підключення до пристрою. Дзеркалить inv.credential_proto. +type Transport int32 + +const ( + Transport_TRANSPORT_UNSPECIFIED Transport = 0 + Transport_TRANSPORT_SSH Transport = 1 + Transport_TRANSPORT_TELNET Transport = 2 + Transport_TRANSPORT_SNMP_V2C Transport = 3 + Transport_TRANSPORT_SNMP_V3 Transport = 4 + Transport_TRANSPORT_HTTP Transport = 5 + Transport_TRANSPORT_HTTPS Transport = 6 + Transport_TRANSPORT_API Transport = 7 + Transport_TRANSPORT_MODBUS Transport = 8 +) + +// Enum value maps for Transport. +var ( + Transport_name = map[int32]string{ + 0: "TRANSPORT_UNSPECIFIED", + 1: "TRANSPORT_SSH", + 2: "TRANSPORT_TELNET", + 3: "TRANSPORT_SNMP_V2C", + 4: "TRANSPORT_SNMP_V3", + 5: "TRANSPORT_HTTP", + 6: "TRANSPORT_HTTPS", + 7: "TRANSPORT_API", + 8: "TRANSPORT_MODBUS", + } + Transport_value = map[string]int32{ + "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, + } +) + +func (x Transport) Enum() *Transport { + p := new(Transport) + *p = x + return p +} + +func (x Transport) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (Transport) Descriptor() protoreflect.EnumDescriptor { + return file_netpulse_v1_common_proto_enumTypes[1].Descriptor() +} + +func (Transport) Type() protoreflect.EnumType { + return &file_netpulse_v1_common_proto_enumTypes[1] +} + +func (x Transport) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use Transport.Descriptor instead. +func (Transport) EnumDescriptor() ([]byte, []int) { + return file_netpulse_v1_common_proto_rawDescGZIP(), []int{1} +} + +// Протокол автовиявлення сусідів. Дзеркалить topo.discovery_proto. +type DiscoveryProto int32 + +const ( + DiscoveryProto_DISCOVERY_PROTO_UNSPECIFIED DiscoveryProto = 0 + DiscoveryProto_DISCOVERY_PROTO_LLDP DiscoveryProto = 1 + DiscoveryProto_DISCOVERY_PROTO_CDP DiscoveryProto = 2 + DiscoveryProto_DISCOVERY_PROTO_ARP DiscoveryProto = 3 + DiscoveryProto_DISCOVERY_PROTO_FDB DiscoveryProto = 4 + DiscoveryProto_DISCOVERY_PROTO_STP DiscoveryProto = 5 + DiscoveryProto_DISCOVERY_PROTO_ROUTING DiscoveryProto = 6 + DiscoveryProto_DISCOVERY_PROTO_SNMP_TOPO DiscoveryProto = 7 +) + +// Enum value maps for DiscoveryProto. +var ( + DiscoveryProto_name = map[int32]string{ + 0: "DISCOVERY_PROTO_UNSPECIFIED", + 1: "DISCOVERY_PROTO_LLDP", + 2: "DISCOVERY_PROTO_CDP", + 3: "DISCOVERY_PROTO_ARP", + 4: "DISCOVERY_PROTO_FDB", + 5: "DISCOVERY_PROTO_STP", + 6: "DISCOVERY_PROTO_ROUTING", + 7: "DISCOVERY_PROTO_SNMP_TOPO", + } + DiscoveryProto_value = map[string]int32{ + "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, + } +) + +func (x DiscoveryProto) Enum() *DiscoveryProto { + p := new(DiscoveryProto) + *p = x + return p +} + +func (x DiscoveryProto) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (DiscoveryProto) Descriptor() protoreflect.EnumDescriptor { + return file_netpulse_v1_common_proto_enumTypes[2].Descriptor() +} + +func (DiscoveryProto) Type() protoreflect.EnumType { + return &file_netpulse_v1_common_proto_enumTypes[2] +} + +func (x DiscoveryProto) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use DiscoveryProto.Descriptor instead. +func (DiscoveryProto) EnumDescriptor() ([]byte, []int) { + return file_netpulse_v1_common_proto_rawDescGZIP(), []int{2} +} + +type SnmpV3Options_SecurityLevel int32 + +const ( + SnmpV3Options_SECURITY_LEVEL_UNSPECIFIED SnmpV3Options_SecurityLevel = 0 + SnmpV3Options_SECURITY_LEVEL_NO_AUTH_NO_PRIV SnmpV3Options_SecurityLevel = 1 + SnmpV3Options_SECURITY_LEVEL_AUTH_NO_PRIV SnmpV3Options_SecurityLevel = 2 + SnmpV3Options_SECURITY_LEVEL_AUTH_PRIV SnmpV3Options_SecurityLevel = 3 +) + +// Enum value maps for SnmpV3Options_SecurityLevel. +var ( + SnmpV3Options_SecurityLevel_name = map[int32]string{ + 0: "SECURITY_LEVEL_UNSPECIFIED", + 1: "SECURITY_LEVEL_NO_AUTH_NO_PRIV", + 2: "SECURITY_LEVEL_AUTH_NO_PRIV", + 3: "SECURITY_LEVEL_AUTH_PRIV", + } + SnmpV3Options_SecurityLevel_value = map[string]int32{ + "SECURITY_LEVEL_UNSPECIFIED": 0, + "SECURITY_LEVEL_NO_AUTH_NO_PRIV": 1, + "SECURITY_LEVEL_AUTH_NO_PRIV": 2, + "SECURITY_LEVEL_AUTH_PRIV": 3, + } +) + +func (x SnmpV3Options_SecurityLevel) Enum() *SnmpV3Options_SecurityLevel { + p := new(SnmpV3Options_SecurityLevel) + *p = x + return p +} + +func (x SnmpV3Options_SecurityLevel) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (SnmpV3Options_SecurityLevel) Descriptor() protoreflect.EnumDescriptor { + return file_netpulse_v1_common_proto_enumTypes[3].Descriptor() +} + +func (SnmpV3Options_SecurityLevel) Type() protoreflect.EnumType { + return &file_netpulse_v1_common_proto_enumTypes[3] +} + +func (x SnmpV3Options_SecurityLevel) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use SnmpV3Options_SecurityLevel.Descriptor instead. +func (SnmpV3Options_SecurityLevel) EnumDescriptor() ([]byte, []int) { + return file_netpulse_v1_common_proto_rawDescGZIP(), []int{3, 0} +} + +// Помилка виконання. Свідомо не gRPC-статус: одна задача може впасти, +// поки решта потоку жива, тому помилки їдуть у корисному навантаженні. +type Error struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Стабільний машинний код: "timeout", "auth_failed", "unreachable", + // "snmp_no_such_object", "prompt_mismatch", "permission_denied". + Code string `protobuf:"bytes,1,opt,name=code,proto3" json:"code,omitempty"` + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + // Чи має сенс повторювати. Сервер вирішує, чи планувати retry. + Retryable bool `protobuf:"varint,3,opt,name=retryable,proto3" json:"retryable,omitempty"` + Details map[string]string `protobuf:"bytes,4,rep,name=details,proto3" json:"details,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Error) Reset() { + *x = Error{} + mi := &file_netpulse_v1_common_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Error) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Error) ProtoMessage() {} + +func (x *Error) ProtoReflect() protoreflect.Message { + mi := &file_netpulse_v1_common_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 Error.ProtoReflect.Descriptor instead. +func (*Error) Descriptor() ([]byte, []int) { + return file_netpulse_v1_common_proto_rawDescGZIP(), []int{0} +} + +func (x *Error) GetCode() string { + if x != nil { + return x.Code + } + return "" +} + +func (x *Error) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *Error) GetRetryable() bool { + if x != nil { + return x.Retryable + } + return false +} + +func (x *Error) GetDetails() map[string]string { + if x != nil { + return x.Details + } + return nil +} + +// Мінімальний опис пристрою, потрібний агенту для опитування. +// Агент не має жодного уявлення про тенанти, тарифи чи мапи — +// це навмисно: зонд знає лише "куди йти і як". +type DeviceTarget struct { + state protoimpl.MessageState `protogen:"open.v1"` + DeviceId string `protobuf:"bytes,1,opt,name=device_id,json=deviceId,proto3" json:"device_id,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + // Адреса опитування: IP або FQDN. + Address string `protobuf:"bytes,3,opt,name=address,proto3" json:"address,omitempty"` + // Ідентифікатори для кореляції автовиявлення (topo.neighbors). + ChassisId string `protobuf:"bytes,4,opt,name=chassis_id,json=chassisId,proto3" json:"chassis_id,omitempty"` + SystemName string `protobuf:"bytes,5,opt,name=system_name,json=systemName,proto3" json:"system_name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeviceTarget) Reset() { + *x = DeviceTarget{} + mi := &file_netpulse_v1_common_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeviceTarget) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeviceTarget) ProtoMessage() {} + +func (x *DeviceTarget) ProtoReflect() protoreflect.Message { + mi := &file_netpulse_v1_common_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 DeviceTarget.ProtoReflect.Descriptor instead. +func (*DeviceTarget) Descriptor() ([]byte, []int) { + return file_netpulse_v1_common_proto_rawDescGZIP(), []int{1} +} + +func (x *DeviceTarget) GetDeviceId() string { + if x != nil { + return x.DeviceId + } + return "" +} + +func (x *DeviceTarget) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *DeviceTarget) GetAddress() string { + if x != nil { + return x.Address + } + return "" +} + +func (x *DeviceTarget) GetChassisId() string { + if x != nil { + return x.ChassisId + } + return "" +} + +func (x *DeviceTarget) GetSystemName() string { + if x != nil { + return x.SystemName + } + return "" +} + +// Облікові дані. Приходять із сервера вже РОЗШИФРОВАНИМИ. +// +// БЕЗПЕКА: +// - передаються виключно всередині mTLS-каналу; +// - агент тримає їх лише в пам'яті, ніколи не пише на диск і не логує; +// - мають TTL (expires_at) — після спливу агент зобов'язаний запитати +// новий комплект, а старий занулити. +type Credential struct { + state protoimpl.MessageState `protogen:"open.v1"` + CredentialId string `protobuf:"bytes,1,opt,name=credential_id,json=credentialId,proto3" json:"credential_id,omitempty"` + Transport Transport `protobuf:"varint,2,opt,name=transport,proto3,enum=netpulse.v1.Transport" json:"transport,omitempty"` + Username string `protobuf:"bytes,3,opt,name=username,proto3" json:"username,omitempty"` + Port uint32 `protobuf:"varint,4,opt,name=port,proto3" json:"port,omitempty"` + // Types that are valid to be assigned to Secret: + // + // *Credential_Password + // *Credential_PrivateKey + // *Credential_Community + // *Credential_Token + Secret isCredential_Secret `protobuf_oneof:"secret"` + // Enable/privileged-пароль (Cisco-подібні). + EnablePassword string `protobuf:"bytes,9,opt,name=enable_password,json=enablePassword,proto3" json:"enable_password,omitempty"` + // Параметри SNMPv3. + SnmpV3 *SnmpV3Options `protobuf:"bytes,10,opt,name=snmp_v3,json=snmpV3,proto3" json:"snmp_v3,omitempty"` + ExpiresAt *timestamppb.Timestamp `protobuf:"bytes,11,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Credential) Reset() { + *x = Credential{} + mi := &file_netpulse_v1_common_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Credential) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Credential) ProtoMessage() {} + +func (x *Credential) ProtoReflect() protoreflect.Message { + mi := &file_netpulse_v1_common_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 Credential.ProtoReflect.Descriptor instead. +func (*Credential) Descriptor() ([]byte, []int) { + return file_netpulse_v1_common_proto_rawDescGZIP(), []int{2} +} + +func (x *Credential) GetCredentialId() string { + if x != nil { + return x.CredentialId + } + return "" +} + +func (x *Credential) GetTransport() Transport { + if x != nil { + return x.Transport + } + return Transport_TRANSPORT_UNSPECIFIED +} + +func (x *Credential) GetUsername() string { + if x != nil { + return x.Username + } + return "" +} + +func (x *Credential) GetPort() uint32 { + if x != nil { + return x.Port + } + return 0 +} + +func (x *Credential) GetSecret() isCredential_Secret { + if x != nil { + return x.Secret + } + return nil +} + +func (x *Credential) GetPassword() string { + if x != nil { + if x, ok := x.Secret.(*Credential_Password); ok { + return x.Password + } + } + return "" +} + +func (x *Credential) GetPrivateKey() []byte { + if x != nil { + if x, ok := x.Secret.(*Credential_PrivateKey); ok { + return x.PrivateKey + } + } + return nil +} + +func (x *Credential) GetCommunity() string { + if x != nil { + if x, ok := x.Secret.(*Credential_Community); ok { + return x.Community + } + } + return "" +} + +func (x *Credential) GetToken() string { + if x != nil { + if x, ok := x.Secret.(*Credential_Token); ok { + return x.Token + } + } + return "" +} + +func (x *Credential) GetEnablePassword() string { + if x != nil { + return x.EnablePassword + } + return "" +} + +func (x *Credential) GetSnmpV3() *SnmpV3Options { + if x != nil { + return x.SnmpV3 + } + return nil +} + +func (x *Credential) GetExpiresAt() *timestamppb.Timestamp { + if x != nil { + return x.ExpiresAt + } + return nil +} + +type isCredential_Secret interface { + isCredential_Secret() +} + +type Credential_Password struct { + Password string `protobuf:"bytes,5,opt,name=password,proto3,oneof"` +} + +type Credential_PrivateKey struct { + PrivateKey []byte `protobuf:"bytes,6,opt,name=private_key,json=privateKey,proto3,oneof"` +} + +type Credential_Community struct { + Community string `protobuf:"bytes,7,opt,name=community,proto3,oneof"` // SNMP v2c +} + +type Credential_Token struct { + Token string `protobuf:"bytes,8,opt,name=token,proto3,oneof"` // HTTP/API +} + +func (*Credential_Password) isCredential_Secret() {} + +func (*Credential_PrivateKey) isCredential_Secret() {} + +func (*Credential_Community) isCredential_Secret() {} + +func (*Credential_Token) isCredential_Secret() {} + +type SnmpV3Options struct { + state protoimpl.MessageState `protogen:"open.v1"` + Level SnmpV3Options_SecurityLevel `protobuf:"varint,1,opt,name=level,proto3,enum=netpulse.v1.SnmpV3Options_SecurityLevel" json:"level,omitempty"` + AuthProtocol string `protobuf:"bytes,2,opt,name=auth_protocol,json=authProtocol,proto3" json:"auth_protocol,omitempty"` // MD5 | SHA | SHA224 | SHA256 | SHA384 | SHA512 + AuthPassword string `protobuf:"bytes,3,opt,name=auth_password,json=authPassword,proto3" json:"auth_password,omitempty"` + PrivProtocol string `protobuf:"bytes,4,opt,name=priv_protocol,json=privProtocol,proto3" json:"priv_protocol,omitempty"` // DES | AES | AES192 | AES256 + PrivPassword string `protobuf:"bytes,5,opt,name=priv_password,json=privPassword,proto3" json:"priv_password,omitempty"` + ContextName string `protobuf:"bytes,6,opt,name=context_name,json=contextName,proto3" json:"context_name,omitempty"` + SecurityName string `protobuf:"bytes,7,opt,name=security_name,json=securityName,proto3" json:"security_name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SnmpV3Options) Reset() { + *x = SnmpV3Options{} + mi := &file_netpulse_v1_common_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SnmpV3Options) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SnmpV3Options) ProtoMessage() {} + +func (x *SnmpV3Options) ProtoReflect() protoreflect.Message { + mi := &file_netpulse_v1_common_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 SnmpV3Options.ProtoReflect.Descriptor instead. +func (*SnmpV3Options) Descriptor() ([]byte, []int) { + return file_netpulse_v1_common_proto_rawDescGZIP(), []int{3} +} + +func (x *SnmpV3Options) GetLevel() SnmpV3Options_SecurityLevel { + if x != nil { + return x.Level + } + return SnmpV3Options_SECURITY_LEVEL_UNSPECIFIED +} + +func (x *SnmpV3Options) GetAuthProtocol() string { + if x != nil { + return x.AuthProtocol + } + return "" +} + +func (x *SnmpV3Options) GetAuthPassword() string { + if x != nil { + return x.AuthPassword + } + return "" +} + +func (x *SnmpV3Options) GetPrivProtocol() string { + if x != nil { + return x.PrivProtocol + } + return "" +} + +func (x *SnmpV3Options) GetPrivPassword() string { + if x != nil { + return x.PrivPassword + } + return "" +} + +func (x *SnmpV3Options) GetContextName() string { + if x != nil { + return x.ContextName + } + return "" +} + +func (x *SnmpV3Options) GetSecurityName() string { + if x != nil { + return x.SecurityName + } + return "" +} + +// Опис версії агента — для сумісності й самооновлення. +type AgentBuild struct { + state protoimpl.MessageState `protogen:"open.v1"` + Version string `protobuf:"bytes,1,opt,name=version,proto3" json:"version,omitempty"` // 1.4.2 + Commit string `protobuf:"bytes,2,opt,name=commit,proto3" json:"commit,omitempty"` + Os string `protobuf:"bytes,3,opt,name=os,proto3" json:"os,omitempty"` // linux | windows | darwin + Arch string `protobuf:"bytes,4,opt,name=arch,proto3" json:"arch,omitempty"` // amd64 | arm64 + GoVersion string `protobuf:"bytes,5,opt,name=go_version,json=goVersion,proto3" json:"go_version,omitempty"` + // Модулі, вкомпільовані в цей бінарник (не обов'язково активні). + CompiledModules []string `protobuf:"bytes,6,rep,name=compiled_modules,json=compiledModules,proto3" json:"compiled_modules,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AgentBuild) Reset() { + *x = AgentBuild{} + mi := &file_netpulse_v1_common_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AgentBuild) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AgentBuild) ProtoMessage() {} + +func (x *AgentBuild) ProtoReflect() protoreflect.Message { + mi := &file_netpulse_v1_common_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 AgentBuild.ProtoReflect.Descriptor instead. +func (*AgentBuild) Descriptor() ([]byte, []int) { + return file_netpulse_v1_common_proto_rawDescGZIP(), []int{4} +} + +func (x *AgentBuild) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +func (x *AgentBuild) GetCommit() string { + if x != nil { + return x.Commit + } + return "" +} + +func (x *AgentBuild) GetOs() string { + if x != nil { + return x.Os + } + return "" +} + +func (x *AgentBuild) GetArch() string { + if x != nil { + return x.Arch + } + return "" +} + +func (x *AgentBuild) GetGoVersion() string { + if x != nil { + return x.GoVersion + } + return "" +} + +func (x *AgentBuild) GetCompiledModules() []string { + if x != nil { + return x.CompiledModules + } + return nil +} + +// Самометрики агента. Лягають у ts.agent_health. +type AgentHealth struct { + state protoimpl.MessageState `protogen:"open.v1"` + CpuPct float32 `protobuf:"fixed32,1,opt,name=cpu_pct,json=cpuPct,proto3" json:"cpu_pct,omitempty"` + RssBytes uint64 `protobuf:"varint,2,opt,name=rss_bytes,json=rssBytes,proto3" json:"rss_bytes,omitempty"` // цільовий бюджет: < 30 МБ + Goroutines uint32 `protobuf:"varint,3,opt,name=goroutines,proto3" json:"goroutines,omitempty"` + QueueDepth uint32 `protobuf:"varint,4,opt,name=queue_depth,json=queueDepth,proto3" json:"queue_depth,omitempty"` // скільки результатів чекає на відправку + ChecksPerSec float32 `protobuf:"fixed32,5,opt,name=checks_per_sec,json=checksPerSec,proto3" json:"checks_per_sec,omitempty"` + ErrorsPerMin float32 `protobuf:"fixed32,6,opt,name=errors_per_min,json=errorsPerMin,proto3" json:"errors_per_min,omitempty"` + // Скільки семплів довелося викинути через переповнення буфера. + // Ненульове значення — привід підняти алерт на самого агента. + DroppedSamples uint64 `protobuf:"varint,7,opt,name=dropped_samples,json=droppedSamples,proto3" json:"dropped_samples,omitempty"` + Uptime *durationpb.Duration `protobuf:"bytes,8,opt,name=uptime,proto3" json:"uptime,omitempty"` + // Розсинхронізація годинника з сервером; агент рахує її з Welcome.server_time. + ClockSkew *durationpb.Duration `protobuf:"bytes,9,opt,name=clock_skew,json=clockSkew,proto3" json:"clock_skew,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AgentHealth) Reset() { + *x = AgentHealth{} + mi := &file_netpulse_v1_common_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AgentHealth) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AgentHealth) ProtoMessage() {} + +func (x *AgentHealth) ProtoReflect() protoreflect.Message { + mi := &file_netpulse_v1_common_proto_msgTypes[5] + 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 AgentHealth.ProtoReflect.Descriptor instead. +func (*AgentHealth) Descriptor() ([]byte, []int) { + return file_netpulse_v1_common_proto_rawDescGZIP(), []int{5} +} + +func (x *AgentHealth) GetCpuPct() float32 { + if x != nil { + return x.CpuPct + } + return 0 +} + +func (x *AgentHealth) GetRssBytes() uint64 { + if x != nil { + return x.RssBytes + } + return 0 +} + +func (x *AgentHealth) GetGoroutines() uint32 { + if x != nil { + return x.Goroutines + } + return 0 +} + +func (x *AgentHealth) GetQueueDepth() uint32 { + if x != nil { + return x.QueueDepth + } + return 0 +} + +func (x *AgentHealth) GetChecksPerSec() float32 { + if x != nil { + return x.ChecksPerSec + } + return 0 +} + +func (x *AgentHealth) GetErrorsPerMin() float32 { + if x != nil { + return x.ErrorsPerMin + } + return 0 +} + +func (x *AgentHealth) GetDroppedSamples() uint64 { + if x != nil { + return x.DroppedSamples + } + return 0 +} + +func (x *AgentHealth) GetUptime() *durationpb.Duration { + if x != nil { + return x.Uptime + } + return nil +} + +func (x *AgentHealth) GetClockSkew() *durationpb.Duration { + if x != nil { + return x.ClockSkew + } + return nil +} + +var File_netpulse_v1_common_proto protoreflect.FileDescriptor + +const file_netpulse_v1_common_proto_rawDesc = "" + + "\n" + + "\x18netpulse/v1/common.proto\x12\vnetpulse.v1\x1a\x1egoogle/protobuf/duration.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xca\x01\n" + + "\x05Error\x12\x12\n" + + "\x04code\x18\x01 \x01(\tR\x04code\x12\x18\n" + + "\amessage\x18\x02 \x01(\tR\amessage\x12\x1c\n" + + "\tretryable\x18\x03 \x01(\bR\tretryable\x129\n" + + "\adetails\x18\x04 \x03(\v2\x1f.netpulse.v1.Error.DetailsEntryR\adetails\x1a:\n" + + "\fDetailsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x99\x01\n" + + "\fDeviceTarget\x12\x1b\n" + + "\tdevice_id\x18\x01 \x01(\tR\bdeviceId\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\x12\x18\n" + + "\aaddress\x18\x03 \x01(\tR\aaddress\x12\x1d\n" + + "\n" + + "chassis_id\x18\x04 \x01(\tR\tchassisId\x12\x1f\n" + + "\vsystem_name\x18\x05 \x01(\tR\n" + + "systemName\"\xb3\x03\n" + + "\n" + + "Credential\x12#\n" + + "\rcredential_id\x18\x01 \x01(\tR\fcredentialId\x124\n" + + "\ttransport\x18\x02 \x01(\x0e2\x16.netpulse.v1.TransportR\ttransport\x12\x1a\n" + + "\busername\x18\x03 \x01(\tR\busername\x12\x12\n" + + "\x04port\x18\x04 \x01(\rR\x04port\x12\x1c\n" + + "\bpassword\x18\x05 \x01(\tH\x00R\bpassword\x12!\n" + + "\vprivate_key\x18\x06 \x01(\fH\x00R\n" + + "privateKey\x12\x1e\n" + + "\tcommunity\x18\a \x01(\tH\x00R\tcommunity\x12\x16\n" + + "\x05token\x18\b \x01(\tH\x00R\x05token\x12'\n" + + "\x0fenable_password\x18\t \x01(\tR\x0eenablePassword\x123\n" + + "\asnmp_v3\x18\n" + + " \x01(\v2\x1a.netpulse.v1.SnmpV3OptionsR\x06snmpV3\x129\n" + + "\n" + + "expires_at\x18\v \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAtB\b\n" + + "\x06secret\"\xc0\x03\n" + + "\rSnmpV3Options\x12>\n" + + "\x05level\x18\x01 \x01(\x0e2(.netpulse.v1.SnmpV3Options.SecurityLevelR\x05level\x12#\n" + + "\rauth_protocol\x18\x02 \x01(\tR\fauthProtocol\x12#\n" + + "\rauth_password\x18\x03 \x01(\tR\fauthPassword\x12#\n" + + "\rpriv_protocol\x18\x04 \x01(\tR\fprivProtocol\x12#\n" + + "\rpriv_password\x18\x05 \x01(\tR\fprivPassword\x12!\n" + + "\fcontext_name\x18\x06 \x01(\tR\vcontextName\x12#\n" + + "\rsecurity_name\x18\a \x01(\tR\fsecurityName\"\x92\x01\n" + + "\rSecurityLevel\x12\x1e\n" + + "\x1aSECURITY_LEVEL_UNSPECIFIED\x10\x00\x12\"\n" + + "\x1eSECURITY_LEVEL_NO_AUTH_NO_PRIV\x10\x01\x12\x1f\n" + + "\x1bSECURITY_LEVEL_AUTH_NO_PRIV\x10\x02\x12\x1c\n" + + "\x18SECURITY_LEVEL_AUTH_PRIV\x10\x03\"\xac\x01\n" + + "\n" + + "AgentBuild\x12\x18\n" + + "\aversion\x18\x01 \x01(\tR\aversion\x12\x16\n" + + "\x06commit\x18\x02 \x01(\tR\x06commit\x12\x0e\n" + + "\x02os\x18\x03 \x01(\tR\x02os\x12\x12\n" + + "\x04arch\x18\x04 \x01(\tR\x04arch\x12\x1d\n" + + "\n" + + "go_version\x18\x05 \x01(\tR\tgoVersion\x12)\n" + + "\x10compiled_modules\x18\x06 \x03(\tR\x0fcompiledModules\"\xe6\x02\n" + + "\vAgentHealth\x12\x17\n" + + "\acpu_pct\x18\x01 \x01(\x02R\x06cpuPct\x12\x1b\n" + + "\trss_bytes\x18\x02 \x01(\x04R\brssBytes\x12\x1e\n" + + "\n" + + "goroutines\x18\x03 \x01(\rR\n" + + "goroutines\x12\x1f\n" + + "\vqueue_depth\x18\x04 \x01(\rR\n" + + "queueDepth\x12$\n" + + "\x0echecks_per_sec\x18\x05 \x01(\x02R\fchecksPerSec\x12$\n" + + "\x0eerrors_per_min\x18\x06 \x01(\x02R\ferrorsPerMin\x12'\n" + + "\x0fdropped_samples\x18\a \x01(\x04R\x0edroppedSamples\x121\n" + + "\x06uptime\x18\b \x01(\v2\x19.google.protobuf.DurationR\x06uptime\x128\n" + + "\n" + + "clock_skew\x18\t \x01(\v2\x19.google.protobuf.DurationR\tclockSkew*\x80\x01\n" + + "\x06Status\x12\x16\n" + + "\x12STATUS_UNSPECIFIED\x10\x00\x12\r\n" + + "\tSTATUS_UP\x10\x01\x12\x0f\n" + + "\vSTATUS_DOWN\x10\x02\x12\x12\n" + + "\x0eSTATUS_WARNING\x10\x03\x12\x12\n" + + "\x0eSTATUS_UNKNOWN\x10\x04\x12\x16\n" + + "\x12STATUS_MAINTENANCE\x10\x05*\xd0\x01\n" + + "\tTransport\x12\x19\n" + + "\x15TRANSPORT_UNSPECIFIED\x10\x00\x12\x11\n" + + "\rTRANSPORT_SSH\x10\x01\x12\x14\n" + + "\x10TRANSPORT_TELNET\x10\x02\x12\x16\n" + + "\x12TRANSPORT_SNMP_V2C\x10\x03\x12\x15\n" + + "\x11TRANSPORT_SNMP_V3\x10\x04\x12\x12\n" + + "\x0eTRANSPORT_HTTP\x10\x05\x12\x13\n" + + "\x0fTRANSPORT_HTTPS\x10\x06\x12\x11\n" + + "\rTRANSPORT_API\x10\a\x12\x14\n" + + "\x10TRANSPORT_MODBUS\x10\b*\xeb\x01\n" + + "\x0eDiscoveryProto\x12\x1f\n" + + "\x1bDISCOVERY_PROTO_UNSPECIFIED\x10\x00\x12\x18\n" + + "\x14DISCOVERY_PROTO_LLDP\x10\x01\x12\x17\n" + + "\x13DISCOVERY_PROTO_CDP\x10\x02\x12\x17\n" + + "\x13DISCOVERY_PROTO_ARP\x10\x03\x12\x17\n" + + "\x13DISCOVERY_PROTO_FDB\x10\x04\x12\x17\n" + + "\x13DISCOVERY_PROTO_STP\x10\x05\x12\x1b\n" + + "\x17DISCOVERY_PROTO_ROUTING\x10\x06\x12\x1d\n" + + "\x19DISCOVERY_PROTO_SNMP_TOPO\x10\aB netpulse.v1.Error.DetailsEntry + 1, // 1: netpulse.v1.Credential.transport:type_name -> netpulse.v1.Transport + 7, // 2: netpulse.v1.Credential.snmp_v3:type_name -> netpulse.v1.SnmpV3Options + 11, // 3: netpulse.v1.Credential.expires_at:type_name -> google.protobuf.Timestamp + 3, // 4: netpulse.v1.SnmpV3Options.level:type_name -> netpulse.v1.SnmpV3Options.SecurityLevel + 12, // 5: netpulse.v1.AgentHealth.uptime:type_name -> google.protobuf.Duration + 12, // 6: netpulse.v1.AgentHealth.clock_skew:type_name -> google.protobuf.Duration + 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_common_proto_init() } +func file_netpulse_v1_common_proto_init() { + if File_netpulse_v1_common_proto != nil { + return + } + file_netpulse_v1_common_proto_msgTypes[2].OneofWrappers = []any{ + (*Credential_Password)(nil), + (*Credential_PrivateKey)(nil), + (*Credential_Community)(nil), + (*Credential_Token)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_netpulse_v1_common_proto_rawDesc), len(file_netpulse_v1_common_proto_rawDesc)), + NumEnums: 4, + NumMessages: 7, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_netpulse_v1_common_proto_goTypes, + DependencyIndexes: file_netpulse_v1_common_proto_depIdxs, + EnumInfos: file_netpulse_v1_common_proto_enumTypes, + MessageInfos: file_netpulse_v1_common_proto_msgTypes, + }.Build() + File_netpulse_v1_common_proto = out.File + file_netpulse_v1_common_proto_goTypes = nil + file_netpulse_v1_common_proto_depIdxs = nil +} diff --git a/gen/go/netpulse/v1/discovery.pb.go b/gen/go/netpulse/v1/discovery.pb.go new file mode 100644 index 0000000..ba1a491 --- /dev/null +++ b/gen/go/netpulse/v1/discovery.pb.go @@ -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 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 +} diff --git a/gen/go/netpulse/v1/logs.pb.go b/gen/go/netpulse/v1/logs.pb.go new file mode 100644 index 0000000..d7f10ac --- /dev/null +++ b/gen/go/netpulse/v1/logs.pb.go @@ -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 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 +} diff --git a/gen/go/netpulse/v1/ncm.pb.go b/gen/go/netpulse/v1/ncm.pb.go new file mode 100644 index 0000000..018573c --- /dev/null +++ b/gen/go/netpulse/v1/ncm.pb.go @@ -0,0 +1,1093 @@ +// ===================================================================== +// NetPulse :: ncm.proto +// Збір конфігурацій (SSH/Telnet/API) та застосування відкату. +// +// Розподіл відповідальності: +// Агент — відкриває сесію, виконує команди, віддає сирий текст. +// Сервер — scrub/redact, хешування, коміт у Git, diff, compliance. +// +// Це навмисно: правила очищення живуть у ncm.profiles і мають +// змінюватись без оновлення агентів у полі. +// ===================================================================== + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.12 +// protoc v3.21.12 +// source: netpulse/v1/ncm.proto + +package netpulsev1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + durationpb "google.golang.org/protobuf/types/known/durationpb" + 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 ConfigJob struct { + state protoimpl.MessageState `protogen:"open.v1"` + JobId string `protobuf:"bytes,1,opt,name=job_id,json=jobId,proto3" json:"job_id,omitempty"` + Device *DeviceTarget `protobuf:"bytes,2,opt,name=device,proto3" json:"device,omitempty"` + Credential *Credential `protobuf:"bytes,3,opt,name=credential,proto3" json:"credential,omitempty"` + Transport Transport `protobuf:"varint,4,opt,name=transport,proto3,enum=netpulse.v1.Transport" json:"transport,omitempty"` + Port uint32 `protobuf:"varint,5,opt,name=port,proto3" json:"port,omitempty"` + // Команди по черзі: ["terminal length 0", "show running-config"]. + // Вивід останньої команди вважається тілом конфігу; вивід попередніх + // (підготовчих) відкидається. + Commands []string `protobuf:"bytes,6,rep,name=commands,proto3" json:"commands,omitempty"` + // Регекс запрошення командного рядка — за ним агент розуміє, + // що команда відпрацювала. + PromptRegex string `protobuf:"bytes,7,opt,name=prompt_regex,json=promptRegex,proto3" json:"prompt_regex,omitempty"` + // Потрібен enable/privileged режим перед виконанням. + EnableRequired bool `protobuf:"varint,8,opt,name=enable_required,json=enableRequired,proto3" json:"enable_required,omitempty"` + EnablePromptRegex string `protobuf:"bytes,9,opt,name=enable_prompt_regex,json=enablePromptRegex,proto3" json:"enable_prompt_regex,omitempty"` + // running | startup | vlan | license | inventory + ConfigType string `protobuf:"bytes,10,opt,name=config_type,json=configType,proto3" json:"config_type,omitempty"` + Timeout *durationpb.Duration `protobuf:"bytes,11,opt,name=timeout,proto3" json:"timeout,omitempty"` + // Ліміт розміру: захист від пристрою, що віддає гігабайт сміття. + MaxBytes uint64 `protobuf:"varint,12,opt,name=max_bytes,json=maxBytes,proto3" json:"max_bytes,omitempty"` + // Записувати повний транскрипт сесії (для діагностики prompt_regex). + CaptureTranscript bool `protobuf:"varint,13,opt,name=capture_transcript,json=captureTranscript,proto3" json:"capture_transcript,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConfigJob) Reset() { + *x = ConfigJob{} + mi := &file_netpulse_v1_ncm_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConfigJob) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConfigJob) ProtoMessage() {} + +func (x *ConfigJob) ProtoReflect() protoreflect.Message { + mi := &file_netpulse_v1_ncm_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 ConfigJob.ProtoReflect.Descriptor instead. +func (*ConfigJob) Descriptor() ([]byte, []int) { + return file_netpulse_v1_ncm_proto_rawDescGZIP(), []int{0} +} + +func (x *ConfigJob) GetJobId() string { + if x != nil { + return x.JobId + } + return "" +} + +func (x *ConfigJob) GetDevice() *DeviceTarget { + if x != nil { + return x.Device + } + return nil +} + +func (x *ConfigJob) GetCredential() *Credential { + if x != nil { + return x.Credential + } + return nil +} + +func (x *ConfigJob) GetTransport() Transport { + if x != nil { + return x.Transport + } + return Transport_TRANSPORT_UNSPECIFIED +} + +func (x *ConfigJob) GetPort() uint32 { + if x != nil { + return x.Port + } + return 0 +} + +func (x *ConfigJob) GetCommands() []string { + if x != nil { + return x.Commands + } + return nil +} + +func (x *ConfigJob) GetPromptRegex() string { + if x != nil { + return x.PromptRegex + } + return "" +} + +func (x *ConfigJob) GetEnableRequired() bool { + if x != nil { + return x.EnableRequired + } + return false +} + +func (x *ConfigJob) GetEnablePromptRegex() string { + if x != nil { + return x.EnablePromptRegex + } + return "" +} + +func (x *ConfigJob) GetConfigType() string { + if x != nil { + return x.ConfigType + } + return "" +} + +func (x *ConfigJob) GetTimeout() *durationpb.Duration { + if x != nil { + return x.Timeout + } + return nil +} + +func (x *ConfigJob) GetMaxBytes() uint64 { + if x != nil { + return x.MaxBytes + } + return 0 +} + +func (x *ConfigJob) GetCaptureTranscript() bool { + if x != nil { + return x.CaptureTranscript + } + return false +} + +type ConfigUpload struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Part: + // + // *ConfigUpload_Header + // *ConfigUpload_Chunk + // *ConfigUpload_Trailer + Part isConfigUpload_Part `protobuf_oneof:"part"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConfigUpload) Reset() { + *x = ConfigUpload{} + mi := &file_netpulse_v1_ncm_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConfigUpload) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConfigUpload) ProtoMessage() {} + +func (x *ConfigUpload) ProtoReflect() protoreflect.Message { + mi := &file_netpulse_v1_ncm_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 ConfigUpload.ProtoReflect.Descriptor instead. +func (*ConfigUpload) Descriptor() ([]byte, []int) { + return file_netpulse_v1_ncm_proto_rawDescGZIP(), []int{1} +} + +func (x *ConfigUpload) GetPart() isConfigUpload_Part { + if x != nil { + return x.Part + } + return nil +} + +func (x *ConfigUpload) GetHeader() *ConfigHeader { + if x != nil { + if x, ok := x.Part.(*ConfigUpload_Header); ok { + return x.Header + } + } + return nil +} + +func (x *ConfigUpload) GetChunk() *ConfigChunk { + if x != nil { + if x, ok := x.Part.(*ConfigUpload_Chunk); ok { + return x.Chunk + } + } + return nil +} + +func (x *ConfigUpload) GetTrailer() *ConfigTrailer { + if x != nil { + if x, ok := x.Part.(*ConfigUpload_Trailer); ok { + return x.Trailer + } + } + return nil +} + +type isConfigUpload_Part interface { + isConfigUpload_Part() +} + +type ConfigUpload_Header struct { + Header *ConfigHeader `protobuf:"bytes,1,opt,name=header,proto3,oneof"` +} + +type ConfigUpload_Chunk struct { + Chunk *ConfigChunk `protobuf:"bytes,2,opt,name=chunk,proto3,oneof"` +} + +type ConfigUpload_Trailer struct { + Trailer *ConfigTrailer `protobuf:"bytes,3,opt,name=trailer,proto3,oneof"` +} + +func (*ConfigUpload_Header) isConfigUpload_Part() {} + +func (*ConfigUpload_Chunk) isConfigUpload_Part() {} + +func (*ConfigUpload_Trailer) isConfigUpload_Part() {} + +type ConfigHeader struct { + state protoimpl.MessageState `protogen:"open.v1"` + JobId string `protobuf:"bytes,1,opt,name=job_id,json=jobId,proto3" json:"job_id,omitempty"` + AgentId string `protobuf:"bytes,2,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"` + DeviceId string `protobuf:"bytes,3,opt,name=device_id,json=deviceId,proto3" json:"device_id,omitempty"` + ConfigType string `protobuf:"bytes,4,opt,name=config_type,json=configType,proto3" json:"config_type,omitempty"` + CollectedAt *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=collected_at,json=collectedAt,proto3" json:"collected_at,omitempty"` + // gzip | none — агент стискає, бо конфіги добре жмуться, + // а канал може бути вузьким. + Encoding string `protobuf:"bytes,6,opt,name=encoding,proto3" json:"encoding,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConfigHeader) Reset() { + *x = ConfigHeader{} + mi := &file_netpulse_v1_ncm_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConfigHeader) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConfigHeader) ProtoMessage() {} + +func (x *ConfigHeader) ProtoReflect() protoreflect.Message { + mi := &file_netpulse_v1_ncm_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 ConfigHeader.ProtoReflect.Descriptor instead. +func (*ConfigHeader) Descriptor() ([]byte, []int) { + return file_netpulse_v1_ncm_proto_rawDescGZIP(), []int{2} +} + +func (x *ConfigHeader) GetJobId() string { + if x != nil { + return x.JobId + } + return "" +} + +func (x *ConfigHeader) GetAgentId() string { + if x != nil { + return x.AgentId + } + return "" +} + +func (x *ConfigHeader) GetDeviceId() string { + if x != nil { + return x.DeviceId + } + return "" +} + +func (x *ConfigHeader) GetConfigType() string { + if x != nil { + return x.ConfigType + } + return "" +} + +func (x *ConfigHeader) GetCollectedAt() *timestamppb.Timestamp { + if x != nil { + return x.CollectedAt + } + return nil +} + +func (x *ConfigHeader) GetEncoding() string { + if x != nil { + return x.Encoding + } + return "" +} + +type ConfigChunk struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Номер чанка з 0; сервер збирає в порядку зростання. + Sequence uint32 `protobuf:"varint,1,opt,name=sequence,proto3" json:"sequence,omitempty"` + Data []byte `protobuf:"bytes,2,opt,name=data,proto3" json:"data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConfigChunk) Reset() { + *x = ConfigChunk{} + mi := &file_netpulse_v1_ncm_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConfigChunk) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConfigChunk) ProtoMessage() {} + +func (x *ConfigChunk) ProtoReflect() protoreflect.Message { + mi := &file_netpulse_v1_ncm_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 ConfigChunk.ProtoReflect.Descriptor instead. +func (*ConfigChunk) Descriptor() ([]byte, []int) { + return file_netpulse_v1_ncm_proto_rawDescGZIP(), []int{3} +} + +func (x *ConfigChunk) GetSequence() uint32 { + if x != nil { + return x.Sequence + } + return 0 +} + +func (x *ConfigChunk) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +type ConfigTrailer struct { + state protoimpl.MessageState `protogen:"open.v1"` + Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` + Error *Error `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` + // sha256 ПОВНОГО тіла ДО стиснення. Сервер звіряє й лише потім комітить. + ContentSha256 []byte `protobuf:"bytes,3,opt,name=content_sha256,json=contentSha256,proto3" json:"content_sha256,omitempty"` + SizeBytes uint64 `protobuf:"varint,4,opt,name=size_bytes,json=sizeBytes,proto3" json:"size_bytes,omitempty"` + LineCount uint32 `protobuf:"varint,5,opt,name=line_count,json=lineCount,proto3" json:"line_count,omitempty"` + ChunkCount uint32 `protobuf:"varint,6,opt,name=chunk_count,json=chunkCount,proto3" json:"chunk_count,omitempty"` + Duration *durationpb.Duration `protobuf:"bytes,7,opt,name=duration,proto3" json:"duration,omitempty"` + // Транскрипт сесії, якщо capture_transcript = true. + Transcript string `protobuf:"bytes,8,opt,name=transcript,proto3" json:"transcript,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConfigTrailer) Reset() { + *x = ConfigTrailer{} + mi := &file_netpulse_v1_ncm_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConfigTrailer) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConfigTrailer) ProtoMessage() {} + +func (x *ConfigTrailer) ProtoReflect() protoreflect.Message { + mi := &file_netpulse_v1_ncm_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 ConfigTrailer.ProtoReflect.Descriptor instead. +func (*ConfigTrailer) Descriptor() ([]byte, []int) { + return file_netpulse_v1_ncm_proto_rawDescGZIP(), []int{4} +} + +func (x *ConfigTrailer) GetSuccess() bool { + if x != nil { + return x.Success + } + return false +} + +func (x *ConfigTrailer) GetError() *Error { + if x != nil { + return x.Error + } + return nil +} + +func (x *ConfigTrailer) GetContentSha256() []byte { + if x != nil { + return x.ContentSha256 + } + return nil +} + +func (x *ConfigTrailer) GetSizeBytes() uint64 { + if x != nil { + return x.SizeBytes + } + return 0 +} + +func (x *ConfigTrailer) GetLineCount() uint32 { + if x != nil { + return x.LineCount + } + return 0 +} + +func (x *ConfigTrailer) GetChunkCount() uint32 { + if x != nil { + return x.ChunkCount + } + return 0 +} + +func (x *ConfigTrailer) GetDuration() *durationpb.Duration { + if x != nil { + return x.Duration + } + return nil +} + +func (x *ConfigTrailer) GetTranscript() string { + if x != nil { + return x.Transcript + } + return "" +} + +type ConfigReceipt struct { + state protoimpl.MessageState `protogen:"open.v1"` + JobId string `protobuf:"bytes,1,opt,name=job_id,json=jobId,proto3" json:"job_id,omitempty"` + Accepted bool `protobuf:"varint,2,opt,name=accepted,proto3" json:"accepted,omitempty"` + // Сервер порівняв content_hash із попередньою версією: конфіг не змінився, + // нового коміту не буде. Агенту корисно знати для локальної статистики. + Unchanged bool `protobuf:"varint,3,opt,name=unchanged,proto3" json:"unchanged,omitempty"` + // Заповнюється, якщо коміт відбувся. + CommitSha string `protobuf:"bytes,4,opt,name=commit_sha,json=commitSha,proto3" json:"commit_sha,omitempty"` + ConfigId string `protobuf:"bytes,5,opt,name=config_id,json=configId,proto3" json:"config_id,omitempty"` + Error *Error `protobuf:"bytes,6,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConfigReceipt) Reset() { + *x = ConfigReceipt{} + mi := &file_netpulse_v1_ncm_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConfigReceipt) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConfigReceipt) ProtoMessage() {} + +func (x *ConfigReceipt) ProtoReflect() protoreflect.Message { + mi := &file_netpulse_v1_ncm_proto_msgTypes[5] + 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 ConfigReceipt.ProtoReflect.Descriptor instead. +func (*ConfigReceipt) Descriptor() ([]byte, []int) { + return file_netpulse_v1_ncm_proto_rawDescGZIP(), []int{5} +} + +func (x *ConfigReceipt) GetJobId() string { + if x != nil { + return x.JobId + } + return "" +} + +func (x *ConfigReceipt) GetAccepted() bool { + if x != nil { + return x.Accepted + } + return false +} + +func (x *ConfigReceipt) GetUnchanged() bool { + if x != nil { + return x.Unchanged + } + return false +} + +func (x *ConfigReceipt) GetCommitSha() string { + if x != nil { + return x.CommitSha + } + return "" +} + +func (x *ConfigReceipt) GetConfigId() string { + if x != nil { + return x.ConfigId + } + return "" +} + +func (x *ConfigReceipt) GetError() *Error { + if x != nil { + return x.Error + } + return nil +} + +type ConfigApplyJob struct { + state protoimpl.MessageState `protogen:"open.v1"` + RollbackId string `protobuf:"bytes,1,opt,name=rollback_id,json=rollbackId,proto3" json:"rollback_id,omitempty"` + Device *DeviceTarget `protobuf:"bytes,2,opt,name=device,proto3" json:"device,omitempty"` + Credential *Credential `protobuf:"bytes,3,opt,name=credential,proto3" json:"credential,omitempty"` + Transport Transport `protobuf:"varint,4,opt,name=transport,proto3,enum=netpulse.v1.Transport" json:"transport,omitempty"` + // Рядки конфігу, які треба виконати послідовно. + Commands []string `protobuf:"bytes,5,rep,name=commands,proto3" json:"commands,omitempty"` + PromptRegex string `protobuf:"bytes,6,opt,name=prompt_regex,json=promptRegex,proto3" json:"prompt_regex,omitempty"` + EnableRequired bool `protobuf:"varint,7,opt,name=enable_required,json=enableRequired,proto3" json:"enable_required,omitempty"` + // Команда збереження після успіху ("write memory", "/system backup save"). + CommitCommand string `protobuf:"bytes,8,opt,name=commit_command,json=commitCommand,proto3" json:"commit_command,omitempty"` + // Якщо пристрій підтримує confirmed commit — відкотиться сам, + // коли агент не підтвердить у цей строк. Найкращий захист від + // втрати керування після помилкового правила фаєрвола. + ConfirmTimeout *durationpb.Duration `protobuf:"bytes,9,opt,name=confirm_timeout,json=confirmTimeout,proto3" json:"confirm_timeout,omitempty"` + // Зупинитись на першій помилці (за замовчуванням) чи виконати все. + ContinueOnError bool `protobuf:"varint,10,opt,name=continue_on_error,json=continueOnError,proto3" json:"continue_on_error,omitempty"` + Timeout *durationpb.Duration `protobuf:"bytes,11,opt,name=timeout,proto3" json:"timeout,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConfigApplyJob) Reset() { + *x = ConfigApplyJob{} + mi := &file_netpulse_v1_ncm_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConfigApplyJob) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConfigApplyJob) ProtoMessage() {} + +func (x *ConfigApplyJob) ProtoReflect() protoreflect.Message { + mi := &file_netpulse_v1_ncm_proto_msgTypes[6] + 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 ConfigApplyJob.ProtoReflect.Descriptor instead. +func (*ConfigApplyJob) Descriptor() ([]byte, []int) { + return file_netpulse_v1_ncm_proto_rawDescGZIP(), []int{6} +} + +func (x *ConfigApplyJob) GetRollbackId() string { + if x != nil { + return x.RollbackId + } + return "" +} + +func (x *ConfigApplyJob) GetDevice() *DeviceTarget { + if x != nil { + return x.Device + } + return nil +} + +func (x *ConfigApplyJob) GetCredential() *Credential { + if x != nil { + return x.Credential + } + return nil +} + +func (x *ConfigApplyJob) GetTransport() Transport { + if x != nil { + return x.Transport + } + return Transport_TRANSPORT_UNSPECIFIED +} + +func (x *ConfigApplyJob) GetCommands() []string { + if x != nil { + return x.Commands + } + return nil +} + +func (x *ConfigApplyJob) GetPromptRegex() string { + if x != nil { + return x.PromptRegex + } + return "" +} + +func (x *ConfigApplyJob) GetEnableRequired() bool { + if x != nil { + return x.EnableRequired + } + return false +} + +func (x *ConfigApplyJob) GetCommitCommand() string { + if x != nil { + return x.CommitCommand + } + return "" +} + +func (x *ConfigApplyJob) GetConfirmTimeout() *durationpb.Duration { + if x != nil { + return x.ConfirmTimeout + } + return nil +} + +func (x *ConfigApplyJob) GetContinueOnError() bool { + if x != nil { + return x.ContinueOnError + } + return false +} + +func (x *ConfigApplyJob) GetTimeout() *durationpb.Duration { + if x != nil { + return x.Timeout + } + return nil +} + +type ConfigApplyResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + RollbackId string `protobuf:"bytes,1,opt,name=rollback_id,json=rollbackId,proto3" json:"rollback_id,omitempty"` + Success bool `protobuf:"varint,2,opt,name=success,proto3" json:"success,omitempty"` + // Результат кожної команди — саме тут видно, на якій усе стало. + Outcomes []*CommandOutcome `protobuf:"bytes,3,rep,name=outcomes,proto3" json:"outcomes,omitempty"` + Committed bool `protobuf:"varint,4,opt,name=committed,proto3" json:"committed,omitempty"` + Transcript string `protobuf:"bytes,5,opt,name=transcript,proto3" json:"transcript,omitempty"` + Error *Error `protobuf:"bytes,6,opt,name=error,proto3" json:"error,omitempty"` + Duration *durationpb.Duration `protobuf:"bytes,7,opt,name=duration,proto3" json:"duration,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConfigApplyResult) Reset() { + *x = ConfigApplyResult{} + mi := &file_netpulse_v1_ncm_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConfigApplyResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConfigApplyResult) ProtoMessage() {} + +func (x *ConfigApplyResult) ProtoReflect() protoreflect.Message { + mi := &file_netpulse_v1_ncm_proto_msgTypes[7] + 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 ConfigApplyResult.ProtoReflect.Descriptor instead. +func (*ConfigApplyResult) Descriptor() ([]byte, []int) { + return file_netpulse_v1_ncm_proto_rawDescGZIP(), []int{7} +} + +func (x *ConfigApplyResult) GetRollbackId() string { + if x != nil { + return x.RollbackId + } + return "" +} + +func (x *ConfigApplyResult) GetSuccess() bool { + if x != nil { + return x.Success + } + return false +} + +func (x *ConfigApplyResult) GetOutcomes() []*CommandOutcome { + if x != nil { + return x.Outcomes + } + return nil +} + +func (x *ConfigApplyResult) GetCommitted() bool { + if x != nil { + return x.Committed + } + return false +} + +func (x *ConfigApplyResult) GetTranscript() string { + if x != nil { + return x.Transcript + } + return "" +} + +func (x *ConfigApplyResult) GetError() *Error { + if x != nil { + return x.Error + } + return nil +} + +func (x *ConfigApplyResult) GetDuration() *durationpb.Duration { + if x != nil { + return x.Duration + } + return nil +} + +type CommandOutcome struct { + state protoimpl.MessageState `protogen:"open.v1"` + Index uint32 `protobuf:"varint,1,opt,name=index,proto3" json:"index,omitempty"` + Command string `protobuf:"bytes,2,opt,name=command,proto3" json:"command,omitempty"` + Output string `protobuf:"bytes,3,opt,name=output,proto3" json:"output,omitempty"` + Success bool `protobuf:"varint,4,opt,name=success,proto3" json:"success,omitempty"` + ErrorLine string `protobuf:"bytes,5,opt,name=error_line,json=errorLine,proto3" json:"error_line,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CommandOutcome) Reset() { + *x = CommandOutcome{} + mi := &file_netpulse_v1_ncm_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CommandOutcome) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CommandOutcome) ProtoMessage() {} + +func (x *CommandOutcome) ProtoReflect() protoreflect.Message { + mi := &file_netpulse_v1_ncm_proto_msgTypes[8] + 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 CommandOutcome.ProtoReflect.Descriptor instead. +func (*CommandOutcome) Descriptor() ([]byte, []int) { + return file_netpulse_v1_ncm_proto_rawDescGZIP(), []int{8} +} + +func (x *CommandOutcome) GetIndex() uint32 { + if x != nil { + return x.Index + } + return 0 +} + +func (x *CommandOutcome) GetCommand() string { + if x != nil { + return x.Command + } + return "" +} + +func (x *CommandOutcome) GetOutput() string { + if x != nil { + return x.Output + } + return "" +} + +func (x *CommandOutcome) GetSuccess() bool { + if x != nil { + return x.Success + } + return false +} + +func (x *CommandOutcome) GetErrorLine() string { + if x != nil { + return x.ErrorLine + } + return "" +} + +var File_netpulse_v1_ncm_proto protoreflect.FileDescriptor + +const file_netpulse_v1_ncm_proto_rawDesc = "" + + "\n" + + "\x15netpulse/v1/ncm.proto\x12\vnetpulse.v1\x1a\x1egoogle/protobuf/duration.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x18netpulse/v1/common.proto\"\x92\x04\n" + + "\tConfigJob\x12\x15\n" + + "\x06job_id\x18\x01 \x01(\tR\x05jobId\x121\n" + + "\x06device\x18\x02 \x01(\v2\x19.netpulse.v1.DeviceTargetR\x06device\x127\n" + + "\n" + + "credential\x18\x03 \x01(\v2\x17.netpulse.v1.CredentialR\n" + + "credential\x124\n" + + "\ttransport\x18\x04 \x01(\x0e2\x16.netpulse.v1.TransportR\ttransport\x12\x12\n" + + "\x04port\x18\x05 \x01(\rR\x04port\x12\x1a\n" + + "\bcommands\x18\x06 \x03(\tR\bcommands\x12!\n" + + "\fprompt_regex\x18\a \x01(\tR\vpromptRegex\x12'\n" + + "\x0fenable_required\x18\b \x01(\bR\x0eenableRequired\x12.\n" + + "\x13enable_prompt_regex\x18\t \x01(\tR\x11enablePromptRegex\x12\x1f\n" + + "\vconfig_type\x18\n" + + " \x01(\tR\n" + + "configType\x123\n" + + "\atimeout\x18\v \x01(\v2\x19.google.protobuf.DurationR\atimeout\x12\x1b\n" + + "\tmax_bytes\x18\f \x01(\x04R\bmaxBytes\x12-\n" + + "\x12capture_transcript\x18\r \x01(\bR\x11captureTranscript\"\xb5\x01\n" + + "\fConfigUpload\x123\n" + + "\x06header\x18\x01 \x01(\v2\x19.netpulse.v1.ConfigHeaderH\x00R\x06header\x120\n" + + "\x05chunk\x18\x02 \x01(\v2\x18.netpulse.v1.ConfigChunkH\x00R\x05chunk\x126\n" + + "\atrailer\x18\x03 \x01(\v2\x1a.netpulse.v1.ConfigTrailerH\x00R\atrailerB\x06\n" + + "\x04part\"\xd9\x01\n" + + "\fConfigHeader\x12\x15\n" + + "\x06job_id\x18\x01 \x01(\tR\x05jobId\x12\x19\n" + + "\bagent_id\x18\x02 \x01(\tR\aagentId\x12\x1b\n" + + "\tdevice_id\x18\x03 \x01(\tR\bdeviceId\x12\x1f\n" + + "\vconfig_type\x18\x04 \x01(\tR\n" + + "configType\x12=\n" + + "\fcollected_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\vcollectedAt\x12\x1a\n" + + "\bencoding\x18\x06 \x01(\tR\bencoding\"=\n" + + "\vConfigChunk\x12\x1a\n" + + "\bsequence\x18\x01 \x01(\rR\bsequence\x12\x12\n" + + "\x04data\x18\x02 \x01(\fR\x04data\"\xb0\x02\n" + + "\rConfigTrailer\x12\x18\n" + + "\asuccess\x18\x01 \x01(\bR\asuccess\x12(\n" + + "\x05error\x18\x02 \x01(\v2\x12.netpulse.v1.ErrorR\x05error\x12%\n" + + "\x0econtent_sha256\x18\x03 \x01(\fR\rcontentSha256\x12\x1d\n" + + "\n" + + "size_bytes\x18\x04 \x01(\x04R\tsizeBytes\x12\x1d\n" + + "\n" + + "line_count\x18\x05 \x01(\rR\tlineCount\x12\x1f\n" + + "\vchunk_count\x18\x06 \x01(\rR\n" + + "chunkCount\x125\n" + + "\bduration\x18\a \x01(\v2\x19.google.protobuf.DurationR\bduration\x12\x1e\n" + + "\n" + + "transcript\x18\b \x01(\tR\n" + + "transcript\"\xc6\x01\n" + + "\rConfigReceipt\x12\x15\n" + + "\x06job_id\x18\x01 \x01(\tR\x05jobId\x12\x1a\n" + + "\baccepted\x18\x02 \x01(\bR\baccepted\x12\x1c\n" + + "\tunchanged\x18\x03 \x01(\bR\tunchanged\x12\x1d\n" + + "\n" + + "commit_sha\x18\x04 \x01(\tR\tcommitSha\x12\x1b\n" + + "\tconfig_id\x18\x05 \x01(\tR\bconfigId\x12(\n" + + "\x05error\x18\x06 \x01(\v2\x12.netpulse.v1.ErrorR\x05error\"\x87\x04\n" + + "\x0eConfigApplyJob\x12\x1f\n" + + "\vrollback_id\x18\x01 \x01(\tR\n" + + "rollbackId\x121\n" + + "\x06device\x18\x02 \x01(\v2\x19.netpulse.v1.DeviceTargetR\x06device\x127\n" + + "\n" + + "credential\x18\x03 \x01(\v2\x17.netpulse.v1.CredentialR\n" + + "credential\x124\n" + + "\ttransport\x18\x04 \x01(\x0e2\x16.netpulse.v1.TransportR\ttransport\x12\x1a\n" + + "\bcommands\x18\x05 \x03(\tR\bcommands\x12!\n" + + "\fprompt_regex\x18\x06 \x01(\tR\vpromptRegex\x12'\n" + + "\x0fenable_required\x18\a \x01(\bR\x0eenableRequired\x12%\n" + + "\x0ecommit_command\x18\b \x01(\tR\rcommitCommand\x12B\n" + + "\x0fconfirm_timeout\x18\t \x01(\v2\x19.google.protobuf.DurationR\x0econfirmTimeout\x12*\n" + + "\x11continue_on_error\x18\n" + + " \x01(\bR\x0fcontinueOnError\x123\n" + + "\atimeout\x18\v \x01(\v2\x19.google.protobuf.DurationR\atimeout\"\xa6\x02\n" + + "\x11ConfigApplyResult\x12\x1f\n" + + "\vrollback_id\x18\x01 \x01(\tR\n" + + "rollbackId\x12\x18\n" + + "\asuccess\x18\x02 \x01(\bR\asuccess\x127\n" + + "\boutcomes\x18\x03 \x03(\v2\x1b.netpulse.v1.CommandOutcomeR\boutcomes\x12\x1c\n" + + "\tcommitted\x18\x04 \x01(\bR\tcommitted\x12\x1e\n" + + "\n" + + "transcript\x18\x05 \x01(\tR\n" + + "transcript\x12(\n" + + "\x05error\x18\x06 \x01(\v2\x12.netpulse.v1.ErrorR\x05error\x125\n" + + "\bduration\x18\a \x01(\v2\x19.google.protobuf.DurationR\bduration\"\x91\x01\n" + + "\x0eCommandOutcome\x12\x14\n" + + "\x05index\x18\x01 \x01(\rR\x05index\x12\x18\n" + + "\acommand\x18\x02 \x01(\tR\acommand\x12\x16\n" + + "\x06output\x18\x03 \x01(\tR\x06output\x12\x18\n" + + "\asuccess\x18\x04 \x01(\bR\asuccess\x12\x1d\n" + + "\n" + + "error_line\x18\x05 \x01(\tR\terrorLineB netpulse.v1.DeviceTarget + 10, // 1: netpulse.v1.ConfigJob.credential:type_name -> netpulse.v1.Credential + 11, // 2: netpulse.v1.ConfigJob.transport:type_name -> netpulse.v1.Transport + 12, // 3: netpulse.v1.ConfigJob.timeout:type_name -> google.protobuf.Duration + 2, // 4: netpulse.v1.ConfigUpload.header:type_name -> netpulse.v1.ConfigHeader + 3, // 5: netpulse.v1.ConfigUpload.chunk:type_name -> netpulse.v1.ConfigChunk + 4, // 6: netpulse.v1.ConfigUpload.trailer:type_name -> netpulse.v1.ConfigTrailer + 13, // 7: netpulse.v1.ConfigHeader.collected_at:type_name -> google.protobuf.Timestamp + 14, // 8: netpulse.v1.ConfigTrailer.error:type_name -> netpulse.v1.Error + 12, // 9: netpulse.v1.ConfigTrailer.duration:type_name -> google.protobuf.Duration + 14, // 10: netpulse.v1.ConfigReceipt.error:type_name -> netpulse.v1.Error + 9, // 11: netpulse.v1.ConfigApplyJob.device:type_name -> netpulse.v1.DeviceTarget + 10, // 12: netpulse.v1.ConfigApplyJob.credential:type_name -> netpulse.v1.Credential + 11, // 13: netpulse.v1.ConfigApplyJob.transport:type_name -> netpulse.v1.Transport + 12, // 14: netpulse.v1.ConfigApplyJob.confirm_timeout:type_name -> google.protobuf.Duration + 12, // 15: netpulse.v1.ConfigApplyJob.timeout:type_name -> google.protobuf.Duration + 8, // 16: netpulse.v1.ConfigApplyResult.outcomes:type_name -> netpulse.v1.CommandOutcome + 14, // 17: netpulse.v1.ConfigApplyResult.error:type_name -> netpulse.v1.Error + 12, // 18: netpulse.v1.ConfigApplyResult.duration:type_name -> google.protobuf.Duration + 19, // [19:19] is the sub-list for method output_type + 19, // [19:19] is the sub-list for method input_type + 19, // [19:19] is the sub-list for extension type_name + 19, // [19:19] is the sub-list for extension extendee + 0, // [0:19] is the sub-list for field type_name +} + +func init() { file_netpulse_v1_ncm_proto_init() } +func file_netpulse_v1_ncm_proto_init() { + if File_netpulse_v1_ncm_proto != nil { + return + } + file_netpulse_v1_common_proto_init() + file_netpulse_v1_ncm_proto_msgTypes[1].OneofWrappers = []any{ + (*ConfigUpload_Header)(nil), + (*ConfigUpload_Chunk)(nil), + (*ConfigUpload_Trailer)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_netpulse_v1_ncm_proto_rawDesc), len(file_netpulse_v1_ncm_proto_rawDesc)), + NumEnums: 0, + NumMessages: 9, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_netpulse_v1_ncm_proto_goTypes, + DependencyIndexes: file_netpulse_v1_ncm_proto_depIdxs, + MessageInfos: file_netpulse_v1_ncm_proto_msgTypes, + }.Build() + File_netpulse_v1_ncm_proto = out.File + file_netpulse_v1_ncm_proto_goTypes = nil + file_netpulse_v1_ncm_proto_depIdxs = nil +} diff --git a/gen/go/netpulse/v1/telemetry.pb.go b/gen/go/netpulse/v1/telemetry.pb.go new file mode 100644 index 0000000..9fd94f3 --- /dev/null +++ b/gen/go/netpulse/v1/telemetry.pb.go @@ -0,0 +1,1109 @@ +// ===================================================================== +// NetPulse :: telemetry.proto +// Гарячий шлях: метрики від агента до сервера. +// +// Три типи навантаження, свідомо різні: +// 1. IcmpResult → ts.icmp_samples (широка таблиця, колір вузла) +// 2. InterfaceCounters → ts.if_counters (широка таблиця, анімація лінії) +// 3. MetricSample → ts.series/samples (узагальнена, будь-який плагін) +// +// Перші два денормалізовані, бо їх читає мапа на кожному тику WebSocket. +// Третій — розширюваний без зміни контракту: плагін реєструє свій +// metric_key і працює. +// ===================================================================== + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.12 +// protoc v3.21.12 +// source: netpulse/v1/telemetry.proto + +package netpulsev1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + durationpb "google.golang.org/protobuf/types/known/durationpb" + 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 SeriesDescriptor struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Локальний номер у межах сесії. Нумерація з 1; 0 — недійсний. + SeriesRef uint32 `protobuf:"varint,1,opt,name=series_ref,json=seriesRef,proto3" json:"series_ref,omitempty"` + DeviceId string `protobuf:"bytes,2,opt,name=device_id,json=deviceId,proto3" json:"device_id,omitempty"` + // Заповнюється для метрик рівня інтерфейсу. + InterfaceId string `protobuf:"bytes,3,opt,name=interface_id,json=interfaceId,proto3" json:"interface_id,omitempty"` + // Плагін-джерело: snmp, modbus, http... + PluginKey string `protobuf:"bytes,4,opt,name=plugin_key,json=pluginKey,proto3" json:"plugin_key,omitempty"` + // cpu.util, mem.used, ups.battery.pct, sensor.temp + MetricKey string `protobuf:"bytes,5,opt,name=metric_key,json=metricKey,proto3" json:"metric_key,omitempty"` + // pct, bps, C, V, ms + Unit string `protobuf:"bytes,6,opt,name=unit,proto3" json:"unit,omitempty"` + // Додаткові виміри: {"core":"0","phase":"L1"} + Labels map[string]string `protobuf:"bytes,7,rep,name=labels,proto3" json:"labels,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SeriesDescriptor) Reset() { + *x = SeriesDescriptor{} + mi := &file_netpulse_v1_telemetry_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SeriesDescriptor) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SeriesDescriptor) ProtoMessage() {} + +func (x *SeriesDescriptor) ProtoReflect() protoreflect.Message { + mi := &file_netpulse_v1_telemetry_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 SeriesDescriptor.ProtoReflect.Descriptor instead. +func (*SeriesDescriptor) Descriptor() ([]byte, []int) { + return file_netpulse_v1_telemetry_proto_rawDescGZIP(), []int{0} +} + +func (x *SeriesDescriptor) GetSeriesRef() uint32 { + if x != nil { + return x.SeriesRef + } + return 0 +} + +func (x *SeriesDescriptor) GetDeviceId() string { + if x != nil { + return x.DeviceId + } + return "" +} + +func (x *SeriesDescriptor) GetInterfaceId() string { + if x != nil { + return x.InterfaceId + } + return "" +} + +func (x *SeriesDescriptor) GetPluginKey() string { + if x != nil { + return x.PluginKey + } + return "" +} + +func (x *SeriesDescriptor) GetMetricKey() string { + if x != nil { + return x.MetricKey + } + return "" +} + +func (x *SeriesDescriptor) GetUnit() string { + if x != nil { + return x.Unit + } + return "" +} + +func (x *SeriesDescriptor) GetLabels() map[string]string { + if x != nil { + return x.Labels + } + return nil +} + +// Один вимір узагальненої метрики. +type MetricSample struct { + state protoimpl.MessageState `protogen:"open.v1"` + SeriesRef uint32 `protobuf:"varint,1,opt,name=series_ref,json=seriesRef,proto3" json:"series_ref,omitempty"` + Ts *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=ts,proto3" json:"ts,omitempty"` + Value float64 `protobuf:"fixed64,3,opt,name=value,proto3" json:"value,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MetricSample) Reset() { + *x = MetricSample{} + mi := &file_netpulse_v1_telemetry_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MetricSample) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MetricSample) ProtoMessage() {} + +func (x *MetricSample) ProtoReflect() protoreflect.Message { + mi := &file_netpulse_v1_telemetry_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 MetricSample.ProtoReflect.Descriptor instead. +func (*MetricSample) Descriptor() ([]byte, []int) { + return file_netpulse_v1_telemetry_proto_rawDescGZIP(), []int{1} +} + +func (x *MetricSample) GetSeriesRef() uint32 { + if x != nil { + return x.SeriesRef + } + return 0 +} + +func (x *MetricSample) GetTs() *timestamppb.Timestamp { + if x != nil { + return x.Ts + } + return nil +} + +func (x *MetricSample) GetValue() float64 { + if x != nil { + return x.Value + } + return 0 +} + +type IcmpResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + DeviceId string `protobuf:"bytes,1,opt,name=device_id,json=deviceId,proto3" json:"device_id,omitempty"` + CheckId string `protobuf:"bytes,2,opt,name=check_id,json=checkId,proto3" json:"check_id,omitempty"` + Ts *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=ts,proto3" json:"ts,omitempty"` + RttAvgMs float32 `protobuf:"fixed32,4,opt,name=rtt_avg_ms,json=rttAvgMs,proto3" json:"rtt_avg_ms,omitempty"` + RttMinMs float32 `protobuf:"fixed32,5,opt,name=rtt_min_ms,json=rttMinMs,proto3" json:"rtt_min_ms,omitempty"` + RttMaxMs float32 `protobuf:"fixed32,6,opt,name=rtt_max_ms,json=rttMaxMs,proto3" json:"rtt_max_ms,omitempty"` + JitterMs float32 `protobuf:"fixed32,7,opt,name=jitter_ms,json=jitterMs,proto3" json:"jitter_ms,omitempty"` + LossPct float32 `protobuf:"fixed32,8,opt,name=loss_pct,json=lossPct,proto3" json:"loss_pct,omitempty"` + PacketsSent uint32 `protobuf:"varint,9,opt,name=packets_sent,json=packetsSent,proto3" json:"packets_sent,omitempty"` + PacketsRecv uint32 `protobuf:"varint,10,opt,name=packets_recv,json=packetsRecv,proto3" json:"packets_recv,omitempty"` + Reachable bool `protobuf:"varint,11,opt,name=reachable,proto3" json:"reachable,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *IcmpResult) Reset() { + *x = IcmpResult{} + mi := &file_netpulse_v1_telemetry_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *IcmpResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IcmpResult) ProtoMessage() {} + +func (x *IcmpResult) ProtoReflect() protoreflect.Message { + mi := &file_netpulse_v1_telemetry_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 IcmpResult.ProtoReflect.Descriptor instead. +func (*IcmpResult) Descriptor() ([]byte, []int) { + return file_netpulse_v1_telemetry_proto_rawDescGZIP(), []int{2} +} + +func (x *IcmpResult) GetDeviceId() string { + if x != nil { + return x.DeviceId + } + return "" +} + +func (x *IcmpResult) GetCheckId() string { + if x != nil { + return x.CheckId + } + return "" +} + +func (x *IcmpResult) GetTs() *timestamppb.Timestamp { + if x != nil { + return x.Ts + } + return nil +} + +func (x *IcmpResult) GetRttAvgMs() float32 { + if x != nil { + return x.RttAvgMs + } + return 0 +} + +func (x *IcmpResult) GetRttMinMs() float32 { + if x != nil { + return x.RttMinMs + } + return 0 +} + +func (x *IcmpResult) GetRttMaxMs() float32 { + if x != nil { + return x.RttMaxMs + } + return 0 +} + +func (x *IcmpResult) GetJitterMs() float32 { + if x != nil { + return x.JitterMs + } + return 0 +} + +func (x *IcmpResult) GetLossPct() float32 { + if x != nil { + return x.LossPct + } + return 0 +} + +func (x *IcmpResult) GetPacketsSent() uint32 { + if x != nil { + return x.PacketsSent + } + return 0 +} + +func (x *IcmpResult) GetPacketsRecv() uint32 { + if x != nil { + return x.PacketsRecv + } + return 0 +} + +func (x *IcmpResult) GetReachable() bool { + if x != nil { + return x.Reachable + } + return false +} + +type InterfaceCounters struct { + state protoimpl.MessageState `protogen:"open.v1"` + DeviceId string `protobuf:"bytes,1,opt,name=device_id,json=deviceId,proto3" json:"device_id,omitempty"` + InterfaceId string `protobuf:"bytes,2,opt,name=interface_id,json=interfaceId,proto3" json:"interface_id,omitempty"` + Ts *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=ts,proto3" json:"ts,omitempty"` + InOctets uint64 `protobuf:"varint,4,opt,name=in_octets,json=inOctets,proto3" json:"in_octets,omitempty"` + OutOctets uint64 `protobuf:"varint,5,opt,name=out_octets,json=outOctets,proto3" json:"out_octets,omitempty"` + InUcastPkts uint64 `protobuf:"varint,6,opt,name=in_ucast_pkts,json=inUcastPkts,proto3" json:"in_ucast_pkts,omitempty"` + OutUcastPkts uint64 `protobuf:"varint,7,opt,name=out_ucast_pkts,json=outUcastPkts,proto3" json:"out_ucast_pkts,omitempty"` + InErrors uint64 `protobuf:"varint,8,opt,name=in_errors,json=inErrors,proto3" json:"in_errors,omitempty"` + OutErrors uint64 `protobuf:"varint,9,opt,name=out_errors,json=outErrors,proto3" json:"out_errors,omitempty"` + InDiscards uint64 `protobuf:"varint,10,opt,name=in_discards,json=inDiscards,proto3" json:"in_discards,omitempty"` + OutDiscards uint64 `protobuf:"varint,11,opt,name=out_discards,json=outDiscards,proto3" json:"out_discards,omitempty"` + // Похідні швидкості за фактичний інтервал між опитуваннями. + InBps float64 `protobuf:"fixed64,12,opt,name=in_bps,json=inBps,proto3" json:"in_bps,omitempty"` + OutBps float64 `protobuf:"fixed64,13,opt,name=out_bps,json=outBps,proto3" json:"out_bps,omitempty"` + InPps float64 `protobuf:"fixed64,14,opt,name=in_pps,json=inPps,proto3" json:"in_pps,omitempty"` + OutPps float64 `protobuf:"fixed64,15,opt,name=out_pps,json=outPps,proto3" json:"out_pps,omitempty"` + // % від номінальної швидкості порту — джерело анімації трафіку на мапі. + UtilInPct float32 `protobuf:"fixed32,16,opt,name=util_in_pct,json=utilInPct,proto3" json:"util_in_pct,omitempty"` + UtilOutPct float32 `protobuf:"fixed32,17,opt,name=util_out_pct,json=utilOutPct,proto3" json:"util_out_pct,omitempty"` + OperUp bool `protobuf:"varint,18,opt,name=oper_up,json=operUp,proto3" json:"oper_up,omitempty"` + AdminUp bool `protobuf:"varint,19,opt,name=admin_up,json=adminUp,proto3" json:"admin_up,omitempty"` + // Лічильник обнулився (reboot/wrap) — швидкості цього разу недійсні. + CounterReset bool `protobuf:"varint,20,opt,name=counter_reset,json=counterReset,proto3" json:"counter_reset,omitempty"` + // Фактичний інтервал, за який пораховані швидкості. + Interval *durationpb.Duration `protobuf:"bytes,21,opt,name=interval,proto3" json:"interval,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InterfaceCounters) Reset() { + *x = InterfaceCounters{} + mi := &file_netpulse_v1_telemetry_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InterfaceCounters) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InterfaceCounters) ProtoMessage() {} + +func (x *InterfaceCounters) ProtoReflect() protoreflect.Message { + mi := &file_netpulse_v1_telemetry_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 InterfaceCounters.ProtoReflect.Descriptor instead. +func (*InterfaceCounters) Descriptor() ([]byte, []int) { + return file_netpulse_v1_telemetry_proto_rawDescGZIP(), []int{3} +} + +func (x *InterfaceCounters) GetDeviceId() string { + if x != nil { + return x.DeviceId + } + return "" +} + +func (x *InterfaceCounters) GetInterfaceId() string { + if x != nil { + return x.InterfaceId + } + return "" +} + +func (x *InterfaceCounters) GetTs() *timestamppb.Timestamp { + if x != nil { + return x.Ts + } + return nil +} + +func (x *InterfaceCounters) GetInOctets() uint64 { + if x != nil { + return x.InOctets + } + return 0 +} + +func (x *InterfaceCounters) GetOutOctets() uint64 { + if x != nil { + return x.OutOctets + } + return 0 +} + +func (x *InterfaceCounters) GetInUcastPkts() uint64 { + if x != nil { + return x.InUcastPkts + } + return 0 +} + +func (x *InterfaceCounters) GetOutUcastPkts() uint64 { + if x != nil { + return x.OutUcastPkts + } + return 0 +} + +func (x *InterfaceCounters) GetInErrors() uint64 { + if x != nil { + return x.InErrors + } + return 0 +} + +func (x *InterfaceCounters) GetOutErrors() uint64 { + if x != nil { + return x.OutErrors + } + return 0 +} + +func (x *InterfaceCounters) GetInDiscards() uint64 { + if x != nil { + return x.InDiscards + } + return 0 +} + +func (x *InterfaceCounters) GetOutDiscards() uint64 { + if x != nil { + return x.OutDiscards + } + return 0 +} + +func (x *InterfaceCounters) GetInBps() float64 { + if x != nil { + return x.InBps + } + return 0 +} + +func (x *InterfaceCounters) GetOutBps() float64 { + if x != nil { + return x.OutBps + } + return 0 +} + +func (x *InterfaceCounters) GetInPps() float64 { + if x != nil { + return x.InPps + } + return 0 +} + +func (x *InterfaceCounters) GetOutPps() float64 { + if x != nil { + return x.OutPps + } + return 0 +} + +func (x *InterfaceCounters) GetUtilInPct() float32 { + if x != nil { + return x.UtilInPct + } + return 0 +} + +func (x *InterfaceCounters) GetUtilOutPct() float32 { + if x != nil { + return x.UtilOutPct + } + return 0 +} + +func (x *InterfaceCounters) GetOperUp() bool { + if x != nil { + return x.OperUp + } + return false +} + +func (x *InterfaceCounters) GetAdminUp() bool { + if x != nil { + return x.AdminUp + } + return false +} + +func (x *InterfaceCounters) GetCounterReset() bool { + if x != nil { + return x.CounterReset + } + return false +} + +func (x *InterfaceCounters) GetInterval() *durationpb.Duration { + if x != nil { + return x.Interval + } + return nil +} + +type StatusChange struct { + state protoimpl.MessageState `protogen:"open.v1"` + DeviceId string `protobuf:"bytes,1,opt,name=device_id,json=deviceId,proto3" json:"device_id,omitempty"` + Ts *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=ts,proto3" json:"ts,omitempty"` + Status Status `protobuf:"varint,3,opt,name=status,proto3,enum=netpulse.v1.Status" json:"status,omitempty"` + PreviousStatus Status `protobuf:"varint,4,opt,name=previous_status,json=previousStatus,proto3,enum=netpulse.v1.Status" json:"previous_status,omitempty"` + Reason string `protobuf:"bytes,5,opt,name=reason,proto3" json:"reason,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StatusChange) Reset() { + *x = StatusChange{} + mi := &file_netpulse_v1_telemetry_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StatusChange) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StatusChange) ProtoMessage() {} + +func (x *StatusChange) ProtoReflect() protoreflect.Message { + mi := &file_netpulse_v1_telemetry_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 StatusChange.ProtoReflect.Descriptor instead. +func (*StatusChange) Descriptor() ([]byte, []int) { + return file_netpulse_v1_telemetry_proto_rawDescGZIP(), []int{4} +} + +func (x *StatusChange) GetDeviceId() string { + if x != nil { + return x.DeviceId + } + return "" +} + +func (x *StatusChange) GetTs() *timestamppb.Timestamp { + if x != nil { + return x.Ts + } + return nil +} + +func (x *StatusChange) GetStatus() Status { + if x != nil { + return x.Status + } + return Status_STATUS_UNSPECIFIED +} + +func (x *StatusChange) GetPreviousStatus() Status { + if x != nil { + return x.PreviousStatus + } + return Status_STATUS_UNSPECIFIED +} + +func (x *StatusChange) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +type CheckResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + CheckId string `protobuf:"bytes,1,opt,name=check_id,json=checkId,proto3" json:"check_id,omitempty"` + DeviceId string `protobuf:"bytes,2,opt,name=device_id,json=deviceId,proto3" json:"device_id,omitempty"` + CheckType string `protobuf:"bytes,3,opt,name=check_type,json=checkType,proto3" json:"check_type,omitempty"` // icmp.ping, snmp.if, http.status + Ts *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=ts,proto3" json:"ts,omitempty"` + Duration *durationpb.Duration `protobuf:"bytes,5,opt,name=duration,proto3" json:"duration,omitempty"` + Success bool `protobuf:"varint,6,opt,name=success,proto3" json:"success,omitempty"` + Error *Error `protobuf:"bytes,7,opt,name=error,proto3" json:"error,omitempty"` + // Довільне корисне навантаження плагіна (JSON), яке не є метрикою: + // напр. http.status → {"status_code":200,"redirects":1}. + PayloadJson []byte `protobuf:"bytes,8,opt,name=payload_json,json=payloadJson,proto3" json:"payload_json,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CheckResult) Reset() { + *x = CheckResult{} + mi := &file_netpulse_v1_telemetry_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CheckResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CheckResult) ProtoMessage() {} + +func (x *CheckResult) ProtoReflect() protoreflect.Message { + mi := &file_netpulse_v1_telemetry_proto_msgTypes[5] + 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 CheckResult.ProtoReflect.Descriptor instead. +func (*CheckResult) Descriptor() ([]byte, []int) { + return file_netpulse_v1_telemetry_proto_rawDescGZIP(), []int{5} +} + +func (x *CheckResult) GetCheckId() string { + if x != nil { + return x.CheckId + } + return "" +} + +func (x *CheckResult) GetDeviceId() string { + if x != nil { + return x.DeviceId + } + return "" +} + +func (x *CheckResult) GetCheckType() string { + if x != nil { + return x.CheckType + } + return "" +} + +func (x *CheckResult) GetTs() *timestamppb.Timestamp { + if x != nil { + return x.Ts + } + return nil +} + +func (x *CheckResult) GetDuration() *durationpb.Duration { + if x != nil { + return x.Duration + } + return nil +} + +func (x *CheckResult) GetSuccess() bool { + if x != nil { + return x.Success + } + return false +} + +func (x *CheckResult) GetError() *Error { + if x != nil { + return x.Error + } + return nil +} + +func (x *CheckResult) GetPayloadJson() []byte { + if x != nil { + return x.PayloadJson + } + return nil +} + +type TelemetryBatch struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Монотонний номер батчу в межах сесії. Основа для ack і ретраїв. + 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"` + // Час формування батчу на агенті (не час вимірів усередині). + CreatedAt *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + // Нові серії, які вперше зустрічаються в цій сесії. + // Мають бути в тому ж батчі, що й перші семпли, які на них посилаються. + NewSeries []*SeriesDescriptor `protobuf:"bytes,4,rep,name=new_series,json=newSeries,proto3" json:"new_series,omitempty"` + Samples []*MetricSample `protobuf:"bytes,5,rep,name=samples,proto3" json:"samples,omitempty"` + Icmp []*IcmpResult `protobuf:"bytes,6,rep,name=icmp,proto3" json:"icmp,omitempty"` + Interfaces []*InterfaceCounters `protobuf:"bytes,7,rep,name=interfaces,proto3" json:"interfaces,omitempty"` + StatusChanges []*StatusChange `protobuf:"bytes,8,rep,name=status_changes,json=statusChanges,proto3" json:"status_changes,omitempty"` + CheckResults []*CheckResult `protobuf:"bytes,9,rep,name=check_results,json=checkResults,proto3" json:"check_results,omitempty"` + // Батч — повтор раніше не підтвердженого (після реконекту). + // Сервер робить upsert: PK (ts, device_id) робить це безпечним. + IsRetransmit bool `protobuf:"varint,10,opt,name=is_retransmit,json=isRetransmit,proto3" json:"is_retransmit,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TelemetryBatch) Reset() { + *x = TelemetryBatch{} + mi := &file_netpulse_v1_telemetry_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TelemetryBatch) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TelemetryBatch) ProtoMessage() {} + +func (x *TelemetryBatch) ProtoReflect() protoreflect.Message { + mi := &file_netpulse_v1_telemetry_proto_msgTypes[6] + 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 TelemetryBatch.ProtoReflect.Descriptor instead. +func (*TelemetryBatch) Descriptor() ([]byte, []int) { + return file_netpulse_v1_telemetry_proto_rawDescGZIP(), []int{6} +} + +func (x *TelemetryBatch) GetBatchId() uint64 { + if x != nil { + return x.BatchId + } + return 0 +} + +func (x *TelemetryBatch) GetAgentId() string { + if x != nil { + return x.AgentId + } + return "" +} + +func (x *TelemetryBatch) GetCreatedAt() *timestamppb.Timestamp { + if x != nil { + return x.CreatedAt + } + return nil +} + +func (x *TelemetryBatch) GetNewSeries() []*SeriesDescriptor { + if x != nil { + return x.NewSeries + } + return nil +} + +func (x *TelemetryBatch) GetSamples() []*MetricSample { + if x != nil { + return x.Samples + } + return nil +} + +func (x *TelemetryBatch) GetIcmp() []*IcmpResult { + if x != nil { + return x.Icmp + } + return nil +} + +func (x *TelemetryBatch) GetInterfaces() []*InterfaceCounters { + if x != nil { + return x.Interfaces + } + return nil +} + +func (x *TelemetryBatch) GetStatusChanges() []*StatusChange { + if x != nil { + return x.StatusChanges + } + return nil +} + +func (x *TelemetryBatch) GetCheckResults() []*CheckResult { + if x != nil { + return x.CheckResults + } + return nil +} + +func (x *TelemetryBatch) GetIsRetransmit() bool { + if x != nil { + return x.IsRetransmit + } + return false +} + +type TelemetryAck 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"` + // Батчі, які треба переслати (напр. частковий збій запису). + NackBatchIds []uint64 `protobuf:"varint,2,rep,packed,name=nack_batch_ids,json=nackBatchIds,proto3" json:"nack_batch_ids,omitempty"` + // Скільки неacknowledged батчів агенту дозволено тримати в польоті. + MaxInFlight uint32 `protobuf:"varint,3,opt,name=max_in_flight,json=maxInFlight,proto3" json:"max_in_flight,omitempty"` + // Сервер під навантаженням: пригальмувати на цей час. + RetryAfter *durationpb.Duration `protobuf:"bytes,4,opt,name=retry_after,json=retryAfter,proto3" json:"retry_after,omitempty"` + // Сервер втратив мапу series_ref → id. Агент має обнулити локальну + // таблицю й перереєструвати всі серії наступним батчем. + ResetSeriesTable bool `protobuf:"varint,5,opt,name=reset_series_table,json=resetSeriesTable,proto3" json:"reset_series_table,omitempty"` + Error *Error `protobuf:"bytes,6,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TelemetryAck) Reset() { + *x = TelemetryAck{} + mi := &file_netpulse_v1_telemetry_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TelemetryAck) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TelemetryAck) ProtoMessage() {} + +func (x *TelemetryAck) ProtoReflect() protoreflect.Message { + mi := &file_netpulse_v1_telemetry_proto_msgTypes[7] + 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 TelemetryAck.ProtoReflect.Descriptor instead. +func (*TelemetryAck) Descriptor() ([]byte, []int) { + return file_netpulse_v1_telemetry_proto_rawDescGZIP(), []int{7} +} + +func (x *TelemetryAck) GetAckedThroughBatchId() uint64 { + if x != nil { + return x.AckedThroughBatchId + } + return 0 +} + +func (x *TelemetryAck) GetNackBatchIds() []uint64 { + if x != nil { + return x.NackBatchIds + } + return nil +} + +func (x *TelemetryAck) GetMaxInFlight() uint32 { + if x != nil { + return x.MaxInFlight + } + return 0 +} + +func (x *TelemetryAck) GetRetryAfter() *durationpb.Duration { + if x != nil { + return x.RetryAfter + } + return nil +} + +func (x *TelemetryAck) GetResetSeriesTable() bool { + if x != nil { + return x.ResetSeriesTable + } + return false +} + +func (x *TelemetryAck) GetError() *Error { + if x != nil { + return x.Error + } + return nil +} + +var File_netpulse_v1_telemetry_proto protoreflect.FileDescriptor + +const file_netpulse_v1_telemetry_proto_rawDesc = "" + + "\n" + + "\x1bnetpulse/v1/telemetry.proto\x12\vnetpulse.v1\x1a\x1egoogle/protobuf/duration.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x18netpulse/v1/common.proto\"\xc1\x02\n" + + "\x10SeriesDescriptor\x12\x1d\n" + + "\n" + + "series_ref\x18\x01 \x01(\rR\tseriesRef\x12\x1b\n" + + "\tdevice_id\x18\x02 \x01(\tR\bdeviceId\x12!\n" + + "\finterface_id\x18\x03 \x01(\tR\vinterfaceId\x12\x1d\n" + + "\n" + + "plugin_key\x18\x04 \x01(\tR\tpluginKey\x12\x1d\n" + + "\n" + + "metric_key\x18\x05 \x01(\tR\tmetricKey\x12\x12\n" + + "\x04unit\x18\x06 \x01(\tR\x04unit\x12A\n" + + "\x06labels\x18\a \x03(\v2).netpulse.v1.SeriesDescriptor.LabelsEntryR\x06labels\x1a9\n" + + "\vLabelsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"o\n" + + "\fMetricSample\x12\x1d\n" + + "\n" + + "series_ref\x18\x01 \x01(\rR\tseriesRef\x12*\n" + + "\x02ts\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\x02ts\x12\x14\n" + + "\x05value\x18\x03 \x01(\x01R\x05value\"\xe6\x02\n" + + "\n" + + "IcmpResult\x12\x1b\n" + + "\tdevice_id\x18\x01 \x01(\tR\bdeviceId\x12\x19\n" + + "\bcheck_id\x18\x02 \x01(\tR\acheckId\x12*\n" + + "\x02ts\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\x02ts\x12\x1c\n" + + "\n" + + "rtt_avg_ms\x18\x04 \x01(\x02R\brttAvgMs\x12\x1c\n" + + "\n" + + "rtt_min_ms\x18\x05 \x01(\x02R\brttMinMs\x12\x1c\n" + + "\n" + + "rtt_max_ms\x18\x06 \x01(\x02R\brttMaxMs\x12\x1b\n" + + "\tjitter_ms\x18\a \x01(\x02R\bjitterMs\x12\x19\n" + + "\bloss_pct\x18\b \x01(\x02R\alossPct\x12!\n" + + "\fpackets_sent\x18\t \x01(\rR\vpacketsSent\x12!\n" + + "\fpackets_recv\x18\n" + + " \x01(\rR\vpacketsRecv\x12\x1c\n" + + "\treachable\x18\v \x01(\bR\treachable\"\xb7\x05\n" + + "\x11InterfaceCounters\x12\x1b\n" + + "\tdevice_id\x18\x01 \x01(\tR\bdeviceId\x12!\n" + + "\finterface_id\x18\x02 \x01(\tR\vinterfaceId\x12*\n" + + "\x02ts\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\x02ts\x12\x1b\n" + + "\tin_octets\x18\x04 \x01(\x04R\binOctets\x12\x1d\n" + + "\n" + + "out_octets\x18\x05 \x01(\x04R\toutOctets\x12\"\n" + + "\rin_ucast_pkts\x18\x06 \x01(\x04R\vinUcastPkts\x12$\n" + + "\x0eout_ucast_pkts\x18\a \x01(\x04R\foutUcastPkts\x12\x1b\n" + + "\tin_errors\x18\b \x01(\x04R\binErrors\x12\x1d\n" + + "\n" + + "out_errors\x18\t \x01(\x04R\toutErrors\x12\x1f\n" + + "\vin_discards\x18\n" + + " \x01(\x04R\n" + + "inDiscards\x12!\n" + + "\fout_discards\x18\v \x01(\x04R\voutDiscards\x12\x15\n" + + "\x06in_bps\x18\f \x01(\x01R\x05inBps\x12\x17\n" + + "\aout_bps\x18\r \x01(\x01R\x06outBps\x12\x15\n" + + "\x06in_pps\x18\x0e \x01(\x01R\x05inPps\x12\x17\n" + + "\aout_pps\x18\x0f \x01(\x01R\x06outPps\x12\x1e\n" + + "\vutil_in_pct\x18\x10 \x01(\x02R\tutilInPct\x12 \n" + + "\futil_out_pct\x18\x11 \x01(\x02R\n" + + "utilOutPct\x12\x17\n" + + "\aoper_up\x18\x12 \x01(\bR\x06operUp\x12\x19\n" + + "\badmin_up\x18\x13 \x01(\bR\aadminUp\x12#\n" + + "\rcounter_reset\x18\x14 \x01(\bR\fcounterReset\x125\n" + + "\binterval\x18\x15 \x01(\v2\x19.google.protobuf.DurationR\binterval\"\xda\x01\n" + + "\fStatusChange\x12\x1b\n" + + "\tdevice_id\x18\x01 \x01(\tR\bdeviceId\x12*\n" + + "\x02ts\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\x02ts\x12+\n" + + "\x06status\x18\x03 \x01(\x0e2\x13.netpulse.v1.StatusR\x06status\x12<\n" + + "\x0fprevious_status\x18\x04 \x01(\x0e2\x13.netpulse.v1.StatusR\x0epreviousStatus\x12\x16\n" + + "\x06reason\x18\x05 \x01(\tR\x06reason\"\xae\x02\n" + + "\vCheckResult\x12\x19\n" + + "\bcheck_id\x18\x01 \x01(\tR\acheckId\x12\x1b\n" + + "\tdevice_id\x18\x02 \x01(\tR\bdeviceId\x12\x1d\n" + + "\n" + + "check_type\x18\x03 \x01(\tR\tcheckType\x12*\n" + + "\x02ts\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\x02ts\x125\n" + + "\bduration\x18\x05 \x01(\v2\x19.google.protobuf.DurationR\bduration\x12\x18\n" + + "\asuccess\x18\x06 \x01(\bR\asuccess\x12(\n" + + "\x05error\x18\a \x01(\v2\x12.netpulse.v1.ErrorR\x05error\x12!\n" + + "\fpayload_json\x18\b \x01(\fR\vpayloadJson\"\x87\x04\n" + + "\x0eTelemetryBatch\x12\x19\n" + + "\bbatch_id\x18\x01 \x01(\x04R\abatchId\x12\x19\n" + + "\bagent_id\x18\x02 \x01(\tR\aagentId\x129\n" + + "\n" + + "created_at\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12<\n" + + "\n" + + "new_series\x18\x04 \x03(\v2\x1d.netpulse.v1.SeriesDescriptorR\tnewSeries\x123\n" + + "\asamples\x18\x05 \x03(\v2\x19.netpulse.v1.MetricSampleR\asamples\x12+\n" + + "\x04icmp\x18\x06 \x03(\v2\x17.netpulse.v1.IcmpResultR\x04icmp\x12>\n" + + "\n" + + "interfaces\x18\a \x03(\v2\x1e.netpulse.v1.InterfaceCountersR\n" + + "interfaces\x12@\n" + + "\x0estatus_changes\x18\b \x03(\v2\x19.netpulse.v1.StatusChangeR\rstatusChanges\x12=\n" + + "\rcheck_results\x18\t \x03(\v2\x18.netpulse.v1.CheckResultR\fcheckResults\x12#\n" + + "\ris_retransmit\x18\n" + + " \x01(\bR\fisRetransmit\"\xa1\x02\n" + + "\fTelemetryAck\x123\n" + + "\x16acked_through_batch_id\x18\x01 \x01(\x04R\x13ackedThroughBatchId\x12$\n" + + "\x0enack_batch_ids\x18\x02 \x03(\x04R\fnackBatchIds\x12\"\n" + + "\rmax_in_flight\x18\x03 \x01(\rR\vmaxInFlight\x12:\n" + + "\vretry_after\x18\x04 \x01(\v2\x19.google.protobuf.DurationR\n" + + "retryAfter\x12,\n" + + "\x12reset_series_table\x18\x05 \x01(\bR\x10resetSeriesTable\x12(\n" + + "\x05error\x18\x06 \x01(\v2\x12.netpulse.v1.ErrorR\x05errorB netpulse.v1.SeriesDescriptor.LabelsEntry + 9, // 1: netpulse.v1.MetricSample.ts:type_name -> google.protobuf.Timestamp + 9, // 2: netpulse.v1.IcmpResult.ts:type_name -> google.protobuf.Timestamp + 9, // 3: netpulse.v1.InterfaceCounters.ts:type_name -> google.protobuf.Timestamp + 10, // 4: netpulse.v1.InterfaceCounters.interval:type_name -> google.protobuf.Duration + 9, // 5: netpulse.v1.StatusChange.ts:type_name -> google.protobuf.Timestamp + 11, // 6: netpulse.v1.StatusChange.status:type_name -> netpulse.v1.Status + 11, // 7: netpulse.v1.StatusChange.previous_status:type_name -> netpulse.v1.Status + 9, // 8: netpulse.v1.CheckResult.ts:type_name -> google.protobuf.Timestamp + 10, // 9: netpulse.v1.CheckResult.duration:type_name -> google.protobuf.Duration + 12, // 10: netpulse.v1.CheckResult.error:type_name -> netpulse.v1.Error + 9, // 11: netpulse.v1.TelemetryBatch.created_at:type_name -> google.protobuf.Timestamp + 0, // 12: netpulse.v1.TelemetryBatch.new_series:type_name -> netpulse.v1.SeriesDescriptor + 1, // 13: netpulse.v1.TelemetryBatch.samples:type_name -> netpulse.v1.MetricSample + 2, // 14: netpulse.v1.TelemetryBatch.icmp:type_name -> netpulse.v1.IcmpResult + 3, // 15: netpulse.v1.TelemetryBatch.interfaces:type_name -> netpulse.v1.InterfaceCounters + 4, // 16: netpulse.v1.TelemetryBatch.status_changes:type_name -> netpulse.v1.StatusChange + 5, // 17: netpulse.v1.TelemetryBatch.check_results:type_name -> netpulse.v1.CheckResult + 10, // 18: netpulse.v1.TelemetryAck.retry_after:type_name -> google.protobuf.Duration + 12, // 19: netpulse.v1.TelemetryAck.error:type_name -> netpulse.v1.Error + 20, // [20:20] is the sub-list for method output_type + 20, // [20:20] is the sub-list for method input_type + 20, // [20:20] is the sub-list for extension type_name + 20, // [20:20] is the sub-list for extension extendee + 0, // [0:20] is the sub-list for field type_name +} + +func init() { file_netpulse_v1_telemetry_proto_init() } +func file_netpulse_v1_telemetry_proto_init() { + if File_netpulse_v1_telemetry_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_telemetry_proto_rawDesc), len(file_netpulse_v1_telemetry_proto_rawDesc)), + NumEnums: 0, + NumMessages: 9, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_netpulse_v1_telemetry_proto_goTypes, + DependencyIndexes: file_netpulse_v1_telemetry_proto_depIdxs, + MessageInfos: file_netpulse_v1_telemetry_proto_msgTypes, + }.Build() + File_netpulse_v1_telemetry_proto = out.File + file_netpulse_v1_telemetry_proto_goTypes = nil + file_netpulse_v1_telemetry_proto_depIdxs = nil +} diff --git a/proto/README.md b/proto/README.md new file mode 100644 index 0000000..c0e0919 --- /dev/null +++ b/proto/README.md @@ -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 | diff --git a/proto/netpulse/v1/agent.proto b/proto/netpulse/v1/agent.proto new file mode 100644 index 0000000..1a88f26 --- /dev/null +++ b/proto/netpulse/v1/agent.proto @@ -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 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; + + // ".": 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 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; +} diff --git a/proto/netpulse/v1/common.proto b/proto/netpulse/v1/common.proto new file mode 100644 index 0000000..5a39fcd --- /dev/null +++ b/proto/netpulse/v1/common.proto @@ -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 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; +} diff --git a/proto/netpulse/v1/discovery.proto b/proto/netpulse/v1/discovery.proto new file mode 100644 index 0000000..fda7e5d --- /dev/null +++ b/proto/netpulse/v1/discovery.proto @@ -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; +} diff --git a/proto/netpulse/v1/logs.proto b/proto/netpulse/v1/logs.proto new file mode 100644 index 0000000..dcb6d50 --- /dev/null +++ b/proto/netpulse/v1/logs.proto @@ -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 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; +} diff --git a/proto/netpulse/v1/ncm.proto b/proto/netpulse/v1/ncm.proto new file mode 100644 index 0000000..97ee91b --- /dev/null +++ b/proto/netpulse/v1/ncm.proto @@ -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; +} diff --git a/proto/netpulse/v1/telemetry.proto b/proto/netpulse/v1/telemetry.proto new file mode 100644 index 0000000..d4d65d3 --- /dev/null +++ b/proto/netpulse/v1/telemetry.proto @@ -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 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; +} diff --git a/test/contract/contract_test.go b/test/contract/contract_test.go new file mode 100644 index 0000000..04929b6 --- /dev/null +++ b/test/contract/contract_test.go @@ -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) + } +} diff --git a/test/contract/go.mod b/test/contract/go.mod new file mode 100644 index 0000000..45b75bb --- /dev/null +++ b/test/contract/go.mod @@ -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 diff --git a/test/contract/go.sum b/test/contract/go.sum new file mode 100644 index 0000000..fcc6745 --- /dev/null +++ b/test/contract/go.sum @@ -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=