Netpulse_SasS/agent/internal/modules/snmp/snmp.go
zotac aaf067a46b Етап 2: сервер сам заводить snmp.if-чеки з виявлених інтерфейсів
Автовиявлення наповнювало 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>
2026-08-14 15:10:02 +03:00

354 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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)
}