package tendrils import ( "fmt" "log" "net" "regexp" "strings" "sync" "time" "github.com/gosnmp/gosnmp" ) var portNameRewrites = []struct { re *regexp.Regexp repl string }{ {regexp.MustCompile(`.*Slot: (\d+) Port: (\d+).*`), "$1/$2"}, } func rewritePortName(name string) string { for _, rw := range portNameRewrites { if rw.re.MatchString(name) { return rw.re.ReplaceAllString(name, rw.repl) } } return name } type snmpConfig struct { username string authKey string privKey string authProto gosnmp.SnmpV3AuthProtocol privProto gosnmp.SnmpV3PrivProtocol secLevel gosnmp.SnmpV3MsgFlags timeout time.Duration retries int } func defaultSNMPConfig() *snmpConfig { return &snmpConfig{ username: "tendrils", authKey: "tendrils", privKey: "tendrils", authProto: gosnmp.SHA512, privProto: gosnmp.AES, secLevel: gosnmp.AuthPriv, timeout: 5 * time.Second, retries: 1, } } func snmpToInt(val any) (int, bool) { switch v := val.(type) { case int: return v, true case int32: return int(v), true case int64: return int(v), true case uint: return int(v), true case uint32: return int(v), true case uint64: return int(v), true default: return 0, false } } func (t *Tendrils) connectSNMP(ip net.IP) (*gosnmp.GoSNMP, error) { cfg := defaultSNMPConfig() snmp := &gosnmp.GoSNMP{ Target: ip.String(), Port: 161, Version: gosnmp.Version3, Timeout: cfg.timeout, Retries: cfg.retries, SecurityModel: gosnmp.UserSecurityModel, MsgFlags: cfg.secLevel, SecurityParameters: &gosnmp.UsmSecurityParameters{ UserName: cfg.username, AuthenticationProtocol: cfg.authProto, AuthenticationPassphrase: cfg.authKey, PrivacyProtocol: cfg.privProto, PrivacyPassphrase: cfg.privKey, }, } err := snmp.Connect() if err != nil { return nil, err } return snmp, nil } func (t *Tendrils) pollSNMP(node *Node, ip net.IP) { var wg sync.WaitGroup wg.Add(2) go func() { defer wg.Done() t.queryWirelessAP(node, ip) }() go func() { defer wg.Done() t.querySNMPDevice(node, ip) }() wg.Wait() } func (t *Tendrils) querySNMPDevice(node *Node, ip net.IP) { snmp, err := t.connectSNMP(ip) if err != nil { if t.DebugSNMP { log.Printf("[snmp] %s: connect failed: %v", ip, err) } return } defer snmp.Conn.Close() ifNames := t.getInterfaceNames(snmp) t.querySysName(snmp, node) t.queryInterfaceMACs(snmp, node, ifNames) sysUpTime := t.getSysUpTime(snmp) t.queryInterfaceStats(snmp, node, ifNames, sysUpTime) t.queryPoEBudget(snmp, node) t.queryBridgeMIB(snmp, node, ifNames) t.queryDHCPBindings(snmp) } func (t *Tendrils) getSysUpTime(snmp *gosnmp.GoSNMP) uint64 { oid := "1.3.6.1.2.1.1.3.0" result, err := snmp.Get([]string{oid}) if err != nil { return 0 } if len(result.Variables) == 0 { return 0 } v, ok := snmpToInt(result.Variables[0].Value) if !ok { log.Printf("[ERROR] failed to parse sysUpTime: type=%T value=%v", result.Variables[0].Value, result.Variables[0].Value) return 0 } return uint64(v) } func (t *Tendrils) querySysName(snmp *gosnmp.GoSNMP, node *Node) { oid := "1.3.6.1.2.1.1.5.0" result, err := snmp.Get([]string{oid}) if err != nil { return } if len(result.Variables) == 0 { return } variable := result.Variables[0] if variable.Type != gosnmp.OctetString { return } sysName := string(variable.Value.([]byte)) if sysName == "" { return } t.nodes.Update(node, nil, nil, "", sysName, "snmp-sysname") } func (t *Tendrils) queryInterfaceMACs(snmp *gosnmp.GoSNMP, node *Node, ifNames map[int]string) { oid := "1.3.6.1.2.1.2.2.1.6" results, err := snmp.BulkWalkAll(oid) if err != nil { return } for _, result := range results { if result.Type != gosnmp.OctetString { continue } macBytes := result.Value.([]byte) if len(macBytes) != 6 { continue } mac := net.HardwareAddr(macBytes) if isBroadcastOrZero(mac) { continue } oidSuffix := strings.TrimPrefix(strings.TrimPrefix(result.Name, "."+oid), ".") var ifIndex int if _, err := fmt.Sscanf(oidSuffix, "%d", &ifIndex); err != nil { continue } name := rewritePortName(ifNames[ifIndex]) if t.DebugSNMP { log.Printf("[snmp] %s: interface %d mac=%s name=%s", snmp.Target, ifIndex, mac, name) } t.nodes.Update(node, mac, nil, name, "", "snmp-ifmac") } } func (t *Tendrils) queryInterfaceStats(snmp *gosnmp.GoSNMP, node *Node, ifNames map[int]string, sysUpTime uint64) { ifOperStatus := t.getInterfaceTable(snmp, "1.3.6.1.2.1.2.2.1.8") ifLastChange := t.getInterfaceTable(snmp, "1.3.6.1.2.1.2.2.1.9") ifHighSpeed := t.getInterfaceTable(snmp, "1.3.6.1.2.1.31.1.1.1.15") ifInErrors := t.getInterfaceTable(snmp, "1.3.6.1.2.1.2.2.1.14") ifOutErrors := t.getInterfaceTable(snmp, "1.3.6.1.2.1.2.2.1.20") ifHCInOctets := t.getInterfaceTable64(snmp, "1.3.6.1.2.1.31.1.1.1.6") ifHCOutOctets := t.getInterfaceTable64(snmp, "1.3.6.1.2.1.31.1.1.1.10") ifHCInUcastPkts := t.getInterfaceTable64(snmp, "1.3.6.1.2.1.31.1.1.1.7") ifHCInMcastPkts := t.getInterfaceTable64(snmp, "1.3.6.1.2.1.31.1.1.1.8") ifHCInBcastPkts := t.getInterfaceTable64(snmp, "1.3.6.1.2.1.31.1.1.1.9") ifHCOutUcastPkts := t.getInterfaceTable64(snmp, "1.3.6.1.2.1.31.1.1.1.11") ifHCOutMcastPkts := t.getInterfaceTable64(snmp, "1.3.6.1.2.1.31.1.1.1.12") ifHCOutBcastPkts := t.getInterfaceTable64(snmp, "1.3.6.1.2.1.31.1.1.1.13") poeStats := t.getPoEStats(snmp, ifNames) now := time.Now() t.nodes.mu.Lock() defer t.nodes.mu.Unlock() for ifIndex, name := range ifNames { name = rewritePortName(name) iface, exists := node.Interfaces[name] if !exists { continue } status, hasStatus := ifOperStatus[ifIndex] isUp := hasStatus && status == 1 if !isUp { if iface.Up { log.Printf("[ERROR] port down on %s %s", node.DisplayName(), name) t.errors.AddPortDown(node, name) } iface.Up = false iface.Stats = nil continue } iface.Up = true stats := &InterfaceStats{} if speed, ok := ifHighSpeed[ifIndex]; ok { stats.Speed = uint64(speed) * 1000000 } if lastChange, ok := ifLastChange[ifIndex]; ok && sysUpTime > 0 { if uint64(lastChange) <= sysUpTime { stats.Uptime = (sysUpTime - uint64(lastChange)) / 100 } } if inErr, ok := ifInErrors[ifIndex]; ok { stats.InErrors = uint64(inErr) } if outErr, ok := ifOutErrors[ifIndex]; ok { stats.OutErrors = uint64(outErr) } inBytes, hasInBytes := ifHCInOctets[ifIndex] outBytes, hasOutBytes := ifHCOutOctets[ifIndex] inPkts := ifHCInUcastPkts[ifIndex] + ifHCInMcastPkts[ifIndex] + ifHCInBcastPkts[ifIndex] outPkts := ifHCOutUcastPkts[ifIndex] + ifHCOutMcastPkts[ifIndex] + ifHCOutBcastPkts[ifIndex] hasPrev := !iface.prevTimestamp.IsZero() if hasPrev { if iface.prevUptime > 0 && stats.Uptime > 0 && stats.Uptime < iface.prevUptime { log.Printf("[ERROR] port flap on %s %s: uptime dropped from %d to %d seconds", node.DisplayName(), name, iface.prevUptime, stats.Uptime) t.errors.AddPortFlap(node, name) } if hasInBytes && hasOutBytes { elapsed := now.Sub(iface.prevTimestamp).Seconds() if elapsed > 0 { stats.InPktsRate = float64(inPkts-iface.prevInPkts) / elapsed stats.OutPktsRate = float64(outPkts-iface.prevOutPkts) / elapsed stats.InBytesRate = float64(inBytes-iface.prevInBytes) / elapsed stats.OutBytesRate = float64(outBytes-iface.prevOutBytes) / elapsed maxBytesRate := float64(stats.Speed) / 8 * 2 if stats.InBytesRate < 0 || stats.InBytesRate > maxBytesRate { stats.InPktsRate = 0 stats.InBytesRate = 0 } if stats.OutBytesRate < 0 || stats.OutBytesRate > maxBytesRate { stats.OutPktsRate = 0 stats.OutBytesRate = 0 } } } } iface.prevInPkts = inPkts iface.prevOutPkts = outPkts iface.prevInBytes = inBytes iface.prevOutBytes = outBytes if stats.Uptime > 0 { iface.prevUptime = stats.Uptime } iface.prevTimestamp = now if poe, ok := poeStats[name]; ok { stats.PoE = poe } node.SetInterfaceStats(name, stats) } } func (t *Tendrils) queryPoEBudget(snmp *gosnmp.GoSNMP, node *Node) { maxPowerOID := "1.3.6.1.2.1.105.1.3.1.1.2.1" powerOID := "1.3.6.1.2.1.105.1.3.1.1.4.1" result, err := snmp.Get([]string{maxPowerOID, powerOID}) if err != nil { return } var power, maxPower float64 for _, v := range result.Variables { val, ok := snmpToInt(v.Value) if !ok { continue } if v.Name == "."+powerOID { power = float64(val) } else if v.Name == "."+maxPowerOID { maxPower = float64(val) } } if maxPower > 0 { t.nodes.mu.Lock() node.PoEBudget = &PoEBudget{Power: power, MaxPower: maxPower} node.Type = NodeTypeSwitch t.nodes.mu.Unlock() } } func (t *Tendrils) getInterfaceTable(snmp *gosnmp.GoSNMP, oid string) map[int]int { results, err := snmp.BulkWalkAll(oid) if err != nil { return nil } table := map[int]int{} for _, result := range results { oidSuffix := strings.TrimPrefix(strings.TrimPrefix(result.Name, "."+oid), ".") var ifIndex int if _, err := fmt.Sscanf(oidSuffix, "%d", &ifIndex); err != nil { continue } value, ok := snmpToInt(result.Value) if !ok { continue } table[ifIndex] = value } return table } func (t *Tendrils) getInterfaceTable64(snmp *gosnmp.GoSNMP, oid string) map[int]uint64 { results, err := snmp.BulkWalkAll(oid) if err != nil { return nil } table := map[int]uint64{} for _, result := range results { oidSuffix := strings.TrimPrefix(strings.TrimPrefix(result.Name, "."+oid), ".") var ifIndex int if _, err := fmt.Sscanf(oidSuffix, "%d", &ifIndex); err != nil { continue } switch v := result.Value.(type) { case uint64: table[ifIndex] = v case uint: table[ifIndex] = uint64(v) case int: table[ifIndex] = uint64(v) case int64: table[ifIndex] = uint64(v) } } return table } func (t *Tendrils) getPoEStats(snmp *gosnmp.GoSNMP, ifNames map[int]string) map[string]*PoEStats { statusOID := "1.3.6.1.2.1.105.1.1.1.6" statusResults, err := snmp.BulkWalkAll(statusOID) if err != nil { return nil } powerTable := t.getPoETable(snmp, "1.3.6.1.4.1.4526.10.15.1.1.1.2") maxPowerTable := t.getPoETable(snmp, "1.3.6.1.4.1.4526.10.15.1.1.1.1") stats := map[string]*PoEStats{} for _, result := range statusResults { oidSuffix := strings.TrimPrefix(strings.TrimPrefix(result.Name, "."+statusOID), ".") parts := strings.Split(oidSuffix, ".") if len(parts) < 2 { continue } var portIndex int if _, err := fmt.Sscanf(parts[1], "%d", &portIndex); err != nil { continue } status, ok := snmpToInt(result.Value) if !ok { continue } ifName := rewritePortName(ifNames[portIndex]) if ifName == "" { continue } if status != 3 { continue } poe := &PoEStats{} if power, ok := powerTable[portIndex]; ok { poe.Power = float64(power) / 1000.0 } if maxPower, ok := maxPowerTable[portIndex]; ok { poe.MaxPower = float64(maxPower) / 1000.0 } if t.DebugSNMP { log.Printf("[snmp] %s: poe port %d (%s) power=%v maxpower=%v", snmp.Target, portIndex, ifName, powerTable[portIndex], maxPowerTable[portIndex]) } if poe.Power > 0 || poe.MaxPower > 0 { stats[ifName] = poe } } return stats } func (t *Tendrils) getPoETable(snmp *gosnmp.GoSNMP, oid string) map[int]int { results, err := snmp.BulkWalkAll(oid) if err != nil { return nil } table := map[int]int{} for _, result := range results { oidSuffix := strings.TrimPrefix(strings.TrimPrefix(result.Name, "."+oid), ".") parts := strings.Split(oidSuffix, ".") if len(parts) < 2 { continue } var portIndex int if _, err := fmt.Sscanf(parts[1], "%d", &portIndex); err != nil { continue } value, ok := snmpToInt(result.Value) if !ok { continue } table[portIndex] = value } return table } func (t *Tendrils) queryBridgeMIB(snmp *gosnmp.GoSNMP, node *Node, ifNames map[int]string) { portOID := "1.3.6.1.2.1.17.7.1.2.2.1.2" portResults, err := snmp.BulkWalkAll(portOID) if err != nil { return } type macPortEntry struct { mac net.HardwareAddr bridgePort int } var macPorts []macPortEntry for _, result := range portResults { if result.Type == gosnmp.Integer { oidSuffix := strings.TrimPrefix(result.Name[len(portOID):], ".") parts := strings.Split(oidSuffix, ".") if len(parts) >= 8 { var macBytes []byte for j := 2; j <= 7; j++ { var b int fmt.Sscanf(parts[j], "%d", &b) macBytes = append(macBytes, byte(b)) } if len(macBytes) == 6 { mac := net.HardwareAddr(macBytes) bridgePort := result.Value.(int) macPorts = append(macPorts, macPortEntry{mac: mac, bridgePort: bridgePort}) } } } } bridgePortToIfIndex := t.getBridgePortMapping(snmp) t.nodes.ClearMACTable(node) for _, entry := range macPorts { mac := entry.mac if isBroadcastOrZero(mac) { continue } ifIndex, exists := bridgePortToIfIndex[entry.bridgePort] if !exists { ifIndex = entry.bridgePort } ifName := rewritePortName(ifNames[ifIndex]) if t.DebugSNMP { log.Printf("[snmp] %s: mac=%s port=%s", snmp.Target, mac, ifName) } t.nodes.Update(nil, mac, nil, "", "", "snmp") t.nodes.UpdateMACTable(node, mac, ifName) } } func (t *Tendrils) getBridgePortMapping(snmp *gosnmp.GoSNMP) map[int]int { oid := "1.3.6.1.2.1.17.1.4.1.2" results, err := snmp.BulkWalkAll(oid) if err != nil { return nil } mapping := make(map[int]int) for _, result := range results { if result.Type == gosnmp.Integer { oidParts := strings.TrimPrefix(strings.TrimPrefix(result.Name, "."+oid), ".") var bridgePort int _, err := fmt.Sscanf(oidParts, "%d", &bridgePort) if err != nil { continue } ifIndex := result.Value.(int) mapping[bridgePort] = ifIndex } } return mapping } func (t *Tendrils) getInterfaceNames(snmp *gosnmp.GoSNMP) map[int]string { oid := "1.3.6.1.2.1.2.2.1.2" results, err := snmp.BulkWalkAll(oid) if err != nil { return nil } names := make(map[int]string) for _, result := range results { if result.Type == gosnmp.OctetString { oidParts := strings.TrimPrefix(strings.TrimPrefix(result.Name, "."+oid), ".") var ifIndex int _, err := fmt.Sscanf(oidParts, "%d", &ifIndex) if err != nil { continue } name := string(result.Value.([]byte)) names[ifIndex] = name } } return names } func (t *Tendrils) queryDHCPBindings(snmp *gosnmp.GoSNMP) { baseOID := "1.3.6.1.4.1.4526.10.12.3.2.1" ipOID := baseOID + ".1" macOID := baseOID + ".3" ipResults, err := snmp.BulkWalkAll(ipOID) if err != nil { return } macResults, err := snmp.BulkWalkAll(macOID) if err != nil { return } ips := map[int]net.IP{} for _, result := range ipResults { if result.Type != gosnmp.IPAddress { continue } oidSuffix := strings.TrimPrefix(strings.TrimPrefix(result.Name, "."+ipOID), ".") var idx int if _, err := fmt.Sscanf(oidSuffix, "%d", &idx); err != nil { continue } ips[idx] = net.ParseIP(result.Value.(string)) } macs := map[int]net.HardwareAddr{} for _, result := range macResults { if result.Type != gosnmp.OctetString { continue } macBytes := result.Value.([]byte) if len(macBytes) != 6 { continue } oidSuffix := strings.TrimPrefix(strings.TrimPrefix(result.Name, "."+macOID), ".") var idx int if _, err := fmt.Sscanf(oidSuffix, "%d", &idx); err != nil { continue } macs[idx] = net.HardwareAddr(macBytes) } for idx, ip := range ips { mac, ok := macs[idx] if !ok { continue } if isBroadcastOrZero(mac) { continue } if t.DebugSNMP { log.Printf("[snmp] %s: dhcp binding mac=%s ip=%s", snmp.Target, mac, ip) } t.nodes.Update(nil, mac, []net.IP{ip}, "", "", "dhcp") } } func (t *Tendrils) connectSNMPv2c(ip net.IP, community string) (*gosnmp.GoSNMP, error) { snmp := &gosnmp.GoSNMP{ Target: ip.String(), Port: 161, Version: gosnmp.Version2c, Community: community, Timeout: 5 * time.Second, Retries: 1, } err := snmp.Connect() if err != nil { return nil, err } return snmp, nil } func (t *Tendrils) queryWirelessAP(node *Node, ip net.IP) bool { snmp, err := t.connectSNMPv2c(ip, "tendrils12!") if err != nil { return false } defer snmp.Conn.Close() sysObjID := t.getSysObjectID(snmp) if !strings.HasPrefix(sysObjID, "1.3.6.1.4.1.11863.") { return false } t.nodes.mu.Lock() node.Type = NodeTypeAP t.nodes.mu.Unlock() ifNames := t.getInterfaceNames(snmp) t.querySysName(snmp, node) t.queryInterfaceMACs(snmp, node, ifNames) t.queryTPLinkWirelessClients(snmp, node) return true } func (t *Tendrils) getSysObjectID(snmp *gosnmp.GoSNMP) string { oid := "1.3.6.1.2.1.1.2.0" result, err := snmp.Get([]string{oid}) if err != nil { return "" } if len(result.Variables) == 0 { return "" } variable := result.Variables[0] if variable.Type != gosnmp.ObjectIdentifier { return "" } return strings.TrimPrefix(variable.Value.(string), ".") } func (t *Tendrils) queryTPLinkWirelessClients(snmp *gosnmp.GoSNMP, node *Node) { clientMACOID := "1.3.6.1.4.1.11863.10.1.1.2.1.2" results, err := snmp.BulkWalkAll(clientMACOID) if err != nil { if t.DebugSNMP { log.Printf("[snmp] %s: tp-link client walk failed: %v", snmp.Target, err) } return } t.nodes.ClearMACTable(node) for _, result := range results { if result.Type != gosnmp.OctetString { continue } macText := string(result.Value.([]byte)) macText = strings.TrimRight(macText, "\x00") macText = strings.ReplaceAll(macText, "-", ":") mac, err := net.ParseMAC(macText) if err != nil { if t.DebugSNMP { log.Printf("[snmp] %s: invalid wireless client mac %q: %v", snmp.Target, macText, err) } continue } t.nodes.UpdateMACTable(node, mac, "wifi") if t.DebugSNMP { log.Printf("[snmp] %s: wireless client mac=%s", snmp.Target, mac.String()) } } t.NotifyUpdate() }