Files
IT-Nexus/nexus-scanner/internal/modules/snmpmod/snmp.go

335 lines
8.4 KiB
Go

// Package snmpmod polls SNMP targets for device information.
// Supports generic devices, printers (toner/pages) and switches.
package snmpmod
import (
"context"
"fmt"
"log/slog"
"strconv"
"strings"
"time"
"github.com/gosnmp/gosnmp"
"github.com/cereda-systems/nexus-scanner/internal/config"
"github.com/cereda-systems/nexus-scanner/internal/db"
)
const modName = "snmp"
// OIDs queried for every device.
var baseOIDs = map[string]string{
"sysDescr": "1.3.6.1.2.1.1.1.0",
"sysUpTime": "1.3.6.1.2.1.1.3.0",
"sysName": "1.3.6.1.2.1.1.5.0",
"sysLocation": "1.3.6.1.2.1.1.6.0",
"sysContact": "1.3.6.1.2.1.1.4.0",
"ifNumber": "1.3.6.1.2.1.2.1.0",
}
// Switch-specific scalar OIDs (queried via Get).
var switchScalarOIDs = map[string]string{
"cpuLoad": "1.3.6.1.2.1.25.3.3.1.2.1", // hrProcessorLoad (first CPU)
}
// Printer-specific OIDs (Kyocera / most RFC 3805 printers).
var printerOIDs = map[string]string{
"tonerLevel": "1.3.6.1.2.1.43.11.1.1.9.1.1",
"tonerMax": "1.3.6.1.2.1.43.11.1.1.8.1.1",
"pageCount": "1.3.6.1.2.1.43.10.2.1.4.1.1",
"printerStatus": "1.3.6.1.2.1.25.3.5.1.1.1",
}
// Walk base OIDs for interfaces and memory.
const (
oidIfOperStatus = "1.3.6.1.2.1.2.2.1.8" // 1=up, 2=down
oidIfHCInOctets = "1.3.6.1.2.1.31.1.1.1.6" // 64-bit in bytes
oidIfHCOutOctets = "1.3.6.1.2.1.31.1.1.1.10" // 64-bit out bytes
oidIfDescr = "1.3.6.1.2.1.2.2.1.2" // interface name
oidHrStorageDescr = "1.3.6.1.2.1.25.2.3.1.2"
oidHrStorageUsed = "1.3.6.1.2.1.25.2.3.1.6"
oidHrStorageSize = "1.3.6.1.2.1.25.2.3.1.5"
oidHrStorageAlloc = "1.3.6.1.2.1.25.2.3.1.4"
)
// Module polls SNMP targets and stores results.
type Module struct {
site string
cfg config.SNMPConfig
store *db.Store
}
// New creates a new SNMP module.
func New(site string, cfg config.SNMPConfig, store *db.Store) *Module {
return &Module{site: site, cfg: cfg, store: store}
}
func (m *Module) Name() string { return modName }
func (m *Module) Interval() time.Duration { return m.cfg.Interval }
// Run polls all enabled SNMP targets.
func (m *Module) Run(ctx context.Context) error {
targets, err := m.store.ListSNMPTargets()
if err != nil {
return fmt.Errorf("list snmp targets: %w", err)
}
if len(targets) == 0 {
return nil
}
scanID, err := m.store.BeginScan(modName)
if err != nil {
return fmt.Errorf("begin scan: %w", err)
}
ok := 0
for _, t := range targets {
if !t.Enabled {
continue
}
if err := m.pollTarget(ctx, t); err != nil {
slog.Warn("snmp poll failed", "target", t.Name, "ip", t.IP, "err", err)
} else {
ok++
}
}
_ = m.store.EndScan(scanID, nil)
slog.Info("snmp poll complete", "ok", ok, "total", len(targets))
return nil
}
func (m *Module) pollTarget(ctx context.Context, t db.SNMPTarget) error {
community := t.Community
if community == "" {
community = m.cfg.Community
}
gs := &gosnmp.GoSNMP{
Target: t.IP,
Port: 161,
Community: community,
Version: snmpVersion(t.Version),
Timeout: 10 * time.Second,
Retries: 1,
MaxOids: 60,
}
if err := gs.Connect(); err != nil {
return fmt.Errorf("connect: %w", err)
}
defer gs.Conn.Close()
now := time.Now()
// --- Base + type-specific scalar GET ---
oids := make(map[string]string)
for k, v := range baseOIDs {
oids[k] = v
}
switch t.Type {
case "printer":
for k, v := range printerOIDs {
oids[k] = v
}
case "switch":
for k, v := range switchScalarOIDs {
oids[k] = v
}
}
oidList := make([]string, 0, len(oids))
reverseMap := make(map[string]string)
for name, oid := range oids {
oidList = append(oidList, oid)
reverseMap[oid] = name
}
result, err := gs.Get(oidList)
if err != nil {
return fmt.Errorf("get: %w", err)
}
for _, pdu := range result.Variables {
name := resolveOID(pdu.Name, reverseMap)
if name == "" {
continue
}
value := pduToString(pdu)
if value == "" || value == "<nil>" || strings.HasPrefix(value, "<nil>") {
continue
}
_ = m.store.UpsertSNMPResult(db.SNMPResult{
TargetID: t.ID,
OIDName: name,
Value: value,
ScannedAt: now,
})
}
// --- Switch: walk interface table + memory ---
if t.Type == "switch" {
m.walkSwitch(gs, t, now)
}
slog.Debug("snmp polled", "target", t.Name, "ip", t.IP, "type", t.Type)
return nil
}
// walkSwitch collects interface stats and memory for switch-type targets.
func (m *Module) walkSwitch(gs *gosnmp.GoSNMP, t db.SNMPTarget, now time.Time) {
// Interface status walk → portsUp / portsTotal
var portsUp, portsTotal int
var trafficIn, trafficOut uint64
ifStatus := walkOID(gs, oidIfOperStatus)
for _, v := range ifStatus {
portsTotal++
if v == "1" {
portsUp++
}
}
// Traffic counters (64-bit)
for _, v := range walkOID(gs, oidIfHCInOctets) {
n, _ := strconv.ParseUint(v, 10, 64)
trafficIn += n
}
for _, v := range walkOID(gs, oidIfHCOutOctets) {
n, _ := strconv.ParseUint(v, 10, 64)
trafficOut += n
}
save := func(name, value string) {
_ = m.store.UpsertSNMPResult(db.SNMPResult{
TargetID: t.ID,
OIDName: name,
Value: value,
ScannedAt: now,
})
}
if portsTotal > 0 {
save("portsTotal", strconv.Itoa(portsTotal))
save("portsUp", strconv.Itoa(portsUp))
}
if trafficIn > 0 {
save("trafficIn", strconv.FormatUint(trafficIn, 10))
}
if trafficOut > 0 {
save("trafficOut", strconv.FormatUint(trafficOut, 10))
}
// Memory: find "Physical Memory" or "Real Memory" row
descrByIdx := walkOID(gs, oidHrStorageDescr)
usedByIdx := walkOID(gs, oidHrStorageUsed)
sizeByIdx := walkOID(gs, oidHrStorageSize)
allocByIdx := walkOID(gs, oidHrStorageAlloc)
for idx, descr := range descrByIdx {
d := strings.ToLower(descr)
if strings.Contains(d, "physical") || strings.Contains(d, "real") || strings.Contains(d, "ram") {
used, _ := strconv.ParseInt(usedByIdx[idx], 10, 64)
size, _ := strconv.ParseInt(sizeByIdx[idx], 10, 64)
alloc, _ := strconv.ParseInt(allocByIdx[idx], 10, 64)
if alloc <= 0 {
alloc = 1024
}
usedBytes := used * alloc
totalBytes := size * alloc
if totalBytes > 0 {
save("memUsed", strconv.FormatInt(usedBytes, 10))
save("memTotal", strconv.FormatInt(totalBytes, 10))
}
break
}
}
}
// walkOID performs an SNMP walk and returns a map of last-OID-index → value string.
func walkOID(gs *gosnmp.GoSNMP, baseOID string) map[string]string {
out := make(map[string]string)
err := gs.Walk(baseOID, func(pdu gosnmp.SnmpPDU) error {
// Extract the index (last component of OID)
name := strings.TrimPrefix(pdu.Name, ".")
parts := strings.Split(name, ".")
idx := parts[len(parts)-1]
val := pduRawToString(pdu)
if val != "" {
out[idx] = val
}
return nil
})
if err != nil {
slog.Debug("snmp walk failed", "oid", baseOID, "err", err)
}
return out
}
func resolveOID(oidName string, reverseMap map[string]string) string {
key := strings.TrimPrefix(oidName, ".")
if name := reverseMap[key]; name != "" {
return name
}
// Try without trailing .0
if len(key) > 2 && key[len(key)-2:] == ".0" {
return reverseMap[key[:len(key)-2]]
}
return ""
}
func snmpVersion(v string) gosnmp.SnmpVersion {
switch v {
case "v1":
return gosnmp.Version1
case "v3":
return gosnmp.Version3
default:
return gosnmp.Version2c
}
}
// pduToString converts a PDU value to a display string (formatted).
func pduToString(pdu gosnmp.SnmpPDU) string {
switch pdu.Type {
case gosnmp.OctetString:
if b, ok := pdu.Value.([]byte); ok {
return strings.TrimSpace(string(b))
}
case gosnmp.TimeTicks:
ticks, _ := pdu.Value.(uint32)
d := time.Duration(ticks) * 10 * time.Millisecond
h := int(d.Hours())
mn := int(d.Minutes()) % 60
return strconv.Itoa(h) + "h " + strconv.Itoa(mn) + "m"
case gosnmp.ObjectIdentifier:
if s, ok := pdu.Value.(string); ok {
return s
}
default:
return fmt.Sprintf("%v", pdu.Value)
}
return fmt.Sprintf("%v", pdu.Value)
}
// pduRawToString converts a PDU value to a raw numeric/string value (for walks).
func pduRawToString(pdu gosnmp.SnmpPDU) string {
switch pdu.Type {
case gosnmp.OctetString:
if b, ok := pdu.Value.([]byte); ok {
return strings.TrimSpace(string(b))
}
case gosnmp.Integer:
return fmt.Sprintf("%d", pdu.Value)
case gosnmp.Counter32, gosnmp.Gauge32, gosnmp.Uinteger32:
return fmt.Sprintf("%d", pdu.Value)
case gosnmp.Counter64:
return fmt.Sprintf("%d", pdu.Value)
case gosnmp.TimeTicks:
if t, ok := pdu.Value.(uint32); ok {
return fmt.Sprintf("%d", t)
}
}
return fmt.Sprintf("%v", pdu.Value)
}