Files
nettopo-go/internal/scans/runner.go
T
2026-04-10 14:56:43 +10:00

625 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.<col>.<timeMark>.<localPort>.<remIndex>
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
}