diff --git a/internal/scans/runner.go b/internal/scans/runner.go index dc3c20e..8bc4964 100644 --- a/internal/scans/runner.go +++ b/internal/scans/runner.go @@ -1,6 +1,7 @@ package scans import ( + "encoding/hex" "fmt" "log" "net" @@ -12,6 +13,7 @@ import ( "strings" "sync" "time" + "unicode/utf8" "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) _ = r.store.SaveSNMPResult(job.ID, snmpRes) 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} for _, vb := range pkt.Variables { - switch vb.Name { - case ".1.3.6.1.2.1.1.5.0": + // Имена OID в ответах gosnmp всегда с ведущей «.», но сравниваем по нормализованной форме. + switch snmpOIDTrim(vb.Name) { + case "1.3.6.1.2.1.1.5.0": 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) - 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) } } @@ -200,12 +205,168 @@ func probeSNMPAndLLDP(ip, community string, checkedAt time.Time, timeoutMS int) func snmpValueToString(v any) string { if b, ok := v.([]byte); ok { - return string(b) + if len(b) == 0 { + return "" + } + 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 fmt.Sprint(v) + return strings.TrimSpace(fmt.Sprint(v)) } -func probeLLDP(client *gosnmp.GoSNMP, ip string, checkedAt time.Time) []LLDPResult { +// lldpRemColsPrefix — колонки lldpRemEntry: ...1.1.... +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 ( lldpRemLocalPortNum = ".1.0.8802.1.1.2.1.4.1.1.2" 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 } +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 { return strings.TrimPrefix(strings.TrimSpace(s), ".") }