Files
Jason Staack 2aaccb77be feat(18-03): add OID result mappers for standard and custom metrics
- mapInterfaceMetrics: ifTable/ifXTable to InterfaceStats with Counter64 preference
- mapHealthMetrics: hrProcessorLoad/ssCpuIdle + hrStorageTable to HealthMetrics
- mapCustomMetrics: generic scalar/table results to SNMPMetricEntry
- mapDeviceStatus: sysDescr/sysUptime to DeviceStatusEvent fields
- pduToUint64, pduToString, extractIndex, formatUptime helpers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 19:25:55 -05:00

441 lines
11 KiB
Go

package snmp
import (
"fmt"
"strconv"
"strings"
"github.com/gosnmp/gosnmp"
"github.com/staack/the-other-dude/poller/internal/bus"
"github.com/staack/the-other-dude/poller/internal/device"
)
// Well-known hrStorageType OID suffixes for filtering storage rows.
const (
hrStorageRam = "1.3.6.1.2.1.25.2.1.2"
hrStorageFixedDisk = "1.3.6.1.2.1.25.2.1.4"
)
// ifTableRow holds parsed column values for a single interface index
// from either ifTable or ifXTable walks.
type ifTableRow struct {
Name string
RxBytes int64
TxBytes int64
OperStatus int
Source string // "ifTable" or "ifXTable"
}
// mapInterfaceMetrics converts walk results from ifTable and/or ifXTable into
// InterfaceStats. When both tables provide data for the same interface index,
// ifXTable (Counter64) supersedes ifTable (Counter32).
//
// tableResults maps table name -> index -> column name -> PDU.
// counterResults maps OID -> CounterResult for rate computation.
func mapInterfaceMetrics(
tableResults map[string]map[string]map[string]gosnmp.SnmpPDU,
counterResults map[string]CounterResult,
) []device.InterfaceStats {
// Merge rows: ifXTable preferred over ifTable per PreferOver semantics.
merged := make(map[string]*ifTableRow)
// First pass: ifTable data.
if ifTableData, ok := tableResults["ifTable"]; ok {
for idx, cols := range ifTableData {
row := &ifTableRow{Source: "ifTable"}
if pdu, ok := cols["ifDescr"]; ok {
row.Name = pduToString(pdu)
}
if pdu, ok := cols["ifInOctets"]; ok {
row.RxBytes = pduToInt64(pdu)
}
if pdu, ok := cols["ifOutOctets"]; ok {
row.TxBytes = pduToInt64(pdu)
}
if pdu, ok := cols["ifOperStatus"]; ok {
row.OperStatus = pduToInt(pdu)
}
merged[idx] = row
}
}
// Second pass: ifXTable supersedes ifTable for overlapping indexes.
if ifXTableData, ok := tableResults["ifXTable"]; ok {
for idx, cols := range ifXTableData {
existing, exists := merged[idx]
if !exists {
existing = &ifTableRow{}
merged[idx] = existing
}
existing.Source = "ifXTable"
if pdu, ok := cols["ifName"]; ok {
existing.Name = pduToString(pdu)
}
if pdu, ok := cols["ifHCInOctets"]; ok {
existing.RxBytes = pduToInt64(pdu)
}
if pdu, ok := cols["ifHCOutOctets"]; ok {
existing.TxBytes = pduToInt64(pdu)
}
// ifXTable doesn't have ifOperStatus; preserve from ifTable if available.
}
}
stats := make([]device.InterfaceStats, 0, len(merged))
for _, row := range merged {
if row.Name == "" {
continue
}
stats = append(stats, device.InterfaceStats{
Name: row.Name,
RxBytes: row.RxBytes,
TxBytes: row.TxBytes,
Running: row.OperStatus == 1,
Type: "ether",
})
}
return stats
}
// mapHealthMetrics converts scalar CPU values and hrStorageTable rows into
// a HealthMetrics struct. If hrProcessorLoad is unavailable but ssCpuIdle
// is present, the transform="invert_percent" logic computes load = 100 - idle.
//
// scalarValues maps scalar name -> PDU.
// tableResults maps table name -> index -> column name -> PDU.
func mapHealthMetrics(
scalarValues map[string]gosnmp.SnmpPDU,
tableResults map[string]map[string]map[string]gosnmp.SnmpPDU,
) *device.HealthMetrics {
health := &device.HealthMetrics{}
// CPU load: prefer hrProcessorLoad, fall back to ssCpuIdle (inverted).
if pdu, ok := scalarValues["hrProcessorLoad"]; ok {
health.CPULoad = strconv.Itoa(pduToInt(pdu))
} else if pdu, ok := scalarValues["ssCpuIdle"]; ok {
idle := pduToInt(pdu)
health.CPULoad = strconv.Itoa(100 - idle)
}
// Storage metrics from hrStorageTable.
if storageData, ok := tableResults["hrStorageTable"]; ok {
for _, cols := range storageData {
storageType := ""
if pdu, ok := cols["hrStorageType"]; ok {
storageType = pduToString(pdu)
}
allocUnits := int64(1)
if pdu, ok := cols["hrStorageAllocationUnits"]; ok {
allocUnits = pduToInt64(pdu)
if allocUnits <= 0 {
allocUnits = 1
}
}
size := int64(0)
if pdu, ok := cols["hrStorageSize"]; ok {
size = pduToInt64(pdu)
}
used := int64(0)
if pdu, ok := cols["hrStorageUsed"]; ok {
used = pduToInt64(pdu)
}
totalBytes := size * allocUnits
usedBytes := used * allocUnits
freeBytes := totalBytes - usedBytes
// Clamp free to zero if used exceeds total (shouldn't happen but be safe).
if freeBytes < 0 {
freeBytes = 0
}
switch {
case strings.HasSuffix(storageType, hrStorageRam) || storageType == hrStorageRam:
health.FreeMemory = strconv.FormatInt(freeBytes, 10)
health.TotalMemory = strconv.FormatInt(totalBytes, 10)
case strings.HasSuffix(storageType, hrStorageFixedDisk) || storageType == hrStorageFixedDisk:
health.FreeDisk = strconv.FormatInt(freeBytes, 10)
health.TotalDisk = strconv.FormatInt(totalBytes, 10)
}
}
}
// Temperature: empty for standard SNMP (vendor-specific, handled via custom profiles).
health.Temperature = ""
return health
}
// mapCustomMetrics converts scalar and table results from a poll group
// into SNMPMetricEntry structs for custom (non-standard) metrics.
func mapCustomMetrics(
groupName string,
scalars []ScalarOID,
scalarValues map[string]gosnmp.SnmpPDU,
tables []TableOID,
tableResults map[string]map[string]map[string]gosnmp.SnmpPDU,
) []bus.SNMPMetricEntry {
var entries []bus.SNMPMetricEntry
// Scalar metrics.
for _, s := range scalars {
pdu, ok := scalarValues[s.Name]
if !ok {
continue
}
entry := bus.SNMPMetricEntry{
MetricName: s.Name,
MetricGroup: groupName,
OID: s.OID,
}
if isNumericType(s.Type) {
v := pduToFloat64(pdu)
entry.ValueNum = &v
} else {
v := pduToString(pdu)
entry.ValueText = &v
}
entries = append(entries, entry)
}
// Table metrics.
for _, t := range tables {
rows, ok := tableResults[t.Name]
if !ok {
continue
}
for idx, cols := range rows {
for _, col := range t.Columns {
pdu, ok := cols[col.Name]
if !ok {
continue
}
idxVal := idx
entry := bus.SNMPMetricEntry{
MetricName: col.Name,
MetricGroup: groupName,
OID: col.OID,
IndexValue: &idxVal,
}
if isNumericType(col.Type) {
v := pduToFloat64(pdu)
entry.ValueNum = &v
} else {
v := pduToString(pdu)
entry.ValueText = &v
}
entries = append(entries, entry)
}
}
}
return entries
}
// mapDeviceStatus extracts sysDescr, sysName, and sysUptime from scalar values
// and returns partial DeviceStatusEvent fields for SNMP devices.
func mapDeviceStatus(scalarValues map[string]gosnmp.SnmpPDU) (boardName, uptime string) {
if pdu, ok := scalarValues["sys_descr"]; ok {
boardName = pduToString(pdu)
// Truncate to reasonable length for DB storage.
if len(boardName) > 255 {
boardName = boardName[:255]
}
}
if pdu, ok := scalarValues["sys_uptime"]; ok {
// sysUpTime.0 is in hundredths of a second (timeticks).
ticks := pduToInt64(pdu)
totalSeconds := ticks / 100
uptime = formatUptime(totalSeconds)
}
return boardName, uptime
}
// formatUptime converts seconds into a RouterOS-compatible uptime string
// (e.g., "5d12h30m").
func formatUptime(totalSeconds int64) string {
if totalSeconds <= 0 {
return "0s"
}
days := totalSeconds / 86400
remaining := totalSeconds % 86400
hours := remaining / 3600
remaining = remaining % 3600
minutes := remaining / 60
var parts []string
if days > 0 {
parts = append(parts, fmt.Sprintf("%dd", days))
}
if hours > 0 {
parts = append(parts, fmt.Sprintf("%dh", hours))
}
if minutes > 0 || len(parts) == 0 {
parts = append(parts, fmt.Sprintf("%dm", minutes))
}
return strings.Join(parts, "")
}
// pduToUint64 extracts a counter value and bit width from a PDU.
// Counter32 returns bits=32, Counter64 returns bits=64.
// Non-counter types return (0, 0).
func pduToUint64(pdu gosnmp.SnmpPDU) (uint64, int) {
switch pdu.Type {
case gosnmp.Counter32:
switch v := pdu.Value.(type) {
case uint:
return uint64(v), 32
case uint32:
return uint64(v), 32
case uint64:
return v, 32
case int:
return uint64(v), 32
}
case gosnmp.Counter64:
switch v := pdu.Value.(type) {
case uint64:
return v, 64
case uint:
return uint64(v), 64
}
}
return 0, 0
}
// pduToString extracts a string representation from a PDU.
// Handles OctetString ([]byte), Integer, Gauge32, Counter32, Counter64,
// TimeTicks, ObjectIdentifier, and IPAddress types.
func pduToString(pdu gosnmp.SnmpPDU) string {
switch pdu.Type {
case gosnmp.OctetString:
switch v := pdu.Value.(type) {
case []byte:
return string(v)
case string:
return v
}
case gosnmp.ObjectIdentifier:
if v, ok := pdu.Value.(string); ok {
return v
}
case gosnmp.IPAddress:
if v, ok := pdu.Value.(string); ok {
return v
}
case gosnmp.Integer:
return fmt.Sprintf("%d", pdu.Value)
case gosnmp.Gauge32:
return fmt.Sprintf("%d", pdu.Value)
case gosnmp.Counter32:
return fmt.Sprintf("%d", pdu.Value)
case gosnmp.Counter64:
return fmt.Sprintf("%d", pdu.Value)
case gosnmp.TimeTicks:
return fmt.Sprintf("%d", pdu.Value)
}
if pdu.Value != nil {
return fmt.Sprintf("%v", pdu.Value)
}
return ""
}
// pduToInt extracts an integer value from a PDU. Returns 0 for non-integer types.
func pduToInt(pdu gosnmp.SnmpPDU) int {
switch v := pdu.Value.(type) {
case int:
return v
case int64:
return int(v)
case uint:
return int(v)
case uint32:
return int(v)
case uint64:
return int(v)
}
return 0
}
// pduToInt64 extracts an int64 value from a PDU. Handles all gosnmp numeric types.
func pduToInt64(pdu gosnmp.SnmpPDU) int64 {
switch v := pdu.Value.(type) {
case int:
return int64(v)
case int64:
return v
case uint:
return int64(v)
case uint32:
return int64(v)
case uint64:
return int64(v)
}
return 0
}
// pduToFloat64 extracts a float64 value from a PDU. Used for custom metrics.
func pduToFloat64(pdu gosnmp.SnmpPDU) float64 {
switch v := pdu.Value.(type) {
case int:
return float64(v)
case int64:
return float64(v)
case uint:
return float64(v)
case uint32:
return float64(v)
case uint64:
return float64(v)
case float64:
return v
}
return 0
}
// extractIndex returns the row index from a full OID given the table column OID prefix.
// For example, given "1.3.6.1.2.1.2.2.1.10.5" and prefix "1.3.6.1.2.1.2.2.1.10",
// returns "5".
func extractIndex(fullOID, columnOID string) string {
// Ensure prefix match.
prefix := columnOID + "."
if strings.HasPrefix(fullOID, prefix) {
return fullOID[len(prefix):]
}
// Fallback: return the last dotted segment.
if idx := strings.LastIndex(fullOID, "."); idx >= 0 {
return fullOID[idx+1:]
}
return fullOID
}
// isNumericType returns true for SNMP types that should be stored as numeric values.
func isNumericType(typeName string) bool {
switch typeName {
case "integer", "gauge", "gauge32", "counter32", "counter64", "timeticks":
return true
}
return false
}
// isStandardMapTo returns true if the map_to value targets a standard metric type
// (interface_metrics, health_metrics.*, device.*).
func isStandardMapTo(mapTo string) bool {
if mapTo == "interface_metrics" {
return true
}
if strings.HasPrefix(mapTo, "health_metrics") {
return true
}
if strings.HasPrefix(mapTo, "device.") {
return true
}
return false
}