fix(snmp): LLDP rem table — BulkWalk first, гибкий индекс, fallback GetNext, лог save LLDP

Made-with: Cursor
This commit is contained in:
Луценко Андрей Анатольевич
2026-04-10 14:48:36 +10:00
parent 0659238bea
commit 66fc827f75
+180 -8
View File
@@ -1,6 +1,7 @@
package scans package scans
import ( import (
"encoding/hex"
"fmt" "fmt"
"log" "log"
"net" "net"
@@ -12,6 +13,7 @@ import (
"strings" "strings"
"sync" "sync"
"time" "time"
"unicode/utf8"
"github.com/gosnmp/gosnmp" "github.com/gosnmp/gosnmp"
) )
@@ -102,7 +104,9 @@ func (r *Runner) run(job ScanJob) {
snmpRes, lldpRes := probeSNMPAndLLDP(ip, r.cfg.SNMPCommunity, checkedAt, job.Options.PingTimeoutMS) snmpRes, lldpRes := probeSNMPAndLLDP(ip, r.cfg.SNMPCommunity, checkedAt, job.Options.PingTimeoutMS)
_ = r.store.SaveSNMPResult(job.ID, snmpRes) _ = r.store.SaveSNMPResult(job.ID, snmpRes)
for _, lr := range lldpRes { for _, lr := range lldpRes {
_ = r.store.SaveLLDPResult(job.ID, lr) if err := r.store.SaveLLDPResult(job.ID, lr); err != nil {
log.Printf("scan %s: save LLDP для %s: %v", job.ID, lr.IP, err)
}
} }
} }
@@ -176,12 +180,13 @@ func probeSNMPAndLLDP(ip, community string, checkedAt time.Time, timeoutMS int)
out := SNMPResult{IP: ip, Success: true, CheckedAt: checkedAt} out := SNMPResult{IP: ip, Success: true, CheckedAt: checkedAt}
for _, vb := range pkt.Variables { for _, vb := range pkt.Variables {
switch vb.Name { // Имена OID в ответах gosnmp всегда с ведущей «.», но сравниваем по нормализованной форме.
case ".1.3.6.1.2.1.1.5.0": switch snmpOIDTrim(vb.Name) {
case "1.3.6.1.2.1.1.5.0":
out.SysName = snmpValueToString(vb.Value) out.SysName = snmpValueToString(vb.Value)
case ".1.3.6.1.2.1.1.1.0": case "1.3.6.1.2.1.1.1.0":
out.SysDescr = snmpValueToString(vb.Value) out.SysDescr = snmpValueToString(vb.Value)
case ".1.3.6.1.2.1.1.2.0": case "1.3.6.1.2.1.1.2.0":
out.SysObjectID = snmpValueToString(vb.Value) out.SysObjectID = snmpValueToString(vb.Value)
} }
} }
@@ -200,12 +205,168 @@ func probeSNMPAndLLDP(ip, community string, checkedAt time.Time, timeoutMS int)
func snmpValueToString(v any) string { func snmpValueToString(v any) string {
if b, ok := v.([]byte); ok { if b, ok := v.([]byte); ok {
return string(b) if len(b) == 0 {
return ""
} }
return fmt.Sprint(v) if utf8.Valid(b) {
s := strings.TrimSpace(string(b))
if s != "" {
return s
}
}
// Chassis ID / port ID в LLDP часто приходят как OCTET STRING (MAC и т.д.) — не UTF-8
return "0x" + hex.EncodeToString(b)
}
return strings.TrimSpace(fmt.Sprint(v))
} }
func probeLLDP(client *gosnmp.GoSNMP, ip string, checkedAt time.Time) []LLDPResult { // lldpRemColsPrefix — колонки lldpRemEntry: ...1.1.<col>.<timeMark>.<localPort>.<remIndex>
const lldpRemColsPrefix = "1.0.8802.1.1.2.1.4.1.1"
// lldpRemTableOID — корень lldpRemTable (IEEE 802.1AB LLDP-MIB).
const lldpRemTableOID = "1.0.8802.1.1.2.1.4.1"
type lldpRemAggRow struct {
localPort, chassis, portID, sysName string
}
func lldpRemRowsFromWalk(rows map[string]*lldpRemAggRow, pduName string, val any) {
n := snmpOIDTrim(pduName)
if !strings.HasPrefix(n, lldpRemColsPrefix+".") {
return
}
rest := strings.TrimPrefix(n, lldpRemColsPrefix+".")
parts := strings.Split(rest, ".")
if len(parts) < 2 {
return
}
col := parts[0]
// Индекс строки таблицы — все субидентификаторы после номера колонки (часто 3 компонента, но оставляем произвольную длину).
rowKey := strings.Join(parts[1:], ".")
v := snmpValueToString(val)
r := rows[rowKey]
if r == nil {
r = &lldpRemAggRow{}
rows[rowKey] = r
}
switch col {
case "2":
r.localPort = v
case "5":
r.chassis = v
case "7":
r.portID = v
case "9":
r.sysName = v
}
}
// probeLLDPViaRemTableWalk один раз обходит всю lldpRemTable (стандарт IEEE 802.1AB).
// Сначала GET-BULK (меньше обменов); при пустом результате — Walk (GetNext). Отдельно есть цепочка GetNext
// в probeLLDPViaGetNextRemTable на случай сбоев внутреннего Walk у gosnmp на части прошивок.
func probeLLDPViaRemTableWalk(client *gosnmp.GoSNMP, ip string, checkedAt time.Time) []LLDPResult {
rows := make(map[string]*lldpRemAggRow)
record := func(pduName string, val any) {
lldpRemRowsFromWalk(rows, pduName, val)
}
skipPDU := func(t gosnmp.Asn1BER) bool {
return t == gosnmp.NoSuchObject || t == gosnmp.NoSuchInstance || t == gosnmp.EndOfMibView
}
walkOID := "." + lldpRemTableOID
pdus, berr := client.BulkWalkAll(walkOID)
if berr != nil {
log.Printf("lldp remTable BulkWalkAll target=%s: %v", client.Target, berr)
}
for _, p := range pdus {
if skipPDU(p.Type) {
continue
}
record(p.Name, p.Value)
}
if len(rows) == 0 {
if err := client.Walk(walkOID, func(pdu gosnmp.SnmpPDU) error {
if skipPDU(pdu.Type) {
return nil
}
record(pdu.Name, pdu.Value)
return nil
}); err != nil {
log.Printf("lldp remTable Walk target=%s: %v", client.Target, err)
}
}
out := make([]LLDPResult, 0, len(rows))
for _, r := range rows {
if r.localPort == "" && r.chassis == "" && r.portID == "" && r.sysName == "" {
continue
}
out = append(out, LLDPResult{
IP: ip,
LocalPortNum: r.localPort,
RemoteChassisID: r.chassis,
RemotePortID: r.portID,
RemoteSysName: r.sysName,
CheckedAt: checkedAt,
})
}
return out
}
// probeLLDPViaGetNextRemTable — пошаговый GetNext по поддереву lldpRemTable без пакетной логики Walk/BulkWalk.
// Обходит случаи, когда snmpwalk с хоста работает, а внутренний walk gosnmp обрывается или не вызывает callback.
func probeLLDPViaGetNextRemTable(client *gosnmp.GoSNMP, ip string, checkedAt time.Time) []LLDPResult {
rows := make(map[string]*lldpRemAggRow)
record := func(pduName string, val any) {
lldpRemRowsFromWalk(rows, pduName, val)
}
const maxSteps = 80000
oid := "." + lldpRemTableOID
for step := 0; step < maxSteps; step++ {
pkt, err := client.GetNext([]string{oid})
if err != nil {
log.Printf("lldp remTable GetNext target=%s step=%d: %v", client.Target, step, err)
break
}
if pkt == nil || len(pkt.Variables) == 0 {
break
}
vb := pkt.Variables[0]
if vb.Type == gosnmp.NoSuchObject || vb.Type == gosnmp.NoSuchInstance || vb.Type == gosnmp.EndOfMibView {
break
}
n := snmpOIDTrim(vb.Name)
if n != lldpRemTableOID && !strings.HasPrefix(n, lldpRemTableOID+".") {
break
}
if n != lldpRemTableOID {
record(vb.Name, vb.Value)
}
oid = vb.Name
}
out := make([]LLDPResult, 0, len(rows))
for _, r := range rows {
if r.localPort == "" && r.chassis == "" && r.portID == "" && r.sysName == "" {
continue
}
out = append(out, LLDPResult{
IP: ip,
LocalPortNum: r.localPort,
RemoteChassisID: r.chassis,
RemotePortID: r.portID,
RemoteSysName: r.sysName,
CheckedAt: checkedAt,
})
}
return out
}
func probeLLDPViaColumnWalks(client *gosnmp.GoSNMP, ip string, checkedAt time.Time) []LLDPResult {
const ( const (
lldpRemLocalPortNum = ".1.0.8802.1.1.2.1.4.1.1.2" lldpRemLocalPortNum = ".1.0.8802.1.1.2.1.4.1.1.2"
lldpRemChassisID = ".1.0.8802.1.1.2.1.4.1.1.5" lldpRemChassisID = ".1.0.8802.1.1.2.1.4.1.1.5"
@@ -250,6 +411,17 @@ func probeLLDP(client *gosnmp.GoSNMP, ip string, checkedAt time.Time) []LLDPResu
return out return out
} }
func probeLLDP(client *gosnmp.GoSNMP, ip string, checkedAt time.Time) []LLDPResult {
out := probeLLDPViaRemTableWalk(client, ip, checkedAt)
if len(out) == 0 {
out = probeLLDPViaColumnWalks(client, ip, checkedAt)
}
if len(out) == 0 {
out = probeLLDPViaGetNextRemTable(client, ip, checkedAt)
}
return out
}
func snmpOIDTrim(s string) string { func snmpOIDTrim(s string) string {
return strings.TrimPrefix(strings.TrimSpace(s), ".") return strings.TrimPrefix(strings.TrimSpace(s), ".")
} }