Автовиявлення наповнювало inv.interfaces, але їх ніхто не опитував: на мапі були лінки й не було трафіку. Тепер сервер формує snmp.if-чек зі складу портів і штовхає його живій сесії як TaskDelta — без цього після кожного нового комутатора були б години порожніх графіків. Чек оновлюється, а не задвоюється: унікальний індекс core.checks включає md5(params). Склад портів порівнюється як множина, бо порядок ключів у jsonb не гарантований. Без SNMP-креденшела чек не створюється. Живий прогін знайшов помилку: OID у запиті йшов без провідної крапки, а pdu.Name повертається з нею — пошук у мапі мовчки не знаходив нічого, і чек виглядав як "жоден інтерфейс не відповів". Канонізація тепер у snmpx.Normalize, застосована з обох боків. Перевірено наскрізь: виявлення -> автостворення чека -> справжні HC-лічильники -> ts.if_counters, без жодного ручного кроку. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
354 lines
12 KiB
Go
354 lines
12 KiB
Go
// Package snmp — модуль опитування по SNMP v2c/v3.
|
||
//
|
||
// Два типи чеків:
|
||
// snmp.if — лічильники інтерфейсів (те, що живить анімацію трафіку)
|
||
// snmp.get — довільні OID → узагальнені метрики
|
||
//
|
||
// Свідоме рішення: агент НЕ ходить по ifTable, щоб з'ясувати, які
|
||
// інтерфейси існують. Перелік (ifIndex → interface_id → speed_bps)
|
||
// приходить у параметрах задачі, бо він уже є в inv.interfaces на
|
||
// сервері. Виявлення нових інтерфейсів — робота модуля topology, і
|
||
// вона окрема саме тому, що впирається в ліміт тарифу.
|
||
package snmp
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"math"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/gosnmp/gosnmp"
|
||
"github.com/netpulse/netpulse/agent/internal/module"
|
||
"github.com/netpulse/netpulse/agent/internal/snmpx"
|
||
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
|
||
"google.golang.org/protobuf/types/known/durationpb"
|
||
"google.golang.org/protobuf/types/known/timestamppb"
|
||
)
|
||
|
||
// Базові 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"
|
||
)
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Параметри задач
|
||
// ---------------------------------------------------------------------
|
||
|
||
// IfParams — params_json для snmp.if.
|
||
type IfParams struct {
|
||
UseHCCounters *bool `json:"use_hc_counters"`
|
||
Interfaces []IfaceTarget `json:"interfaces"`
|
||
}
|
||
|
||
type IfaceTarget struct {
|
||
IfIndex int `json:"if_index"`
|
||
InterfaceID string `json:"interface_id"`
|
||
// Номінальна швидкість порту — знаменник для util_pct.
|
||
SpeedBps uint64 `json:"speed_bps"`
|
||
}
|
||
|
||
// GetParams — params_json для snmp.get.
|
||
type GetParams struct {
|
||
OIDs []OIDSpec `json:"oids"`
|
||
}
|
||
|
||
type OIDSpec struct {
|
||
OID string `json:"oid"`
|
||
MetricKey string `json:"metric_key"`
|
||
Unit string `json:"unit"`
|
||
Labels map[string]string `json:"labels"`
|
||
// Множник: сенсори часто віддають десяті градуса цілим числом.
|
||
Scale float64 `json:"scale"`
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Стан лічильників
|
||
// ---------------------------------------------------------------------
|
||
|
||
// counterState — попередній замір, потрібний для розрахунку швидкості.
|
||
//
|
||
// Швидкості рахує агент, бо лише він знає фактичний інтервал між двома
|
||
// опитуваннями. Серверний розрахунок за номінальним інтервалом дає
|
||
// похибку рівно на мережеву затримку й джитер планувальника.
|
||
type counterState struct {
|
||
ts time.Time
|
||
inOctets uint64
|
||
outOctets uint64
|
||
inUcast uint64
|
||
outUcast uint64
|
||
valid bool
|
||
}
|
||
|
||
type Module struct {
|
||
mu sync.Mutex
|
||
state map[string]*counterState // deviceID|ifIndex
|
||
}
|
||
|
||
func New() *Module {
|
||
return &Module{state: make(map[string]*counterState)}
|
||
}
|
||
|
||
func (m *Module) Key() string { return "snmp" }
|
||
func (m *Module) CheckTypes() []string { return []string{"snmp.if", "snmp.get"} }
|
||
|
||
func (m *Module) Close() error {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
m.state = make(map[string]*counterState)
|
||
return nil
|
||
}
|
||
|
||
func (m *Module) Run(ctx context.Context, task module.Task) (module.Result, error) {
|
||
client, err := snmpx.Dial(ctx, task.Target.Address, task.Credentials, task.Timeout)
|
||
if err != nil {
|
||
return module.Result{}, err
|
||
}
|
||
defer client.Conn.Close()
|
||
|
||
switch task.CheckName() {
|
||
case "if":
|
||
return m.runInterfaces(ctx, client, task)
|
||
case "get":
|
||
return m.runGet(ctx, client, task)
|
||
default:
|
||
return module.Result{}, fmt.Errorf("snmp: невідомий чек %q", task.CheckType)
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// snmp.if
|
||
// ---------------------------------------------------------------------
|
||
|
||
func (m *Module) runInterfaces(ctx context.Context, client *gosnmp.GoSNMP, task module.Task) (module.Result, error) {
|
||
var p IfParams
|
||
if len(task.Params) > 0 {
|
||
if err := json.Unmarshal(task.Params, &p); err != nil {
|
||
return module.Result{}, fmt.Errorf("невалідні params для snmp.if: %w", err)
|
||
}
|
||
}
|
||
if len(p.Interfaces) == 0 {
|
||
return module.Result{}, fmt.Errorf("snmp.if без переліку інтерфейсів: сервер має передати ifIndex → interface_id")
|
||
}
|
||
|
||
useHC := true
|
||
if p.UseHCCounters != nil {
|
||
useHC = *p.UseHCCounters
|
||
}
|
||
|
||
inOID, outOID := oidIfHCInOctets, oidIfHCOutOctets
|
||
if !useHC {
|
||
inOID, outOID = oidIfInOctets, oidIfOutOctets
|
||
}
|
||
|
||
// Один Get на всі інтерфейси замість walk: менше пакетів і жодного
|
||
// зайвого трафіку по портах, які нас не цікавлять.
|
||
var oids []string
|
||
for _, ifc := range p.Interfaces {
|
||
oids = append(oids,
|
||
fmt.Sprintf("%s.%d", inOID, ifc.IfIndex),
|
||
fmt.Sprintf("%s.%d", outOID, ifc.IfIndex),
|
||
fmt.Sprintf("%s.%d", oidIfInErrors, ifc.IfIndex),
|
||
fmt.Sprintf("%s.%d", oidIfOutErrors, ifc.IfIndex),
|
||
fmt.Sprintf("%s.%d", oidIfInDiscards, ifc.IfIndex),
|
||
fmt.Sprintf("%s.%d", oidIfOutDiscards, ifc.IfIndex),
|
||
fmt.Sprintf("%s.%d", oidIfOperStatus, ifc.IfIndex),
|
||
fmt.Sprintf("%s.%d", oidIfAdminStatus, ifc.IfIndex),
|
||
)
|
||
if useHC {
|
||
oids = append(oids,
|
||
fmt.Sprintf("%s.%d", oidIfHCInUcast, ifc.IfIndex),
|
||
fmt.Sprintf("%s.%d", oidIfHCOutUcast, ifc.IfIndex),
|
||
)
|
||
}
|
||
}
|
||
|
||
values, err := getAll(ctx, client, oids)
|
||
if err != nil {
|
||
return module.Result{}, err
|
||
}
|
||
|
||
now := time.Now()
|
||
out := module.Result{}
|
||
|
||
for _, ifc := range p.Interfaces {
|
||
suffix := fmt.Sprintf(".%d", ifc.IfIndex)
|
||
|
||
inOct, okIn := values[inOID+suffix]
|
||
outOct, okOut := values[outOID+suffix]
|
||
if !okIn || !okOut {
|
||
// Порт зник (модуль вийнято зі стека) — це не помилка чека.
|
||
continue
|
||
}
|
||
|
||
counters := &npv1.InterfaceCounters{
|
||
DeviceId: task.DeviceID,
|
||
InterfaceId: ifc.InterfaceID,
|
||
Ts: timestamppb.New(now),
|
||
InOctets: inOct,
|
||
OutOctets: outOct,
|
||
InErrors: values[oidIfInErrors+suffix],
|
||
OutErrors: values[oidIfOutErrors+suffix],
|
||
InDiscards: values[oidIfInDiscards+suffix],
|
||
OutDiscards: values[oidIfOutDiscards+suffix],
|
||
OperUp: values[oidIfOperStatus+suffix] == 1,
|
||
AdminUp: values[oidIfAdminStatus+suffix] == 1,
|
||
}
|
||
if useHC {
|
||
counters.InUcastPkts = values[oidIfHCInUcast+suffix]
|
||
counters.OutUcastPkts = values[oidIfHCOutUcast+suffix]
|
||
}
|
||
|
||
key := task.DeviceID + "|" + fmt.Sprint(ifc.IfIndex)
|
||
m.mu.Lock()
|
||
prev := m.state[key]
|
||
if prev == nil {
|
||
prev = &counterState{}
|
||
m.state[key] = prev
|
||
}
|
||
prevCopy := *prev
|
||
prev.ts = now
|
||
prev.inOctets = inOct
|
||
prev.outOctets = outOct
|
||
prev.inUcast = counters.InUcastPkts
|
||
prev.outUcast = counters.OutUcastPkts
|
||
prev.valid = true
|
||
m.mu.Unlock()
|
||
|
||
if prevCopy.valid {
|
||
elapsed := now.Sub(prevCopy.ts)
|
||
counters.Interval = durationpb.New(elapsed)
|
||
|
||
if inOct < prevCopy.inOctets || outOct < prevCopy.outOctets {
|
||
// Пристрій перезавантажився або лічильник перевернувся.
|
||
// Швидкості цього разу недійсні — краще діра в графіку,
|
||
// ніж стрибок на терабіт, який підніме хибний алерт.
|
||
counters.CounterReset = true
|
||
} else if elapsed > 0 {
|
||
secs := elapsed.Seconds()
|
||
counters.InBps = float64(inOct-prevCopy.inOctets) * 8 / secs
|
||
counters.OutBps = float64(outOct-prevCopy.outOctets) * 8 / secs
|
||
if useHC {
|
||
counters.InPps = float64(counters.InUcastPkts-prevCopy.inUcast) / secs
|
||
counters.OutPps = float64(counters.OutUcastPkts-prevCopy.outUcast) / secs
|
||
}
|
||
if ifc.SpeedBps > 0 {
|
||
counters.UtilInPct = clampPct(counters.InBps / float64(ifc.SpeedBps) * 100)
|
||
counters.UtilOutPct = clampPct(counters.OutBps / float64(ifc.SpeedBps) * 100)
|
||
}
|
||
}
|
||
}
|
||
|
||
out.Interfaces = append(out.Interfaces, counters)
|
||
|
||
if counters.Interval != nil && !counters.CounterReset {
|
||
labels := map[string]string{"if_index": fmt.Sprint(ifc.IfIndex)}
|
||
out.Metrics = append(out.Metrics,
|
||
module.Metric{MetricKey: "if.in_bps", Unit: "bps", Value: counters.InBps,
|
||
InterfaceID: ifc.InterfaceID, Labels: labels, Ts: now},
|
||
module.Metric{MetricKey: "if.out_bps", Unit: "bps", Value: counters.OutBps,
|
||
InterfaceID: ifc.InterfaceID, Labels: labels, Ts: now},
|
||
)
|
||
}
|
||
}
|
||
|
||
if len(out.Interfaces) == 0 {
|
||
return module.Result{}, fmt.Errorf("жоден із %d інтерфейсів не відповів", len(p.Interfaces))
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func clampPct(v float64) float32 {
|
||
if math.IsNaN(v) || math.IsInf(v, 0) || v < 0 {
|
||
return 0
|
||
}
|
||
// Трохи понад 100% буває при розсинхроні інтервалу — не ріжемо
|
||
// одразу, але захищаємось від абсурду.
|
||
if v > 1000 {
|
||
return 1000
|
||
}
|
||
return float32(v)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// snmp.get
|
||
// ---------------------------------------------------------------------
|
||
|
||
func (m *Module) runGet(ctx context.Context, client *gosnmp.GoSNMP, task module.Task) (module.Result, error) {
|
||
var p GetParams
|
||
if len(task.Params) > 0 {
|
||
if err := json.Unmarshal(task.Params, &p); err != nil {
|
||
return module.Result{}, fmt.Errorf("невалідні params для snmp.get: %w", err)
|
||
}
|
||
}
|
||
if len(p.OIDs) == 0 {
|
||
return module.Result{}, fmt.Errorf("snmp.get без жодного OID")
|
||
}
|
||
|
||
oids := make([]string, 0, len(p.OIDs))
|
||
for _, spec := range p.OIDs {
|
||
oids = append(oids, normalizeOID(spec.OID))
|
||
}
|
||
|
||
values, err := getAll(ctx, client, oids)
|
||
if err != nil {
|
||
return module.Result{}, err
|
||
}
|
||
|
||
now := time.Now()
|
||
out := module.Result{}
|
||
for _, spec := range p.OIDs {
|
||
raw, ok := values[normalizeOID(spec.OID)]
|
||
if !ok {
|
||
continue
|
||
}
|
||
scale := spec.Scale
|
||
if scale == 0 {
|
||
scale = 1
|
||
}
|
||
key := spec.MetricKey
|
||
if key == "" {
|
||
key = "snmp" + normalizeOID(spec.OID)
|
||
}
|
||
out.Metrics = append(out.Metrics, module.Metric{
|
||
MetricKey: key,
|
||
Unit: spec.Unit,
|
||
Labels: spec.Labels,
|
||
InterfaceID: task.InterfaceID,
|
||
Value: float64(raw) * scale,
|
||
Ts: now,
|
||
})
|
||
}
|
||
|
||
if len(out.Metrics) == 0 {
|
||
return module.Result{}, fmt.Errorf("жоден з %d OID не повернув значення", len(p.OIDs))
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func normalizeOID(oid string) string { return snmpx.Normalize(oid) }
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Транспорт
|
||
//
|
||
// Дзвінки, вибір креденшелів і розбиття запитів на порції живуть у
|
||
// пакеті snmpx: ними користується ще й модуль topology, а два
|
||
// незалежні набори однієї логіки — два незалежні набори багів.
|
||
// ---------------------------------------------------------------------
|
||
|
||
func getAll(ctx context.Context, client *gosnmp.GoSNMP, oids []string) (map[string]uint64, error) {
|
||
return snmpx.GetUints(ctx, client, oids)
|
||
}
|