package scans import ( "context" "encoding/hex" "errors" "fmt" "log" "net" "net/netip" "os" "os/exec" "runtime" "sort" "strconv" "strings" "sync" "time" "unicode/utf8" "github.com/gosnmp/gosnmp" ) type Runner struct { store Store cfg RunnerConfig mu sync.Mutex cancels map[string]context.CancelFunc } type RunnerConfig struct { SNMPEnabled bool SNMPCommunity string } func NewRunner(store Store, cfg RunnerConfig) *Runner { return &Runner{store: store, cfg: cfg} } // scanProgressBlend: часть прогресса за ping/порты (быстро), основная — за полное завершение хоста (SNMP и т.д.). // Пока не все хосты завершены полностью, процент не поднимается выше 99 — чтобы не казалось, что скан «завис на 100%». func scanProgressBlend(quickDone, fullDone, total int) int { if total <= 0 { return 100 } if fullDone >= total { return 100 } // Округление к ближайшему проценту, иначе 15*256+85*253 даёт 98 при ожидании «почти 99». num := 15*quickDone + 85*fullDone p := (num + total/2) / total if p >= 99 { return 99 } return p } func (r *Runner) Start(job ScanJob) { ctx, cancel := context.WithCancel(context.Background()) r.mu.Lock() if r.cancels == nil { r.cancels = make(map[string]context.CancelFunc) } r.cancels[job.ID] = cancel r.mu.Unlock() go func() { defer func() { cancel() r.mu.Lock() delete(r.cancels, job.ID) r.mu.Unlock() }() r.run(ctx, job) }() } // CancelScan отменяет активный воркер скана в этом процессе. Возвращает false, если скан не числится в памяти Runner (уже завершён или не стартовал). func (r *Runner) CancelScan(id string) bool { r.mu.Lock() c, ok := r.cancels[id] r.mu.Unlock() if !ok || c == nil { return false } c() return true } func (r *Runner) markJobCanceledFromStore(jobID string) { cur, ok, err := r.store.GetScan(jobID) if err != nil { log.Printf("runner markJobCanceledFromStore GetScan %q: %v", jobID, err) return } if !ok { return } switch cur.Status { case "done", "failed", "canceled": return } fin := time.Now().UTC() _, _ = r.store.UpdateScan(jobID, ScanUpdate{ Status: "canceled", Progress: cur.Progress, Stats: cur.Stats, FinishedAt: &fin, }) } func (r *Runner) run(ctx context.Context, job ScanJob) { if cur, ok, err := r.store.GetScan(job.ID); err != nil { log.Printf("runner run GetScan %q: %v", job.ID, err) return } else if ok && cur.Status == "canceled" { return } if err := ctx.Err(); err != nil && errors.Is(err, context.Canceled) { r.markJobCanceledFromStore(job.ID) return } started := time.Now().UTC() _, _ = r.store.UpdateScan(job.ID, ScanUpdate{ Status: "running", Progress: 0, Stats: ScanStats{}, StartedAt: &started, }) if err := ctx.Err(); err != nil && errors.Is(err, context.Canceled) { r.markJobCanceledFromStore(job.ID) return } ips, err := expandTargets(job.CIDRs, job.ExcludeIPs) if err != nil { finished := time.Now().UTC() _, _ = r.store.UpdateScan(job.ID, ScanUpdate{ Status: "failed", Progress: 100, Stats: ScanStats{}, FinishedAt: &finished, }) return } total := len(ips) if total == 0 { finished := time.Now().UTC() _, _ = r.store.UpdateScan(job.ID, ScanUpdate{ Status: "done", Progress: 100, Stats: ScanStats{}, FinishedAt: &finished, }) return } maxWorkers := job.Options.MaxParallelHosts if maxWorkers <= 0 { maxWorkers = 64 } var mu sync.Mutex var quickDone int // ping (+ TCP-порты) var fullDone int // полностью, включая SNMP var up int workCh := make(chan string) wg := sync.WaitGroup{} // Каждый UpdateScan в Postgres тянет GetScan+UPDATE; при сотнях воркеров без паузы исчерпывается пул соединений к БД. const minProgressWriteInterval = 500 * time.Millisecond var progWriteMu sync.Mutex var lastProgressWrite time.Time pushProgress := func(force bool) { mu.Lock() q, f, u := quickDone, fullDone, up mu.Unlock() prog := scanProgressBlend(q, f, total) if !force { progWriteMu.Lock() now := time.Now() if !lastProgressWrite.IsZero() && now.Sub(lastProgressWrite) < minProgressWriteInterval { progWriteMu.Unlock() return } lastProgressWrite = now progWriteMu.Unlock() } _, _ = r.store.UpdateScan(job.ID, ScanUpdate{ Status: "running", Progress: prog, Stats: ScanStats{ HostsTotal: total, HostsUp: u, HostsPingPhaseDone: q, HostsProcessed: f, }, }) } worker := func() { defer wg.Done() for ip := range workCh { isUp := probeHost(ip, job.Options.PingTimeoutMS) checkedAt := time.Now().UTC() _ = r.store.SaveHostResult(job.ID, HostResult{ IP: ip, IsUp: isUp, CheckedAt: checkedAt, }) if isUp && job.Options.PortScanEnabled { for _, p := range job.Options.Ports { _ = r.store.SaveOpenPortResult(job.ID, OpenPortResult{ IP: ip, Port: p, IsOpen: probeTCPPort(ip, p, job.Options.PingTimeoutMS), CheckedAt: checkedAt, }) } } mu.Lock() if isUp { up++ } quickDone++ mu.Unlock() pushProgress(false) if isUp && r.cfg.SNMPEnabled { snmpRes, lldpRes, ifRes, portDevRes := probeSNMPAndLLDP(ip, r.cfg.SNMPCommunity, checkedAt, job.Options.PingTimeoutMS) _ = r.store.SaveSNMPResult(job.ID, snmpRes) for _, lr := range lldpRes { if err := r.store.SaveLLDPResult(job.ID, lr); err != nil { log.Printf("scan %s: save LLDP для %s: %v", job.ID, lr.IP, err) } } for _, iface := range ifRes { if err := r.store.SaveInterfaceResult(job.ID, iface); err != nil { log.Printf("scan %s: save interface %s ifIndex=%d: %v", job.ID, iface.IP, iface.IfIndex, err) } } for _, pd := range portDevRes { if err := r.store.SavePortDeviceResult(job.ID, pd); err != nil { log.Printf("scan %s: save port-device %s ifIndex=%d mac=%s: %v", job.ID, pd.IP, pd.IfIndex, pd.MAC, err) } } } mu.Lock() fullDone++ mu.Unlock() pushProgress(false) } } for i := 0; i < maxWorkers; i++ { wg.Add(1) go worker() } go func() { defer close(workCh) for _, ip := range ips { select { case <-ctx.Done(): return case workCh <- ip: } } }() wg.Wait() finished := time.Now().UTC() mu.Lock() upFinal := up full := fullDone quick := quickDone mu.Unlock() if ctx.Err() != nil && errors.Is(ctx.Err(), context.Canceled) && full < total { st := ScanStats{ HostsTotal: total, HostsUp: upFinal, HostsPingPhaseDone: quick, HostsProcessed: full, } prog := scanProgressBlend(quick, full, total) _, _ = r.store.UpdateScan(job.ID, ScanUpdate{ Status: "canceled", Progress: prog, Stats: st, FinishedAt: &finished, }) return } _, _ = r.store.UpdateScan(job.ID, ScanUpdate{ Status: "done", Progress: 100, Stats: ScanStats{ HostsTotal: total, HostsUp: upFinal, HostsPingPhaseDone: total, HostsProcessed: total, }, FinishedAt: &finished, }) } func probeSNMPAndLLDP(ip, community string, checkedAt time.Time, timeoutMS int) (SNMPResult, []LLDPResult, []InterfaceResult, []PortDeviceResult) { if timeoutMS <= 0 { timeoutMS = 700 } if community == "" { community = "public" } client := &gosnmp.GoSNMP{ Target: ip, Port: 161, Version: gosnmp.Version2c, Community: community, Timeout: time.Duration(timeoutMS) * time.Millisecond, Retries: 1, } if err := client.Connect(); err != nil { return SNMPResult{IP: ip, Success: false, Error: err.Error(), CheckedAt: checkedAt}, nil, nil, nil } defer client.Conn.Close() oids := []string{ ".1.3.6.1.2.1.1.5.0", ".1.3.6.1.2.1.1.1.0", ".1.3.6.1.2.1.1.2.0", } pkt, err := client.Get(oids) if err != nil { return SNMPResult{IP: ip, Success: false, Error: err.Error(), CheckedAt: checkedAt}, nil, nil, nil } out := SNMPResult{IP: ip, Success: true, CheckedAt: checkedAt} for _, vb := range pkt.Variables { // Имена 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": out.SysDescr = snmpValueToString(vb.Value) case "1.3.6.1.2.1.1.2.0": out.SysObjectID = snmpValueToString(vb.Value) } } // LLDP — несколько BulkWalk подряд; часть прошивок (в т.ч. Ubiquiti EdgeSwitch) отваливается по таймауту // или некорректно обрабатывает GET-BULK с большим MaxRepetitions — увеличиваем время и снижаем пакет, // при ошибке BulkWalk в walkAsMap делаем запасной SNMP Walk (GetNext). if min := time.Duration(3000) * time.Millisecond; client.Timeout < min { client.Timeout = min } client.MaxRepetitions = 12 client.Retries = 2 lldpRes := probeLLDP(client, ip, checkedAt) var ifList []InterfaceResult var portDevices []PortDeviceResult if out.Success { ifList = probeIfTable(client, ip, checkedAt) portDevices = probePortDevices(client, ip, checkedAt) } return out, lldpRes, ifList, portDevices } // probePortDevices собирает MAC-адреса за портами (BRIDGE-MIB) и пытается сопоставить им IP через ARP (ipNetToMedia). func probePortDevices(client *gosnmp.GoSNMP, ip string, checkedAt time.Time) []PortDeviceResult { // dot1dBasePortIfIndex: bridge-port -> ifIndex basePortIfIndex := walkAsMap(client, ".1.3.6.1.2.1.17.1.4.1.2") // dot1dTpFdbPort: mac(6 octets) -> bridge-port fdbMacToBridgePort := walkAsMap(client, ".1.3.6.1.2.1.17.4.3.1.2") // dot1qTpFdbPort (Q-BRIDGE-MIB): vlan + mac(6 octets) -> bridge-port qFdbMacToBridgePort := walkAsMap(client, ".1.3.6.1.2.1.17.7.1.2.2.1.2") // ipNetToMediaPhysAddress: ifIndex.ip -> mac arpIfIPToMac := walkAsMap(client, ".1.3.6.1.2.1.4.22.1.2") if len(basePortIfIndex) == 0 || (len(fdbMacToBridgePort) == 0 && len(qFdbMacToBridgePort) == 0) { return nil } combinedFdb := make(map[string]string, len(fdbMacToBridgePort)+len(qFdbMacToBridgePort)) for k, v := range fdbMacToBridgePort { combinedFdb[k] = v } for k, v := range qFdbMacToBridgePort { combinedFdb[k] = v } macToIPs := make(map[string]map[string]struct{}) for key, mac := range arpIfIPToMac { mac = normalizeMACKey(mac) if mac == "" { continue } parts := strings.Split(key, ".") if len(parts) < 5 { continue } ipStr := strings.Join(parts[1:], ".") if net.ParseIP(ipStr) == nil { continue } if _, ok := macToIPs[mac]; !ok { macToIPs[mac] = make(map[string]struct{}) } macToIPs[mac][ipStr] = struct{}{} } seen := make(map[string]struct{}) out := make([]PortDeviceResult, 0, len(combinedFdb)) for macTail, bridgePortRaw := range combinedFdb { vlan, macSix := parseFdbKeyVlanAndMacTail(macTail) mac := normalizeMACKey(dottedDecimalToMACTail(macSix)) if mac == "" { continue } bridgePort, err := strconv.Atoi(strings.TrimSpace(snmpNumericString(bridgePortRaw))) if err != nil || bridgePort <= 0 { continue } ifIndexRaw, ok := basePortIfIndex[strconv.Itoa(bridgePort)] if !ok { continue } ifIndex, err := strconv.Atoi(strings.TrimSpace(ifIndexRaw)) if err != nil || ifIndex <= 0 { continue } ipsSet := macToIPs[mac] if len(ipsSet) == 0 { key := fmt.Sprintf("%d|%d|%s|", vlan, ifIndex, mac) if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} out = append(out, PortDeviceResult{ IP: ip, IfIndex: ifIndex, BridgePort: bridgePort, Vlan: vlan, MAC: mac, LearnedIP: "", CheckedAt: checkedAt, }) continue } for learnedIP := range ipsSet { key := fmt.Sprintf("%d|%d|%s|%s", vlan, ifIndex, mac, learnedIP) if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} out = append(out, PortDeviceResult{ IP: ip, IfIndex: ifIndex, BridgePort: bridgePort, Vlan: vlan, MAC: mac, LearnedIP: learnedIP, CheckedAt: checkedAt, }) } } sort.Slice(out, func(i, j int) bool { if out[i].IfIndex != out[j].IfIndex { return out[i].IfIndex < out[j].IfIndex } if out[i].Vlan != out[j].Vlan { return out[i].Vlan < out[j].Vlan } if out[i].MAC != out[j].MAC { return out[i].MAC < out[j].MAC } return out[i].LearnedIP < out[j].LearnedIP }) const maxRows = 20000 if len(out) > maxRows { out = out[:maxRows] } return out } // parseFdbKeyVlanAndMacTail — суффикс OID FDB: dot1q — {vlan}.{6 октетов MAC}; dot1d — только 6 октетов (vlan=0). func parseFdbKeyVlanAndMacTail(macTail string) (vlan int, macSixTail string) { p := strings.Split(strings.TrimSpace(macTail), ".") if len(p) >= 7 { vlan, _ = strconv.Atoi(p[0]) return vlan, strings.Join(p[1:7], ".") } if len(p) >= 6 { return 0, strings.Join(p[len(p)-6:], ".") } return 0, "" } // snmpNumericString — значение SNMP как строка (int/float/[]byte) для Atoi. func snmpNumericString(v any) string { switch x := v.(type) { case string: return x case []byte: return strings.TrimSpace(string(x)) default: return strings.TrimSpace(fmt.Sprint(x)) } } func normalizeMACKey(s string) string { return NormalizeMAC(s) } func dottedDecimalToMACTail(s string) string { p := strings.Split(strings.TrimSpace(s), ".") if len(p) < 6 { return "" } p = p[len(p)-6:] b := make([]byte, 6) for i := 0; i < 6; i++ { n, err := strconv.Atoi(p[i]) if err != nil || n < 0 || n > 255 { return "" } b[i] = byte(n) } return fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x", b[0], b[1], b[2], b[3], b[4], b[5]) } // probeIfTable собирает ifDescr, ifName (ifXTable), ifOperStatus по IF-MIB для списка портов в UI. func probeIfTable(client *gosnmp.GoSNMP, ip string, checkedAt time.Time) []InterfaceResult { descr := walkAsMap(client, ".1.3.6.1.2.1.2.2.1.2") names := walkAsMap(client, ".1.3.6.1.2.1.31.1.1.1.1") oper := walkAsMap(client, ".1.3.6.1.2.1.2.2.1.8") idxs := mergeIFMIBKeys(descr, names, oper) const maxIF = 2048 if len(idxs) > maxIF { idxs = idxs[:maxIF] } out := make([]InterfaceResult, 0, len(idxs)) for _, ks := range idxs { idx, err := strconv.Atoi(ks) if err != nil || idx <= 0 { continue } out = append(out, InterfaceResult{ IP: ip, IfIndex: idx, IfDescr: descr[ks], IfName: names[ks], IfOperStatus: mapIfOperStatus(oper[ks]), CheckedAt: checkedAt, }) } return out } func mergeIFMIBKeys(maps ...map[string]string) []string { seen := make(map[string]struct{}) for _, m := range maps { for k := range m { seen[k] = struct{}{} } } out := make([]string, 0, len(seen)) for k := range seen { out = append(out, k) } sort.Slice(out, func(i, j int) bool { ai, e1 := strconv.Atoi(out[i]) bj, e2 := strconv.Atoi(out[j]) if e1 != nil || e2 != nil { return out[i] < out[j] } return ai < bj }) return out } func mapIfOperStatus(s string) string { s = strings.TrimSpace(s) switch s { case "1": return "up" case "2": return "down" case "3": return "testing" case "4": return "unknown" case "5": return "dormant" case "6": return "notPresent" case "7": return "lowerLayerDown" default: return s } } // snmpBytesToDisplay строка для OCTET STRING SNMP: UTF-8 текст, иначе MAC (6 байт) или 0x+hex. func snmpBytesToDisplay(b []byte) string { if len(b) == 0 { return "" } if utf8.Valid(b) { s := strings.TrimSpace(string(b)) if s != "" { return s } } // LLDP chassis/port id часто — MAC из 6 байт (не UTF-8). if len(b) == 6 { return fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x", b[0], b[1], b[2], b[3], b[4], b[5]) } return "0x" + hex.EncodeToString(b) } func snmpValueToString(v any) string { if b, ok := v.([]byte); ok { return snmpBytesToDisplay(b) } return strings.TrimSpace(fmt.Sprint(v)) } // 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 } // Индекс lldpRemEntry: { timeMark, localPortNum, remIndex } — часть прошивок не отдаёт колонку 2, но порт есть в суффиксе OID. idx := parts[1:] if len(idx) >= 3 && strings.TrimSpace(r.localPort) == "" { r.localPort = idx[1] } } // 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" lldpRemPortID = ".1.0.8802.1.1.2.1.4.1.1.7" lldpRemSysName = ".1.0.8802.1.1.2.1.4.1.1.9" ) ports := walkAsMap(client, lldpRemLocalPortNum) chassis := walkAsMap(client, lldpRemChassisID) remotePorts := walkAsMap(client, lldpRemPortID) sysNames := walkAsMap(client, lldpRemSysName) keys := make(map[string]struct{}) for k := range ports { keys[k] = struct{}{} } for k := range chassis { keys[k] = struct{}{} } for k := range remotePorts { keys[k] = struct{}{} } for k := range sysNames { keys[k] = struct{}{} } out := make([]LLDPResult, 0, len(keys)) for k := range keys { lp := strings.TrimSpace(ports[k]) if lp == "" { lp = lldpRemIndexLocalPortFromKey(k) } item := LLDPResult{ IP: ip, LocalPortNum: lp, RemoteChassisID: chassis[k], RemotePortID: remotePorts[k], RemoteSysName: sysNames[k], CheckedAt: checkedAt, } if item.LocalPortNum == "" && item.RemoteChassisID == "" && item.RemotePortID == "" && item.RemoteSysName == "" { continue } out = append(out, item) } 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 enrichLLDPLocalPortLabels(client, out) } // lldpRemIndexLocalPortFromKey суффикс ключа walk: timeMark.localPortNum.remIndex (IEEE LLDP remTable). func lldpRemIndexLocalPortFromKey(key string) string { parts := strings.Split(key, ".") if len(parts) >= 3 { return parts[1] } return "" } // lldpLocPortDescOIDs — lldpLocPortDesc по номеру порта (RFC 4502 и встречающийся в поле вариант). var lldpLocPortDescOIDs = []string{ ".1.0.8802.1.1.2.1.2.1.7.1.3", ".1.0.8802.1.1.2.1.3.7.1.3", } func probeLLDPLocPortDescByPortNum(client *gosnmp.GoSNMP) map[string]string { out := make(map[string]string) for _, base := range lldpLocPortDescOIDs { for k, v := range walkAsMap(client, base) { v = strings.TrimSpace(v) if v != "" { out[k] = v } } } return out } func formatLocalPortLabel(portNum, ifDescr string) string { portNum = strings.TrimSpace(portNum) ifDescr = strings.TrimSpace(ifDescr) if ifDescr != "" && portNum != "" && ifDescr != portNum { return portNum + " — " + ifDescr } if ifDescr != "" { return ifDescr } return portNum } // enrichLLDPLocalPortLabels подставляет lldpLocPortDesc (как в CLI) к номеру локального порта. func enrichLLDPLocalPortLabels(client *gosnmp.GoSNMP, items []LLDPResult) []LLDPResult { if len(items) == 0 { return items } descByPort := probeLLDPLocPortDescByPortNum(client) if len(descByPort) == 0 { return items } for i := range items { num := strings.TrimSpace(items[i].LocalPortNum) items[i].LocalPortNum = formatLocalPortLabel(num, descByPort[num]) } return items } func snmpOIDTrim(s string) string { return strings.TrimPrefix(strings.TrimSpace(s), ".") } // walkAsMap выполняет поддерево SNMP по base oid. Сначала GET-BULK (быстро); при ошибке — Walk (GetNext), // что совместимо с большим числом встраиваемых коммутаторов. func walkAsMap(client *gosnmp.GoSNMP, oid string) map[string]string { base := snmpOIDTrim(oid) prefix := base + "." out := make(map[string]string) record := func(pduName string, val any) { n := snmpOIDTrim(pduName) if !strings.HasPrefix(n, prefix) { return } key := strings.TrimPrefix(n, prefix) if key == "" { return } out[key] = snmpValueToString(val) } pdus, err := client.BulkWalkAll(oid) if err != nil { log.Printf("lldp snmp BulkWalkAll target=%s oid=%s: %v (fallback Walk)", client.Target, base, err) if werr := client.Walk(oid, func(pdu gosnmp.SnmpPDU) error { record(pdu.Name, pdu.Value) return nil }); werr != nil { log.Printf("lldp snmp Walk target=%s oid=%s: %v", client.Target, base, werr) } return out } for _, p := range pdus { record(p.Name, p.Value) } return out } func expandTargets(cidrs []string, excludeIPs []string) ([]string, error) { excluded := make(map[string]struct{}, len(excludeIPs)) for _, ip := range excludeIPs { excluded[ip] = struct{}{} } seen := make(map[string]struct{}) out := make([]string, 0, 256) for _, c := range cidrs { ip, ipnet, err := net.ParseCIDR(c) if err != nil { return nil, err } start := ip.Mask(ipnet.Mask).To4() if start == nil { continue } for current := dupIP(start); ipnet.Contains(current); incIP(current) { host := current.String() if _, ok := excluded[host]; ok { continue } if _, ok := seen[host]; ok { continue } seen[host] = struct{}{} out = append(out, host) } } return out, nil } func probeHost(ip string, timeoutMS int) bool { if timeoutMS <= 0 { timeoutMS = 700 } timeoutSec := strconv.Itoa(max(1, timeoutMS/1000)) var cmd *exec.Cmd if runtime.GOOS == "windows" { // -n 1 one packet, -w timeout in ms cmd = exec.Command("ping", "-n", "1", "-w", strconv.Itoa(timeoutMS), ip) } else { // -c 1 one packet, -W timeout in sec on Linux/macOS cmd = exec.Command("ping", "-c", "1", "-W", timeoutSec, ip) } if err := cmd.Run(); err != nil { // На Linux/macOS ping с -c 1 даёт exit 1 при отсутствии ответа — это ожидаемо для большинства // адресов в CIDR, не ошибка приложения. Логировать каждый такой случай бессмысленно засоряет journal. if os.Getenv("NETTOPO_LOG_PING_FAILURES") == "true" { log.Printf("ping: нет ответа от %s: %v", ip, err) } return false } return true } func probeTCPPort(ip string, port int, timeoutMS int) bool { if timeoutMS <= 0 { timeoutMS = 700 } addr, err := netip.ParseAddr(ip) if err != nil { return false } target := net.JoinHostPort(addr.String(), strconv.Itoa(port)) conn, err := net.DialTimeout("tcp", target, time.Duration(timeoutMS)*time.Millisecond) if err != nil { return false } _ = conn.Close() return true } func dupIP(ip net.IP) net.IP { out := make(net.IP, len(ip)) copy(out, ip) return out } func incIP(ip net.IP) { for j := len(ip) - 1; j >= 0; j-- { ip[j]++ if ip[j] > 0 { break } } } func max(a, b int) int { if a > b { return a } return b }