package scans import ( "encoding/hex" "fmt" "log" "net" "net/netip" "os" "os/exec" "runtime" "strconv" "strings" "sync" "time" "unicode/utf8" "github.com/gosnmp/gosnmp" ) type Runner struct { store Store cfg RunnerConfig } type RunnerConfig struct { SNMPEnabled bool SNMPCommunity string } func NewRunner(store Store, cfg RunnerConfig) *Runner { return &Runner{store: store, cfg: cfg} } func (r *Runner) Start(job ScanJob) { go r.run(job) } func (r *Runner) run(job ScanJob) { started := time.Now().UTC() _, _ = r.store.UpdateScan(job.ID, ScanUpdate{ Status: "running", Progress: 0, Stats: ScanStats{}, StartedAt: &started, }) 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{HostsTotal: 0, HostsUp: 0}, FinishedAt: &finished, }) return } maxWorkers := job.Options.MaxParallelHosts if maxWorkers <= 0 { maxWorkers = 64 } var mu sync.Mutex var done int var up int workCh := make(chan string) wg := sync.WaitGroup{} 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, }) } } if isUp && r.cfg.SNMPEnabled { snmpRes, lldpRes := 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) } } } mu.Lock() done++ if isUp { up++ } progress := done * 100 / total stats := ScanStats{HostsTotal: total, HostsUp: up} mu.Unlock() _, _ = r.store.UpdateScan(job.ID, ScanUpdate{ Status: "running", Progress: progress, Stats: stats, }) } } for i := 0; i < maxWorkers; i++ { wg.Add(1) go worker() } for _, ip := range ips { workCh <- ip } close(workCh) wg.Wait() finished := time.Now().UTC() _, _ = r.store.UpdateScan(job.ID, ScanUpdate{ Status: "done", Progress: 100, Stats: ScanStats{HostsTotal: total, HostsUp: up}, FinishedAt: &finished, }) } func probeSNMPAndLLDP(ip, community string, checkedAt time.Time, timeoutMS int) (SNMPResult, []LLDPResult) { 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 } 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 } 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 return out, probeLLDP(client, ip, checkedAt) } func snmpValueToString(v any) string { if b, ok := v.([]byte); ok { 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 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 }