package scans import ( "fmt" "log" "net" "net/netip" "os/exec" "runtime" "strings" "strconv" "sync" "time" "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 { _ = r.store.SaveLLDPResult(job.ID, lr) } } 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 { switch 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 { return string(b) } return fmt.Sprint(v) } func probeLLDP(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 { item := LLDPResult{ IP: ip, LocalPortNum: ports[k], 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 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 { log.Printf("probe failed for %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 }