335 lines
8.4 KiB
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)
|
|
}
|