Syslog: транспорт до сервера і тригер позачергового бекапу
Приймач на зонді лежав без діла — тепер під'єднаний. Окремий стрім StreamLogs, а не контрольний канал: сплеск логів під час аварії не має заважати heartbeat і командам. Хост зіставляється за адресою джерела на зонді: у сервера немає контексту мережі клієнта, а один приватний діапазон трапляється в десятках кабінетів. Невідома адреса не привід викинути подію. Подія, що збіглася зі зразком у ncm.device_policies.syslog_match, ставить позачерговий збір конфігу. Типовий зразок покриває Cisco, HP/Huawei, Juniper і MikroTik — навмисно широкий: зайвий бекап коштує секунд, пропущений — цілої зміни. Заразом увесь репозиторій прогнано через gofmt: CI, написаний два кроки тому, перевіряє це і впав би на 29 файлах із порушеннями, накопиченими за весь проєкт. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
43d1f62b29
commit
c28852df66
35 changed files with 588 additions and 213 deletions
72
HISTORY.md
72
HISTORY.md
|
|
@ -2475,3 +2475,75 @@ button, [role='button'], summary {
|
|||
|
||||
Урок той самий, що з невидимою вкладкою: перш ніж шукати винного в коді,
|
||||
перевірити, що вимірюєш те, що думаєш.
|
||||
|
||||
## Syslog доведено до кінця
|
||||
|
||||
### Транспорт
|
||||
|
||||
Приймач на зонді був написаний минулого разу й лежав без діла. Тепер він
|
||||
під'єднаний: `logsLoop` віддає накопичене окремим стрімом `StreamLogs`,
|
||||
а не контрольним каналом — сплеск логів під час аварії не має заважати
|
||||
heartbeat і командам. Саме тому в контракті ці стріми й розділені.
|
||||
|
||||
Пачка збирається півсекунди. Це компроміс: тригер бекапу має спрацьовувати
|
||||
відчутно швидше за хвилинний цикл, а слати кожен рядок окремо немає сенсу.
|
||||
|
||||
Невідправлене повертається в чергу й доїде наступною сесією. Приймач
|
||||
живе поза сесією: обрив зв'язку з сервером не зупиняє збір журналу — саме
|
||||
заради цього черга й існує.
|
||||
|
||||
### Зіставлення хоста
|
||||
|
||||
За адресою джерела, на зонді. У сервера немає контексту мережі клієнта, а
|
||||
один і той самий приватний діапазон трапляється в десятках кабінетів.
|
||||
Зонд же має свіжий список своїх хостів із плану задач.
|
||||
|
||||
Не знайшли — подія все одно доїде з порожнім `device_id` і заповненим
|
||||
`source_ip`. Викинути журнал через незнайому адресу означало б утратити
|
||||
рівно те, що показує появу нового заліза в мережі.
|
||||
|
||||
### Тригер бекапу
|
||||
|
||||
Зразок звіряє Postgres, а не Go: політика зберігає його рядком у базі, і
|
||||
тягнути всі політики в пам'ять заради кожної пачки журналу означало б
|
||||
робити роботу там, де для неї немає даних.
|
||||
|
||||
Типовий зразок покриває три родини — Cisco `%SYS-5-CONFIG_I`, HP/Huawei
|
||||
`CFGCHG`, Juniper і MikroTik `commit complete`. Навмисно широкий: зайвий
|
||||
бекап коштує кількох секунд сесії, пропущений — цілої зміни, про яку
|
||||
ніхто не дізнається.
|
||||
|
||||
Сплеск однакових рядків не перетворюється на сплеск сесій до пристрою:
|
||||
`EnqueueConfigJob` уже відсіює другий `queued` для того самого хоста.
|
||||
|
||||
### Перевірено наскрізно
|
||||
|
||||
Окремий QA-зонд через запрошення, щоб не чіпати робочий:
|
||||
|
||||
```
|
||||
реєстрація -enroll → «приймач syslog слухає адреса=127.0.0.1:5514»
|
||||
план хост прив'язано до QA-зонда → tasks=1
|
||||
подія <189>… %SYS-5-CONFIG_I: Configured from console by admin
|
||||
надіслано з 127.0.0.5
|
||||
розбір severity 5, hostname rtr-qa, текст цілий (не порізаний у tag)
|
||||
зіставлення device_id = qa-syslog-host за адресою джерела
|
||||
запис ts.syslog
|
||||
тригер ncm.jobs: trigger=syslog, status=queued
|
||||
негатив %LINK-3-UPDOWN і вхід ssh записані, завдань не додали
|
||||
RFC5424 tag sshd[991], structured data np.user=admin, msgid ID9
|
||||
```
|
||||
|
||||
### Заразом: увесь репозиторій під gofmt
|
||||
|
||||
CI, який я написав два кроки тому, перевіряє `gofmt -l`. Прогнав його
|
||||
чесно — **29 файлів** із реальними порушеннями, накопиченими за весь
|
||||
проєкт: неправильний порядок імпортів, збите вирівнювання полів структур
|
||||
і ключів у літералах. Перший же запуск CI впав би на них.
|
||||
|
||||
Прогнав `gofmt -w` по всьому дереву разом із нормалізацією кінців рядків.
|
||||
Через це коміт зачіпає більше файлів, ніж сама можливість: правки
|
||||
формату й нового коду в тих самих файлах не розділити на два коміти без
|
||||
проміжного стану, який не збирається.
|
||||
|
||||
Урок: правило, яке ніхто не запускав, не виконується — воно лише
|
||||
здається виконаним.
|
||||
|
|
|
|||
13
ROADMAP.md
13
ROADMAP.md
|
|
@ -24,7 +24,7 @@ API віддає готове полотно з живими статусами,
|
|||
| Автовиявлення LLDP/CDP/ARP/FDB | ✅ | ✅ |
|
||||
| **Користувачі, ролі, вхід** | ✅ | ✅ |
|
||||
| **Шаблони опитування** | ✅ | ⚠️ є; немає автопризначення за sysObjectID, snmp.walk, тригерів у шаблоні |
|
||||
| **NCM (збір конфігів)** | ✅ | ⚠️ збір, розклад і Git є; немає приймача syslog, відкату й compliance |
|
||||
| **NCM (збір конфігів)** | ✅ | ⚠️ збір, розклад, Git і syslog є; немає відкату й compliance |
|
||||
| **Керування зондом із UI** | ✅ | ✅ |
|
||||
| **Алерти й сповіщення** | ✅ | ✅ |
|
||||
| **Мобільна адаптивність, PWA** | — | ⚠️ адаптив є, PWA немає |
|
||||
|
|
@ -174,7 +174,7 @@ JSONPath, `discard unchanged`) — на сервері при записі: ін
|
|||
|
||||
---
|
||||
|
||||
## Етап 7. NCM — збір конфігів до кінця — ⚠️ майже, 2026-08-25
|
||||
## Етап 7. NCM — збір конфігів до кінця — ✅ зроблено 2026-08-25
|
||||
|
||||
> **Зроблено:** агентський модуль SSH/Telnet, черга завдань, диспетчер,
|
||||
> вивантаження зі стисненням і дедуплікацією, візуальний diff у вебі,
|
||||
|
|
@ -182,11 +182,10 @@ JSONPath, `discard unchanged`) — на сервері при записі: ін
|
|||
> версією в UI, переливання історії командою `netpulse-gitsync`.
|
||||
> Перевірено наскрізно.
|
||||
>
|
||||
> **Лишилось:** тригер за Syslog. Він упирається не в тригер: приймача
|
||||
> syslog немає взагалі — `ts.syslog` і `WriteLogs` у сховищі є, але на
|
||||
> агенті немає модуля, який слухав би 514/udp, і транспорт до сервера
|
||||
> не під'єднано. Це окрема робота розміром із власний етап, а не
|
||||
> дописування умови до наявного коду.
|
||||
> **Додано 2026-08-25:** приймач syslog на зонді (RFC3164/5424, черга,
|
||||
> ліміт частоти на джерело), транспорт `StreamLogs`, зіставлення хоста
|
||||
> за адресою джерела й тригер позачергового бекапу за зразком у
|
||||
> `ncm.device_policies.syslog_match`.
|
||||
|
||||
## Етап 7 (початковий план)
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import (
|
|||
"github.com/netpulse/netpulse/agent/internal/module"
|
||||
"github.com/netpulse/netpulse/agent/internal/modules/icmp"
|
||||
"github.com/netpulse/netpulse/agent/internal/modules/snmp"
|
||||
"github.com/netpulse/netpulse/agent/internal/modules/syslog"
|
||||
"github.com/netpulse/netpulse/agent/internal/modules/topology"
|
||||
"github.com/netpulse/netpulse/agent/internal/scheduler"
|
||||
"github.com/netpulse/netpulse/agent/internal/session"
|
||||
|
|
@ -116,6 +117,17 @@ func run() error {
|
|||
}
|
||||
reg.EnsureDefaults(cfg.DefaultModules...)
|
||||
|
||||
// Приймач syslog не модуль реєстру: у нього немає задач і розкладу,
|
||||
// він просто слухає порт. Вмикається тим самим переліком -modules,
|
||||
// щоб людині не треба було знати про цю різницю.
|
||||
var syslogRecv *syslog.Receiver
|
||||
for _, m := range cfg.DefaultModules {
|
||||
if m == "syslog" {
|
||||
syslogRecv = syslog.New(cfg.SyslogListen, log)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
buf := telemetry.NewBuffer(telemetry.Options{
|
||||
MaxItems: cfg.BufferMaxItems,
|
||||
MaxBytes: cfg.BufferMaxBytes,
|
||||
|
|
@ -146,6 +158,7 @@ func run() error {
|
|||
MinBackoff: cfg.MinBackoff,
|
||||
MaxBackoff: cfg.MaxBackoff,
|
||||
DefaultModules: cfg.DefaultModules,
|
||||
Syslog: syslogRecv,
|
||||
})
|
||||
|
||||
sched := scheduler.New(scheduler.Config{
|
||||
|
|
@ -175,6 +188,22 @@ func run() error {
|
|||
sched.Run(ctx)
|
||||
}()
|
||||
|
||||
// Приймач живе поза сесією: обрив зв'язку з сервером не має
|
||||
// зупиняти збір журналу. Події лягають у чергу й доїдуть, коли
|
||||
// сесія відновиться — саме заради цього черга й існує.
|
||||
if syslogRecv != nil {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := syslogRecv.Run(ctx); err != nil {
|
||||
// Зайнятий порт або брак прав — не привід зупиняти
|
||||
// зонд: решта модулів працює, а причину видно в журналі.
|
||||
log.Error("приймач syslog не запустився",
|
||||
"адреса", cfg.SyslogListen, "err", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
runErr := sess.Run(ctx)
|
||||
|
||||
wg.Wait()
|
||||
|
|
|
|||
|
|
@ -45,6 +45,12 @@ type Config struct {
|
|||
// Модулі, увімкнені до першої відповіді сервера.
|
||||
DefaultModules []string
|
||||
|
||||
// Адреса приймача syslog. Порт нижче 1024 вимагає
|
||||
// CAP_NET_BIND_SERVICE — тому й налаштовується: віддати зонду це
|
||||
// право можна не всюди, а перенаправити 514 на 5514 правилом
|
||||
// фаєрвола можна скрізь.
|
||||
SyslogListen string
|
||||
|
||||
// Одноразовий токен запрошення. Якщо заданий — зонд спершу
|
||||
// реєструється, зберігає посвідчення й лише потім працює.
|
||||
EnrollToken string
|
||||
|
|
@ -104,6 +110,9 @@ func Parse(args []string) (*Config, error) {
|
|||
mods := fs.String("modules", envOr("NETPULSE_MODULES", "icmp"),
|
||||
"модулі, увімкнені до відповіді сервера (через кому)")
|
||||
|
||||
fs.StringVar(&c.SyslogListen, "syslog-listen", envOr("NETPULSE_SYSLOG_LISTEN", ":514"),
|
||||
"адреса приймача syslog; діє лише з модулем syslog")
|
||||
|
||||
fs.StringVar(&c.EnrollToken, "enroll", envOr("NETPULSE_ENROLL", ""),
|
||||
"одноразовий токен запрошення з UI")
|
||||
fs.StringVar(&c.EnrollName, "name", envOr("NETPULSE_NAME", ""),
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
|
@ -23,10 +23,10 @@ import (
|
|||
// і вимагати від systemd-юніта нести токен означало б залишити його в
|
||||
// журналі при кожному `systemctl show`.
|
||||
type Identity struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
AgentName string `json:"agent_name"`
|
||||
Token string `json:"token"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
AgentID string `json:"agent_id"`
|
||||
AgentName string `json:"agent_name"`
|
||||
Token string `json:"token"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
EnrolledAt time.Time `json:"enrolled_at"`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
// Package snmp — модуль опитування по SNMP v2c/v3.
|
||||
//
|
||||
// Два типи чеків:
|
||||
// snmp.if — лічильники інтерфейсів (те, що живить анімацію трафіку)
|
||||
// snmp.get — довільні OID → узагальнені метрики
|
||||
//
|
||||
// snmp.if — лічильники інтерфейсів (те, що живить анімацію трафіку)
|
||||
// snmp.get — довільні OID → узагальнені метрики
|
||||
//
|
||||
// Свідоме рішення: агент НЕ ходить по ifTable, щоб з'ясувати, які
|
||||
// інтерфейси існують. Перелік (ifIndex → interface_id → speed_bps)
|
||||
|
|
@ -30,18 +31,18 @@ import (
|
|||
// Базові OID. HC — 64-бітні лічильники з ifXTable; на гігабіті
|
||||
// 32-бітні перевертаються за 34 секунди, тому вони лише запасний варіант.
|
||||
const (
|
||||
oidIfInOctets = ".1.3.6.1.2.1.2.2.1.10"
|
||||
oidIfOutOctets = ".1.3.6.1.2.1.2.2.1.16"
|
||||
oidIfHCInOctets = ".1.3.6.1.2.1.31.1.1.1.6"
|
||||
oidIfHCOutOctets = ".1.3.6.1.2.1.31.1.1.1.10"
|
||||
oidIfHCInUcast = ".1.3.6.1.2.1.31.1.1.1.7"
|
||||
oidIfHCOutUcast = ".1.3.6.1.2.1.31.1.1.1.11"
|
||||
oidIfInErrors = ".1.3.6.1.2.1.2.2.1.14"
|
||||
oidIfOutErrors = ".1.3.6.1.2.1.2.2.1.20"
|
||||
oidIfInDiscards = ".1.3.6.1.2.1.2.2.1.13"
|
||||
oidIfOutDiscards = ".1.3.6.1.2.1.2.2.1.19"
|
||||
oidIfAdminStatus = ".1.3.6.1.2.1.2.2.1.7"
|
||||
oidIfOperStatus = ".1.3.6.1.2.1.2.2.1.8"
|
||||
oidIfInOctets = ".1.3.6.1.2.1.2.2.1.10"
|
||||
oidIfOutOctets = ".1.3.6.1.2.1.2.2.1.16"
|
||||
oidIfHCInOctets = ".1.3.6.1.2.1.31.1.1.1.6"
|
||||
oidIfHCOutOctets = ".1.3.6.1.2.1.31.1.1.1.10"
|
||||
oidIfHCInUcast = ".1.3.6.1.2.1.31.1.1.1.7"
|
||||
oidIfHCOutUcast = ".1.3.6.1.2.1.31.1.1.1.11"
|
||||
oidIfInErrors = ".1.3.6.1.2.1.2.2.1.14"
|
||||
oidIfOutErrors = ".1.3.6.1.2.1.2.2.1.20"
|
||||
oidIfInDiscards = ".1.3.6.1.2.1.2.2.1.13"
|
||||
oidIfOutDiscards = ".1.3.6.1.2.1.2.2.1.19"
|
||||
oidIfAdminStatus = ".1.3.6.1.2.1.2.2.1.7"
|
||||
oidIfOperStatus = ".1.3.6.1.2.1.2.2.1.8"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
|
|
@ -85,12 +86,12 @@ type OIDSpec struct {
|
|||
// опитуваннями. Серверний розрахунок за номінальним інтервалом дає
|
||||
// похибку рівно на мережеву затримку й джитер планувальника.
|
||||
type counterState struct {
|
||||
ts time.Time
|
||||
inOctets uint64
|
||||
outOctets uint64
|
||||
inUcast uint64
|
||||
outUcast uint64
|
||||
valid bool
|
||||
ts time.Time
|
||||
inOctets uint64
|
||||
outOctets uint64
|
||||
inUcast uint64
|
||||
outUcast uint64
|
||||
valid bool
|
||||
}
|
||||
|
||||
type Module struct {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
// Package scheduler виконує план задач, отриманий від сервера.
|
||||
//
|
||||
// Дві вимоги визначають будову:
|
||||
// 1. 5000 чеків з інтервалом 60 с не мають стартувати одночасно —
|
||||
// інакше зонд раз на хвилину викидає сплеск на всю мережу.
|
||||
// Розведення дає schedule_offset, який рахує СЕРВЕР (детерміновано
|
||||
// від check_id), щоб воно зберігалось між перезапусками агента.
|
||||
// 2. Повільний пристрій не має гальмувати решту: кожна задача має
|
||||
// власний дедлайн, а паралельність обмежена семафором.
|
||||
// 1. 5000 чеків з інтервалом 60 с не мають стартувати одночасно —
|
||||
// інакше зонд раз на хвилину викидає сплеск на всю мережу.
|
||||
// Розведення дає schedule_offset, який рахує СЕРВЕР (детерміновано
|
||||
// від check_id), щоб воно зберігалось між перезапусками агента.
|
||||
// 2. Повільний пристрій не має гальмувати решту: кожна задача має
|
||||
// власний дедлайн, а паралельність обмежена семафором.
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
|
|
@ -436,12 +436,12 @@ func (s *Scheduler) execute(ctx context.Context, e *entry) {
|
|||
elapsed := time.Since(started)
|
||||
|
||||
cr := &npv1.CheckResult{
|
||||
CheckId: task.CheckID,
|
||||
DeviceId: task.DeviceID,
|
||||
CheckType: task.CheckType,
|
||||
Ts: timestamppb.New(started),
|
||||
Duration: durationpb.New(elapsed),
|
||||
Success: runErr == nil,
|
||||
CheckId: task.CheckID,
|
||||
DeviceId: task.DeviceID,
|
||||
CheckType: task.CheckType,
|
||||
Ts: timestamppb.New(started),
|
||||
Duration: durationpb.New(elapsed),
|
||||
Success: runErr == nil,
|
||||
PayloadJson: res.Payload,
|
||||
}
|
||||
|
||||
|
|
@ -486,10 +486,10 @@ func classify(err error, ctx context.Context) string {
|
|||
|
||||
type taskHeap []*entry
|
||||
|
||||
func (h taskHeap) Len() int { return len(h) }
|
||||
func (h taskHeap) Less(i, j int) bool { return h[i].nextRun.Before(h[j].nextRun) }
|
||||
func (h taskHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i]; h[i].index = i; h[j].index = j }
|
||||
func (h *taskHeap) Push(x any) { e := x.(*entry); e.index = len(*h); *h = append(*h, e) }
|
||||
func (h taskHeap) Len() int { return len(h) }
|
||||
func (h taskHeap) Less(i, j int) bool { return h[i].nextRun.Before(h[j].nextRun) }
|
||||
func (h taskHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i]; h[i].index = i; h[j].index = j }
|
||||
func (h *taskHeap) Push(x any) { e := x.(*entry); e.index = len(*h); *h = append(*h, e) }
|
||||
func (h *taskHeap) Pop() any {
|
||||
old := *h
|
||||
n := len(old)
|
||||
|
|
|
|||
139
agent/internal/session/logs.go
Normal file
139
agent/internal/session/logs.go
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
package session
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
|
||||
)
|
||||
|
||||
// maxLogBatch — стеля подій в одній пачці.
|
||||
//
|
||||
// Журнал не телеметрія: під час аварії він приходить сплеском, і
|
||||
// відправляти по одній події означає накрутити тисячі викликів рівно
|
||||
// тоді, коли мережа й так не в порядку.
|
||||
const maxLogBatch = 500
|
||||
|
||||
// logFlush — як довго накопичувати пачку.
|
||||
//
|
||||
// Півсекунди — компроміс: тригер бекапу за «%SYS-5-CONFIG_I» має
|
||||
// спрацьовувати відчутно швидше за хвилинний цикл, а слати кожен рядок
|
||||
// окремо немає сенсу.
|
||||
const logFlush = 500 * time.Millisecond
|
||||
|
||||
// logsLoop віддає накопичені події серверу.
|
||||
//
|
||||
// Окремий стрім, а не контрольний канал: сплеск логів під час аварії не
|
||||
// має заважати heartbeat і командам. Саме тому в контракті StreamLogs
|
||||
// існує окремо від Control.
|
||||
func (s *Session) logsLoop(ctx context.Context, client npv1.AgentServiceClient) error {
|
||||
rec := s.cfg.Syslog
|
||||
if rec == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
stream, err := client.StreamLogs(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Читач підтверджень окремою горутиною: Send і Recv на одному
|
||||
// стрімі — єдине, що gRPC дозволяє робити паралельно.
|
||||
ackErr := make(chan error, 1)
|
||||
go func() {
|
||||
for {
|
||||
ack, err := stream.Recv()
|
||||
if err != nil {
|
||||
ackErr <- err
|
||||
return
|
||||
}
|
||||
if e := ack.GetError(); e != nil {
|
||||
s.log.Warn("сервер не прийняв журнал",
|
||||
"code", e.GetCode(), "msg", e.GetMessage())
|
||||
continue
|
||||
}
|
||||
// Ліміти задає сервер: він бачить картину по всіх зондах і
|
||||
// краще знає, що вважати шумом.
|
||||
rec.ApplyAck(ack.GetMinSeverity(), ack.GetRateLimitPerSource())
|
||||
}
|
||||
}()
|
||||
|
||||
ticker := time.NewTicker(logFlush)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case err := <-ackErr:
|
||||
return err
|
||||
case <-rec.Ready():
|
||||
// Подія з'явилась — але не летимо одразу: даємо тіку
|
||||
// зібрати сусідів у ту саму пачку.
|
||||
case <-ticker.C:
|
||||
}
|
||||
|
||||
entries, dropped := rec.Drain(maxLogBatch)
|
||||
if len(entries) == 0 && dropped == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
batch := &npv1.LogBatch{
|
||||
BatchId: s.nextBatch.Add(1),
|
||||
AgentId: s.cfg.AgentID,
|
||||
Syslog: entries,
|
||||
Dropped: dropped,
|
||||
}
|
||||
if err := stream.Send(batch); err != nil {
|
||||
// Невідправлене повертаємо в чергу: наступна сесія
|
||||
// доставить. Порядок зберігається — журнал читають
|
||||
// хронологічно.
|
||||
rec.Requeue(entries)
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// resolveDeviceByIP шукає хост за адресою джерела.
|
||||
//
|
||||
// Зіставлення на зонді, а не на сервері: у сервера немає контексту
|
||||
// мережі клієнта, а один і той самий приватний діапазон трапляється в
|
||||
// десятках кабінетів. Зонд же має свіжий список своїх хостів.
|
||||
//
|
||||
// Не знайшли — не біда: подія все одно доїде з порожнім device_id і
|
||||
// заповненим source_ip. Викидати журнал через незнайому адресу означало
|
||||
// б утратити рівно те, що показує появу нового заліза в мережі.
|
||||
func (s *Session) resolveDeviceByIP(ip string) string {
|
||||
if ip == "" {
|
||||
return ""
|
||||
}
|
||||
addr := net.ParseIP(ip)
|
||||
|
||||
s.devMu.RLock()
|
||||
defer s.devMu.RUnlock()
|
||||
|
||||
for id, d := range s.devices {
|
||||
target := strings.TrimSpace(d.GetAddress())
|
||||
if target == "" {
|
||||
continue
|
||||
}
|
||||
if target == ip {
|
||||
return id
|
||||
}
|
||||
// Адреса хоста може бути записана з портом або як FQDN —
|
||||
// порівнюємо ще й розібраний варіант.
|
||||
if addr != nil {
|
||||
if host, _, err := net.SplitHostPort(target); err == nil {
|
||||
if net.ParseIP(host).Equal(addr) {
|
||||
return id
|
||||
}
|
||||
}
|
||||
if net.ParseIP(target).Equal(addr) {
|
||||
return id
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/netpulse/netpulse/agent/internal/module"
|
||||
"github.com/netpulse/netpulse/agent/internal/modules/syslog"
|
||||
"github.com/netpulse/netpulse/agent/internal/scheduler"
|
||||
"github.com/netpulse/netpulse/agent/internal/telemetry"
|
||||
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
|
||||
|
|
@ -52,6 +53,11 @@ type Config struct {
|
|||
|
||||
// Модулі, увімкнені до першої відповіді сервера.
|
||||
DefaultModules []string
|
||||
|
||||
// Приймач syslog. Порожній — зонд журнали не збирає, і стрім до
|
||||
// сервера не відкривається взагалі: тримати порожній канал заради
|
||||
// вимкненої можливості немає сенсу.
|
||||
Syslog *syslog.Receiver
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
|
|
@ -71,7 +77,7 @@ type Session struct {
|
|||
creds map[string][]*npv1.Credential
|
||||
credExpiry time.Time
|
||||
// Коли востаннє просили поновлення — щоб не питати щотіку.
|
||||
credAsked time.Time
|
||||
credAsked time.Time
|
||||
|
||||
devMu sync.RWMutex
|
||||
devices map[string]*npv1.DeviceTarget
|
||||
|
|
@ -104,7 +110,7 @@ func New(cfg Config) *Session {
|
|||
if cfg.MaxBackoff <= 0 {
|
||||
cfg.MaxBackoff = 2 * time.Minute
|
||||
}
|
||||
return &Session{
|
||||
s := &Session{
|
||||
cfg: cfg,
|
||||
log: cfg.Logger,
|
||||
statusCh: make(chan *npv1.TaskStatusUpdate, 512),
|
||||
|
|
@ -113,6 +119,12 @@ func New(cfg Config) *Session {
|
|||
devices: make(map[string]*npv1.DeviceTarget),
|
||||
startedAt: time.Now(),
|
||||
}
|
||||
// Приймач слухає порт незалежно від сесії, але зіставити адресу з
|
||||
// хостом може лише вона: список хостів приходить у плані.
|
||||
if cfg.Syslog != nil {
|
||||
cfg.Syslog.SetResolver(s.resolveDeviceByIP)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// SetScheduler замикає взаємну залежність: планувальник потребує
|
||||
|
|
@ -291,6 +303,9 @@ func (s *Session) runOnce(ctx context.Context) error {
|
|||
spawn("status", func() error { return s.statusLoop(sctx, out) })
|
||||
spawn("telemetry", func() error { return s.telemetryLoop(sctx, client, welcome) })
|
||||
spawn("discovery", func() error { return s.discoveryLoop(sctx, client) })
|
||||
if s.cfg.Syslog != nil {
|
||||
spawn("logs", func() error { return s.logsLoop(sctx, client) })
|
||||
}
|
||||
|
||||
// Читання команд — у цій же горутині.
|
||||
readErr := s.controlLoop(sctx, ctrl, out)
|
||||
|
|
|
|||
|
|
@ -22,8 +22,8 @@ import (
|
|||
"github.com/netpulse/netpulse/server/internal/crypto"
|
||||
"github.com/netpulse/netpulse/server/internal/gitstore"
|
||||
"github.com/netpulse/netpulse/server/internal/httpapi"
|
||||
"github.com/netpulse/netpulse/server/webui"
|
||||
"github.com/netpulse/netpulse/server/internal/store"
|
||||
"github.com/netpulse/netpulse/server/webui"
|
||||
)
|
||||
|
||||
var version = "dev"
|
||||
|
|
@ -52,7 +52,7 @@ func run() error {
|
|||
"як часто обчислювати правила алертів; 0 — не запускати движок")
|
||||
alertKeep = flag.Duration("alert-retention", 7*24*time.Hour,
|
||||
"скільки тримати закриті алерти до переносу в історію")
|
||||
gitRoot = flag.String("git-root", envOr("NETPULSE_GIT_ROOT", "/var/lib/netpulse/git"),
|
||||
gitRoot = flag.String("git-root", envOr("NETPULSE_GIT_ROOT", "/var/lib/netpulse/git"),
|
||||
"корінь сховища версій конфігів; порожньо — без Git")
|
||||
privateHooks = flag.Bool("allow-private-webhooks",
|
||||
os.Getenv("NETPULSE_ALLOW_PRIVATE_WEBHOOKS") == "1",
|
||||
|
|
|
|||
|
|
@ -17,11 +17,11 @@ import (
|
|||
"syscall"
|
||||
"time"
|
||||
|
||||
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
|
||||
"github.com/netpulse/netpulse/server/internal/crypto"
|
||||
"github.com/netpulse/netpulse/server/internal/gitstore"
|
||||
"github.com/netpulse/netpulse/server/internal/grpcapi"
|
||||
"github.com/netpulse/netpulse/server/internal/store"
|
||||
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/keepalive"
|
||||
|
|
|
|||
|
|
@ -20,11 +20,11 @@ import (
|
|||
// переліку: перевірка «чи підходить хвилина» стає одним зсувом, а
|
||||
// шукати наступний запуск доводиться перебором хвилин.
|
||||
type Schedule struct {
|
||||
minute uint64 // 0..59
|
||||
hour uint64 // 0..23
|
||||
dom uint64 // 1..31
|
||||
month uint64 // 1..12
|
||||
dow uint64 // 0..6, неділя = 0
|
||||
minute uint64 // 0..59
|
||||
hour uint64 // 0..23
|
||||
dom uint64 // 1..31
|
||||
month uint64 // 1..12
|
||||
dow uint64 // 0..6, неділя = 0
|
||||
// domRestricted і dowRestricted кажуть, чи задане поле явно.
|
||||
// Потрібні для правила cron: якщо обидва задані, збіг за будь-яким
|
||||
// із них уже підходить. Це не примха, а спосіб записати «щоп'ятниці
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ import (
|
|||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/netpulse/netpulse/server/internal/store"
|
||||
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
|
||||
"github.com/netpulse/netpulse/server/internal/store"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -22,10 +22,10 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
|
||||
"github.com/netpulse/netpulse/server/internal/crypto"
|
||||
"github.com/netpulse/netpulse/server/internal/grpcapi"
|
||||
"github.com/netpulse/netpulse/server/internal/store"
|
||||
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/metadata"
|
||||
|
|
@ -566,14 +566,14 @@ func TestDiscoveryResolvesLink(t *testing.T) {
|
|||
AgentId: f.agentID,
|
||||
Final: true,
|
||||
Neighbors: []*npv1.NeighborRecord{{
|
||||
DeviceId: f.deviceID,
|
||||
LocalInterfaceId: f.ifaceID,
|
||||
Proto: npv1.DiscoveryProto_DISCOVERY_PROTO_LLDP,
|
||||
RemoteChassisId: "0011.2233.6677",
|
||||
RemoteSystemName: "edge-rtr-01",
|
||||
RemotePortId: "ether1",
|
||||
DeviceId: f.deviceID,
|
||||
LocalInterfaceId: f.ifaceID,
|
||||
Proto: npv1.DiscoveryProto_DISCOVERY_PROTO_LLDP,
|
||||
RemoteChassisId: "0011.2233.6677",
|
||||
RemoteSystemName: "edge-rtr-01",
|
||||
RemotePortId: "ether1",
|
||||
RemoteCapabilities: []string{"bridge", "router"},
|
||||
SeenAt: timestamppb.Now(),
|
||||
SeenAt: timestamppb.Now(),
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -15,9 +15,9 @@ import (
|
|||
"sync"
|
||||
"time"
|
||||
|
||||
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
|
||||
"github.com/netpulse/netpulse/server/internal/crypto"
|
||||
"github.com/netpulse/netpulse/server/internal/store"
|
||||
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/metadata"
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ import (
|
|||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/netpulse/netpulse/server/internal/store"
|
||||
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
|
||||
"github.com/netpulse/netpulse/server/internal/store"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
|
@ -121,7 +121,7 @@ func (s *Service) StreamLogs(stream npv1.AgentService_StreamLogsServer) error {
|
|||
s.log.Error("журнали не записались", "agent", agent.ID, "err", err)
|
||||
if err := stream.Send(&npv1.LogAck{
|
||||
AckedThroughBatchId: acked,
|
||||
Error: &npv1.Error{Code: "write_failed", Message: err.Error(), Retryable: true},
|
||||
Error: &npv1.Error{Code: "write_failed", Message: err.Error(), Retryable: true},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -132,6 +132,8 @@ func (s *Service) StreamLogs(stream npv1.AgentService_StreamLogsServer) error {
|
|||
s.log.Warn("зонд відкинув події через ліміт",
|
||||
"agent", agent.ID, "dropped", batch.GetDropped())
|
||||
}
|
||||
|
||||
s.triggerSyslogBackups(ctx, agent, batch.GetSyslog())
|
||||
if batch.GetBatchId() > acked {
|
||||
acked = batch.GetBatchId()
|
||||
}
|
||||
|
|
@ -142,6 +144,41 @@ func (s *Service) StreamLogs(stream npv1.AgentService_StreamLogsServer) error {
|
|||
}
|
||||
}
|
||||
|
||||
// triggerSyslogBackups ставить позачерговий збір конфігу за подією в
|
||||
// журналі.
|
||||
//
|
||||
// Заради цього приймач журналу й потрібен NCM: нічний cron ловить зміну
|
||||
// в середньому через дванадцять годин, а «%SYS-5-CONFIG_I» — за секунди.
|
||||
// Інженер, який щось зламав о десятій ранку, встигає піти додому, і без
|
||||
// цього тригера лишається вчорашній конфіг і сьогоднішня аварія без
|
||||
// нічого між ними.
|
||||
//
|
||||
// Помилка тут не зриває приймання журналу: події вже записані, і
|
||||
// втратити їх через проблему з чергою завдань було б обміном гіршим за
|
||||
// пропущений бекап.
|
||||
func (s *Service) triggerSyslogBackups(ctx context.Context, agent *store.Agent, entries []*npv1.SyslogEntry) {
|
||||
if len(entries) == 0 {
|
||||
return
|
||||
}
|
||||
devices, err := s.store.SyslogBackupTriggers(ctx, agent.TenantID, entries)
|
||||
if err != nil {
|
||||
s.log.Error("звірка журналу з політиками бекапу", "agent", agent.ID, "err", err)
|
||||
return
|
||||
}
|
||||
for _, deviceID := range devices {
|
||||
// Порожній користувач — завдання від системи, а не від людини.
|
||||
// Повторний виклик для хоста з уже поставленим завданням нічого
|
||||
// не додає: EnqueueConfigJob сам це відсіює, тож сплеск однакових
|
||||
// рядків не перетворюється на сплеск сесій до пристрою.
|
||||
if _, err := s.store.EnqueueConfigJob(ctx, agent.TenantID, deviceID, "syslog", ""); err != nil {
|
||||
s.log.Error("бекап за подією журналу не поставлено",
|
||||
"device", deviceID, "err", err)
|
||||
continue
|
||||
}
|
||||
s.log.Info("подія журналу призначила збір конфігу", "device", deviceID)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Автовиявлення
|
||||
// ---------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -21,10 +21,10 @@ import (
|
|||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
|
||||
"github.com/netpulse/netpulse/server/internal/auth"
|
||||
"github.com/netpulse/netpulse/server/internal/httpapi"
|
||||
"github.com/netpulse/netpulse/server/internal/store"
|
||||
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -582,4 +582,3 @@ func (s *Server) handleGetDeviceCredentials(w http.ResponseWriter, r *http.Reque
|
|||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"credential_ids": ids})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -370,4 +370,3 @@ func (s *Server) handleDeleteUserGroup(w http.ResponseWriter, r *http.Request, p
|
|||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -456,4 +456,3 @@ func (s *Server) handleCloneTemplate(w http.ResponseWriter, r *http.Request, p *
|
|||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"id": id})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ package store
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
|
@ -27,12 +27,12 @@ type Agent struct {
|
|||
|
||||
// Limits — ліміти опитування, які сервер диктує зонду в Welcome.
|
||||
type Limits struct {
|
||||
MaxConcurrency int
|
||||
IcmpRatePPS int
|
||||
BatchSize int
|
||||
BatchInterval time.Duration
|
||||
MaxInFlight int
|
||||
HeartbeatEvery time.Duration
|
||||
MaxConcurrency int
|
||||
IcmpRatePPS int
|
||||
BatchSize int
|
||||
BatchInterval time.Duration
|
||||
MaxInFlight int
|
||||
HeartbeatEvery time.Duration
|
||||
}
|
||||
|
||||
func defaultLimits() Limits {
|
||||
|
|
@ -59,11 +59,11 @@ func (s *Store) AuthenticateAgent(ctx context.Context, token string) (*Agent, er
|
|||
sum := sha256.Sum256([]byte(token))
|
||||
|
||||
var (
|
||||
a Agent
|
||||
siteID *string
|
||||
hash []byte
|
||||
status string
|
||||
limits map[string]any
|
||||
a Agent
|
||||
siteID *string
|
||||
hash []byte
|
||||
status string
|
||||
limits map[string]any
|
||||
)
|
||||
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
|
|
|
|||
|
|
@ -29,16 +29,16 @@ type Rule struct {
|
|||
// це найчастіший випадок («ping усіх»), і вимагати для нього явного
|
||||
// переліку означало б ламати правило щоразу, коли додається пристрій.
|
||||
type Selector struct {
|
||||
DeviceIDs []string `json:"device_ids"`
|
||||
GroupIDs []string `json:"group_ids"`
|
||||
SiteIDs []string `json:"site_ids"`
|
||||
Kinds []string `json:"kinds"`
|
||||
DeviceIDs []string `json:"device_ids"`
|
||||
GroupIDs []string `json:"group_ids"`
|
||||
SiteIDs []string `json:"site_ids"`
|
||||
Kinds []string `json:"kinds"`
|
||||
// Хости, яким призначено ці шаблони. Так тригер шаблону стає одним
|
||||
// правилом замість правила на кожен хост: призначили шаблон новому
|
||||
// комутатору — він одразу під правилом, без перегенерації.
|
||||
TemplateIDs []string `json:"template_ids"`
|
||||
Vendors []string `json:"vendors"`
|
||||
Tags map[string]string `json:"tags"`
|
||||
TemplateIDs []string `json:"template_ids"`
|
||||
Vendors []string `json:"vendors"`
|
||||
Tags map[string]string `json:"tags"`
|
||||
}
|
||||
|
||||
// Condition — умова спрацювання.
|
||||
|
|
|
|||
|
|
@ -29,8 +29,8 @@ const InterfaceCheckInterval = 60 * time.Second
|
|||
|
||||
// ifCheckParams — те, що лягає в core.checks.params для snmp.if.
|
||||
type ifCheckParams struct {
|
||||
UseHCCounters bool `json:"use_hc_counters"`
|
||||
Interfaces []ifCheckTarget `json:"interfaces"`
|
||||
UseHCCounters bool `json:"use_hc_counters"`
|
||||
Interfaces []ifCheckTarget `json:"interfaces"`
|
||||
}
|
||||
|
||||
type ifCheckTarget struct {
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/netpulse/netpulse/server/internal/crypto"
|
||||
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
|
||||
"github.com/netpulse/netpulse/server/internal/crypto"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
|
|
@ -147,13 +147,13 @@ func fillSecret(cred *npv1.Credential, kind, proto string, plaintext []byte, opt
|
|||
}
|
||||
}
|
||||
cred.SnmpV3 = &npv1.SnmpV3Options{
|
||||
Level: secLevel(opts.SecLevel),
|
||||
AuthProtocol: opts.AuthProto,
|
||||
AuthPassword: p.AuthPassword,
|
||||
PrivProtocol: opts.PrivProto,
|
||||
PrivPassword: p.PrivPassword,
|
||||
ContextName: opts.Context,
|
||||
SecurityName: opts.SecurityName,
|
||||
Level: secLevel(opts.SecLevel),
|
||||
AuthProtocol: opts.AuthProto,
|
||||
AuthPassword: p.AuthPassword,
|
||||
PrivProtocol: opts.PrivProto,
|
||||
PrivPassword: p.PrivPassword,
|
||||
ContextName: opts.Context,
|
||||
SecurityName: opts.SecurityName,
|
||||
}
|
||||
|
||||
case kind == "ssh_key":
|
||||
|
|
|
|||
|
|
@ -487,7 +487,6 @@ func (s *Store) DeviceGroupsOf(ctx context.Context, tenantID string) (map[string
|
|||
return out, err
|
||||
}
|
||||
|
||||
|
||||
// nonNilIDs віддає порожній масив замість nil.
|
||||
//
|
||||
// pgx перетворює nil-слайс на SQL NULL, а `id = ANY(NULL)` — це не
|
||||
|
|
|
|||
|
|
@ -8,23 +8,23 @@ import (
|
|||
)
|
||||
|
||||
type DeviceSummary struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
Vendor string `json:"vendor,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
SiteName string `json:"site_name,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Enabled bool `json:"enabled"`
|
||||
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
|
||||
AgentID string `json:"agent_id,omitempty"`
|
||||
IfaceCount int `json:"interface_count"`
|
||||
GroupIDs []string `json:"group_ids"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
Vendor string `json:"vendor,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
SiteName string `json:"site_name,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Enabled bool `json:"enabled"`
|
||||
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
|
||||
AgentID string `json:"agent_id,omitempty"`
|
||||
IfaceCount int `json:"interface_count"`
|
||||
GroupIDs []string `json:"group_ids"`
|
||||
// Writable каже інтерфейсу, чи показувати дії. Рахується з груп
|
||||
// користувача, а не з ролі: роль дозволяє редагувати пристрої взагалі,
|
||||
// групи — саме цей.
|
||||
Writable bool `json:"writable"`
|
||||
Writable bool `json:"writable"`
|
||||
}
|
||||
|
||||
// ListDevices — інвентар для таблиці в UI.
|
||||
|
|
@ -84,17 +84,17 @@ func (s *Store) ListDevices(ctx context.Context, tenantID string, sc Scope) ([]D
|
|||
}
|
||||
|
||||
type AgentSummary struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Version string `json:"version,omitempty"`
|
||||
OS string `json:"os,omitempty"`
|
||||
Arch string `json:"arch,omitempty"`
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
LastHeartbeatAt *time.Time `json:"last_heartbeat_at,omitempty"`
|
||||
EnabledModules []string `json:"enabled_modules"`
|
||||
Health map[string]any `json:"health"`
|
||||
DeviceCount int `json:"device_count"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Version string `json:"version,omitempty"`
|
||||
OS string `json:"os,omitempty"`
|
||||
Arch string `json:"arch,omitempty"`
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
LastHeartbeatAt *time.Time `json:"last_heartbeat_at,omitempty"`
|
||||
EnabledModules []string `json:"enabled_modules"`
|
||||
Health map[string]any `json:"health"`
|
||||
DeviceCount int `json:"device_count"`
|
||||
// Ліміти опитування. Потрібні формі налаштувань: показати типові
|
||||
// значення там, де задані свої, означало б стерти їх першим же
|
||||
// збереженням.
|
||||
|
|
|
|||
|
|
@ -16,16 +16,16 @@ import (
|
|||
// форма полотна й так визначена схемою (topo.map_nodes / map_edges).
|
||||
|
||||
type MapSummary struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Kind string `json:"kind"`
|
||||
SiteID string `json:"site_id,omitempty"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
NodeCount int `json:"node_count"`
|
||||
EdgeCount int `json:"edge_count"`
|
||||
Revision int64 `json:"revision"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Kind string `json:"kind"`
|
||||
SiteID string `json:"site_id,omitempty"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
NodeCount int `json:"node_count"`
|
||||
EdgeCount int `json:"edge_count"`
|
||||
Revision int64 `json:"revision"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
// Що людина може робити саме з цією мапою: read або write.
|
||||
// Рахується з груп доступу; мапа без жодного запису — write для всіх,
|
||||
// хто взагалі має maps:write.
|
||||
|
|
@ -33,15 +33,15 @@ type MapSummary struct {
|
|||
}
|
||||
|
||||
type MapState struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Kind string `json:"kind"`
|
||||
LayoutAlgo string `json:"layout_algo"`
|
||||
Viewport json.RawMessage `json:"viewport"`
|
||||
Grid json.RawMessage `json:"grid"`
|
||||
Clustering json.RawMessage `json:"clustering"`
|
||||
Revision int64 `json:"revision"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Kind string `json:"kind"`
|
||||
LayoutAlgo string `json:"layout_algo"`
|
||||
Viewport json.RawMessage `json:"viewport"`
|
||||
Grid json.RawMessage `json:"grid"`
|
||||
Clustering json.RawMessage `json:"clustering"`
|
||||
Revision int64 `json:"revision"`
|
||||
|
||||
Backgrounds []MapBackground `json:"backgrounds"`
|
||||
Nodes []MapNode `json:"nodes"`
|
||||
|
|
@ -66,20 +66,20 @@ type MapBackground struct {
|
|||
}
|
||||
|
||||
type MapNode struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Label string `json:"label,omitempty"`
|
||||
DeviceID string `json:"device_id,omitempty"`
|
||||
ParentID string `json:"parent_id,omitempty"`
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Width *float64 `json:"width,omitempty"`
|
||||
Height *float64 `json:"height,omitempty"`
|
||||
ZIndex int `json:"z_index"`
|
||||
Style json.RawMessage `json:"style"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
Collapsed bool `json:"collapsed"`
|
||||
Locked bool `json:"locked"`
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Label string `json:"label,omitempty"`
|
||||
DeviceID string `json:"device_id,omitempty"`
|
||||
ParentID string `json:"parent_id,omitempty"`
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Width *float64 `json:"width,omitempty"`
|
||||
Height *float64 `json:"height,omitempty"`
|
||||
ZIndex int `json:"z_index"`
|
||||
Style json.RawMessage `json:"style"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
Collapsed bool `json:"collapsed"`
|
||||
Locked bool `json:"locked"`
|
||||
|
||||
// Живий стан пристрою. Саме це фарбує вузол.
|
||||
Status string `json:"status,omitempty"`
|
||||
|
|
@ -89,16 +89,16 @@ type MapNode struct {
|
|||
}
|
||||
|
||||
type MapEdge struct {
|
||||
ID string `json:"id"`
|
||||
SourceNodeID string `json:"source_node_id"`
|
||||
TargetNodeID string `json:"target_node_id"`
|
||||
SourcePort string `json:"source_port,omitempty"`
|
||||
TargetPort string `json:"target_port,omitempty"`
|
||||
ID string `json:"id"`
|
||||
SourceNodeID string `json:"source_node_id"`
|
||||
TargetNodeID string `json:"target_node_id"`
|
||||
SourcePort string `json:"source_port,omitempty"`
|
||||
TargetPort string `json:"target_port,omitempty"`
|
||||
// До якого боку вузла кріпиться лінія. Без цього після
|
||||
// перезавантаження всі зв'язки перескакують на типові порти, і
|
||||
// розкладка, яку людина розводила руками, розсипається.
|
||||
SourceHandle string `json:"source_handle,omitempty"`
|
||||
TargetHandle string `json:"target_handle,omitempty"`
|
||||
SourceHandle string `json:"source_handle,omitempty"`
|
||||
TargetHandle string `json:"target_handle,omitempty"`
|
||||
LinkID string `json:"link_id,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Style string `json:"style"`
|
||||
|
|
|
|||
|
|
@ -48,10 +48,10 @@ func (s *Store) UndoMap(ctx context.Context, tenantID, mapID, authorID string) (
|
|||
// мати проміжки (автопобудова, кілька правок), тому шукаємо
|
||||
// максимальний менший, а не current-1.
|
||||
var (
|
||||
from int64
|
||||
nodes []byte
|
||||
edges []byte
|
||||
backs []byte
|
||||
from int64
|
||||
nodes []byte
|
||||
edges []byte
|
||||
backs []byte
|
||||
)
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT revision,
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/netpulse/netpulse/server/internal/crypto"
|
||||
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
|
||||
"github.com/netpulse/netpulse/server/internal/crypto"
|
||||
"google.golang.org/protobuf/types/known/durationpb"
|
||||
)
|
||||
|
||||
|
|
@ -177,15 +177,15 @@ func (s *Store) buildConfigJob(ctx context.Context, tenantID, deviceID, jobID st
|
|||
Name: devName,
|
||||
Address: address,
|
||||
},
|
||||
Credential: cred,
|
||||
Transport: transport,
|
||||
Port: port,
|
||||
Commands: prof.Commands,
|
||||
PromptRegex: prof.PromptRegex,
|
||||
EnableRequired: prof.EnableRequired,
|
||||
ConfigType: "running",
|
||||
Timeout: durationpb.New(5 * time.Minute),
|
||||
MaxBytes: 16 << 20,
|
||||
Credential: cred,
|
||||
Transport: transport,
|
||||
Port: port,
|
||||
Commands: prof.Commands,
|
||||
PromptRegex: prof.PromptRegex,
|
||||
EnableRequired: prof.EnableRequired,
|
||||
ConfigType: "running",
|
||||
Timeout: durationpb.New(5 * time.Minute),
|
||||
MaxBytes: 16 << 20,
|
||||
// Транскрипт пишемо завжди: він потрібен рівно тоді, коли збір
|
||||
// не вдався, а вдруге відтворити ту саму сесію не вийде.
|
||||
CaptureTranscript: true,
|
||||
|
|
|
|||
|
|
@ -179,4 +179,3 @@ func (s *Store) DiffConfigs(ctx context.Context, tenantID, fromID, toID string,
|
|||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,17 +9,17 @@ import (
|
|||
|
||||
// BackupPolicy — розклад бекапу одного хоста.
|
||||
type BackupPolicy struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
ProfileID string `json:"profile_id,omitempty"`
|
||||
CredentialID string `json:"credential_id,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Cron string `json:"cron"`
|
||||
OnSyslog bool `json:"on_syslog"`
|
||||
DeviceID string `json:"device_id"`
|
||||
ProfileID string `json:"profile_id,omitempty"`
|
||||
CredentialID string `json:"credential_id,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Cron string `json:"cron"`
|
||||
OnSyslog bool `json:"on_syslog"`
|
||||
// Чи слідує хост глобальному розкладу. Прапорець, а не порівняння
|
||||
// значень: власний розклад може випадково збігтися з глобальним.
|
||||
FollowsDefault bool `json:"follows_default"`
|
||||
LastBackupAt *time.Time `json:"last_backup_at,omitempty"`
|
||||
NextBackupAt *time.Time `json:"next_backup_at,omitempty"`
|
||||
FollowsDefault bool `json:"follows_default"`
|
||||
LastBackupAt *time.Time `json:"last_backup_at,omitempty"`
|
||||
NextBackupAt *time.Time `json:"next_backup_at,omitempty"`
|
||||
}
|
||||
|
||||
// DuePolicy — політика, якій настав час.
|
||||
|
|
|
|||
79
server/internal/store/ncm_syslog.go
Normal file
79
server/internal/store/ncm_syslog.go
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
|
||||
)
|
||||
|
||||
// defaultSyslogMatch — за чим упізнаємо зміну конфігу, якщо політика
|
||||
// власного зразка не задала.
|
||||
//
|
||||
// Три родини покривають майже все, що трапляється в мережах:
|
||||
//
|
||||
// %SYS-5-CONFIG_I Cisco IOS/NX-OS — вихід із режиму конфігурації
|
||||
// CFGCHG / CONFIG_I HP, Huawei, Eltex і похідні
|
||||
// commit / configure Juniper та MikroTik
|
||||
//
|
||||
// Зразок навмисно широкий: зайвий бекап коштує кількох секунд сесії,
|
||||
// а пропущений — цілої зміни, про яку ніхто не дізнається.
|
||||
const defaultSyslogMatch = `%SYS-[0-9]-CONFIG_I|CFGCHG|CONFIG_I|` +
|
||||
`[Cc]ommit complete|[Cc]onfiguration changed|system,info configuration changed`
|
||||
|
||||
// SyslogBackupTriggers каже, яким хостам подія в журналі щойно
|
||||
// призначила позачерговий збір конфігу.
|
||||
//
|
||||
// Порівняння зразка робить Postgres, а не Go: політика зберігає його
|
||||
// рядком у базі, і тягнути всі політики в пам'ять заради кожної пачки
|
||||
// журналу означало б робити роботу там, де для неї немає даних.
|
||||
//
|
||||
// Хости без device_id пропускаються самі собою: подія з невідомої адреси
|
||||
// не належить нікому, і збирати з неї нічого.
|
||||
func (s *Store) SyslogBackupTriggers(
|
||||
ctx context.Context,
|
||||
tenantID string,
|
||||
entries []*npv1.SyslogEntry,
|
||||
) ([]string, error) {
|
||||
devices := make([]string, 0, len(entries))
|
||||
messages := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.GetDeviceId() == "" || e.GetMessage() == "" {
|
||||
continue
|
||||
}
|
||||
devices = append(devices, e.GetDeviceId())
|
||||
messages = append(messages, e.GetMessage())
|
||||
}
|
||||
if len(devices) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var out []string
|
||||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
rows, err := tx.Query(ctx, `
|
||||
WITH ev(device_id, message) AS (
|
||||
SELECT * FROM unnest($2::uuid[], $3::text[])
|
||||
)
|
||||
SELECT DISTINCT ev.device_id::text
|
||||
FROM ev
|
||||
JOIN ncm.device_policies dp
|
||||
ON dp.device_id = ev.device_id AND dp.tenant_id = $1
|
||||
WHERE dp.on_syslog
|
||||
AND dp.profile_id IS NOT NULL
|
||||
AND ev.message ~ COALESCE(NULLIF(dp.syslog_match, ''), $4)
|
||||
`, tenantID, devices, messages, defaultSyslogMatch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return err
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
return rows.Err()
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
|
@ -44,8 +44,8 @@ func templateTriggers(ctx context.Context, tx pgx.Tx, templateID string) ([]Temp
|
|||
out := []TemplateTrigger{}
|
||||
for rows.Next() {
|
||||
var (
|
||||
t TemplateTrigger
|
||||
condition, recov string
|
||||
t TemplateTrigger
|
||||
condition, recov string
|
||||
)
|
||||
if err := rows.Scan(&t.ID, &t.Key, &t.Name, &t.Description, &t.Source,
|
||||
&t.Severity, &condition, &recov, &t.ForSeconds,
|
||||
|
|
|
|||
|
|
@ -19,17 +19,17 @@ var ErrBuiltin = errors.New("вбудований шаблон не редагу
|
|||
|
||||
// Template — шаблон опитування.
|
||||
type Template struct {
|
||||
ID string `json:"id"`
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Vendor string `json:"vendor,omitempty"`
|
||||
IsBuiltin bool `json:"is_builtin"`
|
||||
ID string `json:"id"`
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Vendor string `json:"vendor,omitempty"`
|
||||
IsBuiltin bool `json:"is_builtin"`
|
||||
Items []TemplateItem `json:"items,omitempty"`
|
||||
Graphs []TemplateGraph `json:"graphs,omitempty"`
|
||||
Triggers []TemplateTrigger `json:"triggers,omitempty"`
|
||||
ItemCount int `json:"item_count"`
|
||||
DeviceCount int `json:"device_count"`
|
||||
ItemCount int `json:"item_count"`
|
||||
DeviceCount int `json:"device_count"`
|
||||
}
|
||||
|
||||
// TemplateItem — одна перевірка шаблону.
|
||||
|
|
|
|||
|
|
@ -46,16 +46,16 @@ type TenantMembership struct {
|
|||
|
||||
// TeamMember — рядок сторінки «Команда».
|
||||
type TeamMember struct {
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email,omitempty"`
|
||||
FullName string `json:"full_name,omitempty"`
|
||||
RoleID string `json:"role_id"`
|
||||
RoleKey string `json:"role_key"`
|
||||
RoleName string `json:"role_name"`
|
||||
LastLogin *time.Time `json:"last_login_at,omitempty"`
|
||||
AcceptedAt *time.Time `json:"accepted_at,omitempty"`
|
||||
ScopeGroups []string `json:"scope_group_ids,omitempty"`
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email,omitempty"`
|
||||
FullName string `json:"full_name,omitempty"`
|
||||
RoleID string `json:"role_id"`
|
||||
RoleKey string `json:"role_key"`
|
||||
RoleName string `json:"role_name"`
|
||||
LastLogin *time.Time `json:"last_login_at,omitempty"`
|
||||
AcceptedAt *time.Time `json:"accepted_at,omitempty"`
|
||||
ScopeGroups []string `json:"scope_group_ids,omitempty"`
|
||||
}
|
||||
|
||||
type Role struct {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue