Files
nettopo-go/internal/scans/runner.go
T
Луценко Андрей Анатольевич 0659238bea Ping: stop logging every no-reply by default; opt-in NETTOPO_LOG_PING_FAILURES.
Made-with: Cursor
2026-04-10 14:14:25 +10:00

388 lines
9.2 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 (
"fmt"
"log"
"net"
"net/netip"
"os"
"os/exec"
"runtime"
"strconv"
"strings"
"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 {
// На 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
}