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), ".")
}