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