package scans import ( "encoding/hex" "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 } 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, 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() 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, []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 { mac := normalizeMACKey(dottedDecimalToMACTail(macTail)) 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|%s|", ifIndex, mac) if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} out = append(out, PortDeviceResult{ IP: ip, IfIndex: ifIndex, BridgePort: bridgePort, MAC: mac, LearnedIP: "", CheckedAt: checkedAt, }) continue } for learnedIP := range ipsSet { key := fmt.Sprintf("%d|%s|%s", ifIndex, mac, learnedIP) if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} out = append(out, PortDeviceResult{ IP: ip, IfIndex: ifIndex, BridgePort: bridgePort, 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].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 } // 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)) } } // normalizeMACKey приводит MAC к виду aa:bb:cc:dd:ee:ff для сопоставления FDB и ARP. func normalizeMACKey(s string) string { s = strings.ToLower(strings.TrimSpace(s)) if s == "" { return "" } s = strings.ReplaceAll(s, "-", ":") if strings.Count(s, ":") == 5 { parts := strings.Split(s, ":") if len(parts) != 6 { return "" } var b [6]byte for i, p := range parts { n, err := strconv.ParseUint(strings.TrimSpace(p), 16, 8) if err != nil { 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]) } if strings.HasPrefix(s, "0x") { h := strings.TrimPrefix(s, "0x") h = strings.ReplaceAll(h, ":", "") if len(h) != 12 { return "" } raw, err := hex.DecodeString(h) if err != nil || len(raw) != 6 { return "" } return fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x", raw[0], raw[1], raw[2], raw[3], raw[4], raw[5]) } if len(s) == 12 { raw, err := hex.DecodeString(s) if err != nil || len(raw) != 6 { return "" } return fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x", raw[0], raw[1], raw[2], raw[3], raw[4], raw[5]) } return "" } 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.