// 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 == "" || strings.HasPrefix(value, "") { 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) }