Files
nettopo-go/internal/scans/store.go
T

528 lines
13 KiB
Go

package scans
import (
"crypto/rand"
"encoding/hex"
"errors"
"net"
"sort"
"sync"
"time"
)
type CreateScanRequest struct {
Name string `json:"name"`
CIDRs []string `json:"cidrs"`
ExcludeIPs []string `json:"exclude_ips"`
SNMPCredentialsID string `json:"snmp_credentials_id"`
Options ScanOptions `json:"options"`
}
type ScanOptions struct {
PingTimeoutMS int `json:"ping_timeout_ms"`
PingRetries int `json:"ping_retries"`
MaxParallelHosts int `json:"max_parallel_hosts"`
PortScanEnabled bool `json:"port_scan_enabled"`
Ports []int `json:"ports"`
}
type ScanJob struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
CIDRs []string `json:"cidrs"`
ExcludeIPs []string `json:"exclude_ips"`
SNMPCredentialsID string `json:"snmp_credentials_id"`
Options ScanOptions `json:"options"`
Progress int `json:"progress"`
Stats ScanStats `json:"stats"`
CreatedAt time.Time `json:"created_at"`
StartedAt time.Time `json:"started_at,omitempty"`
FinishedAt time.Time `json:"finished_at,omitempty"`
}
type ScanStats struct {
HostsTotal int `json:"hosts_total"`
HostsUp int `json:"hosts_up"`
}
type ScanUpdate struct {
Status string
Progress int
Stats ScanStats
StartedAt *time.Time
FinishedAt *time.Time
}
type Store interface {
CreateScan(CreateScanRequest) (ScanJob, error)
GetScan(id string) (ScanJob, bool)
ListScans(limit int) []ScanJob
UpdateScan(id string, update ScanUpdate) (ScanJob, bool)
SaveHostResult(scanID string, host HostResult) error
ListHostResults(scanID string, limit int) []HostResult
SaveOpenPortResult(scanID string, result OpenPortResult) error
ListOpenPortResults(scanID string, limit int) []OpenPortResult
SaveSNMPResult(scanID string, result SNMPResult) error
ListSNMPResults(scanID string, limit int) []SNMPResult
SaveLLDPResult(scanID string, result LLDPResult) error
ListLLDPResults(scanID string, limit int) []LLDPResult
SaveInterfaceResult(scanID string, result InterfaceResult) error
ListInterfaceResults(scanID string, filterIP string, limit int) []InterfaceResult
SavePortDeviceResult(scanID string, result PortDeviceResult) error
ListPortDeviceResults(scanID string, filterIP string, filterIfIndex int, limit int) []PortDeviceResult
// PurgeAllScanData удаляет все сканы и связанные строки (хосты, порты, SNMP, LLDP, интерфейсы).
PurgeAllScanData() error
}
type HostResult struct {
IP string `json:"ip"`
IsUp bool `json:"is_up"`
CheckedAt time.Time `json:"checked_at"`
}
type OpenPortResult struct {
IP string `json:"ip"`
Port int `json:"port"`
IsOpen bool `json:"is_open"`
CheckedAt time.Time `json:"checked_at"`
}
type SNMPResult struct {
IP string `json:"ip"`
Success bool `json:"success"`
SysName string `json:"sys_name"`
SysDescr string `json:"sys_descr"`
SysObjectID string `json:"sys_object_id"`
Error string `json:"error,omitempty"`
CheckedAt time.Time `json:"checked_at"`
}
type LLDPResult struct {
IP string `json:"ip"`
LocalPortNum string `json:"local_port_num"`
RemoteChassisID string `json:"remote_chassis_id"`
RemotePortID string `json:"remote_port_id"`
RemoteSysName string `json:"remote_sys_name"`
CheckedAt time.Time `json:"checked_at"`
}
// InterfaceResult — строка IF-MIB (interfaces/ifTable + ifName) для устройства.
type InterfaceResult struct {
IP string `json:"ip"`
IfIndex int `json:"if_index"`
IfDescr string `json:"if_descr"`
IfName string `json:"if_name"`
IfOperStatus string `json:"if_oper_status"`
CheckedAt time.Time `json:"checked_at"`
}
// PortDeviceResult — MAC/IP, обнаруженные за конкретным портом (ifIndex) через BRIDGE-MIB + ARP.
type PortDeviceResult struct {
IP string `json:"ip"`
IfIndex int `json:"if_index"`
BridgePort int `json:"bridge_port,omitempty"`
Vlan int `json:"vlan"`
MAC string `json:"mac"`
LearnedIP string `json:"learned_ip"`
Hostname string `json:"hostname,omitempty"` // подставляется при ответе API по sysName скана, не хранится в БД
CheckedAt time.Time `json:"checked_at"`
}
type MemoryStore struct {
mu sync.RWMutex
jobs map[string]ScanJob
hosts map[string][]HostResult
ports map[string][]OpenPortResult
snmp map[string][]SNMPResult
lldp map[string][]LLDPResult
ifaces map[string][]InterfaceResult
fdb map[string][]PortDeviceResult
}
func NewMemoryStore() *MemoryStore {
return &MemoryStore{
jobs: make(map[string]ScanJob),
hosts: make(map[string][]HostResult),
ports: make(map[string][]OpenPortResult),
snmp: make(map[string][]SNMPResult),
lldp: make(map[string][]LLDPResult),
ifaces: make(map[string][]InterfaceResult),
fdb: make(map[string][]PortDeviceResult),
}
}
func (s *MemoryStore) CreateScan(req CreateScanRequest) (ScanJob, error) {
if err := validateCreateScanRequest(&req); err != nil {
return ScanJob{}, err
}
applyDefaultOptions(&req.Options)
id, err := newID()
if err != nil {
return ScanJob{}, err
}
now := time.Now().UTC()
job := ScanJob{
ID: id,
Name: req.Name,
Status: "queued",
CIDRs: req.CIDRs,
ExcludeIPs: req.ExcludeIPs,
SNMPCredentialsID: req.SNMPCredentialsID,
Options: req.Options,
Progress: 0,
Stats: ScanStats{},
CreatedAt: now,
}
s.mu.Lock()
s.jobs[id] = job
s.mu.Unlock()
return job, nil
}
func (s *MemoryStore) GetScan(id string) (ScanJob, bool) {
s.mu.RLock()
job, ok := s.jobs[id]
s.mu.RUnlock()
return job, ok
}
func (s *MemoryStore) ListScans(limit int) []ScanJob {
if limit <= 0 {
limit = 50
}
s.mu.RLock()
jobs := make([]ScanJob, 0, len(s.jobs))
for _, j := range s.jobs {
jobs = append(jobs, j)
}
s.mu.RUnlock()
sort.Slice(jobs, func(i, j int) bool {
return jobs[i].CreatedAt.After(jobs[j].CreatedAt)
})
if len(jobs) > limit {
jobs = jobs[:limit]
}
return jobs
}
func (s *MemoryStore) UpdateScan(id string, update ScanUpdate) (ScanJob, bool) {
s.mu.Lock()
defer s.mu.Unlock()
job, ok := s.jobs[id]
if !ok {
return ScanJob{}, false
}
if update.Status != "" {
job.Status = update.Status
}
if update.Progress >= 0 {
job.Progress = update.Progress
}
job.Stats = update.Stats
if update.StartedAt != nil {
job.StartedAt = *update.StartedAt
}
if update.FinishedAt != nil {
job.FinishedAt = *update.FinishedAt
}
s.jobs[id] = job
return job, true
}
func (s *MemoryStore) SaveHostResult(scanID string, host HostResult) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.jobs[scanID]; !ok {
return errors.New("scan not found")
}
s.hosts[scanID] = append(s.hosts[scanID], host)
return nil
}
func (s *MemoryStore) ListHostResults(scanID string, limit int) []HostResult {
s.mu.RLock()
defer s.mu.RUnlock()
items := s.hosts[scanID]
return dedupeHostsLatest(items, limit)
}
func (s *MemoryStore) SaveOpenPortResult(scanID string, result OpenPortResult) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.jobs[scanID]; !ok {
return errors.New("scan not found")
}
s.ports[scanID] = append(s.ports[scanID], result)
return nil
}
func (s *MemoryStore) ListOpenPortResults(scanID string, limit int) []OpenPortResult {
if limit <= 0 {
limit = 1000
}
s.mu.RLock()
defer s.mu.RUnlock()
items := s.ports[scanID]
if len(items) <= limit {
out := make([]OpenPortResult, len(items))
copy(out, items)
return out
}
out := make([]OpenPortResult, limit)
copy(out, items[:limit])
return out
}
func (s *MemoryStore) SaveSNMPResult(scanID string, result SNMPResult) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.jobs[scanID]; !ok {
return errors.New("scan not found")
}
s.snmp[scanID] = append(s.snmp[scanID], result)
return nil
}
func (s *MemoryStore) ListSNMPResults(scanID string, limit int) []SNMPResult {
s.mu.RLock()
defer s.mu.RUnlock()
items := s.snmp[scanID]
return dedupeSNMPLatest(items, limit)
}
func (s *MemoryStore) SaveLLDPResult(scanID string, result LLDPResult) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.jobs[scanID]; !ok {
return errors.New("scan not found")
}
s.lldp[scanID] = append(s.lldp[scanID], result)
return nil
}
func (s *MemoryStore) ListLLDPResults(scanID string, limit int) []LLDPResult {
if limit <= 0 {
limit = 2000
}
s.mu.RLock()
defer s.mu.RUnlock()
items := s.lldp[scanID]
if len(items) <= limit {
out := make([]LLDPResult, len(items))
copy(out, items)
return out
}
out := make([]LLDPResult, limit)
copy(out, items[:limit])
return out
}
func (s *MemoryStore) SaveInterfaceResult(scanID string, result InterfaceResult) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.jobs[scanID]; !ok {
return errors.New("scan not found")
}
s.ifaces[scanID] = append(s.ifaces[scanID], result)
return nil
}
func (s *MemoryStore) PurgeAllScanData() error {
s.mu.Lock()
defer s.mu.Unlock()
s.jobs = make(map[string]ScanJob)
s.hosts = make(map[string][]HostResult)
s.ports = make(map[string][]OpenPortResult)
s.snmp = make(map[string][]SNMPResult)
s.lldp = make(map[string][]LLDPResult)
s.ifaces = make(map[string][]InterfaceResult)
s.fdb = make(map[string][]PortDeviceResult)
return nil
}
func (s *MemoryStore) ListInterfaceResults(scanID string, filterIP string, limit int) []InterfaceResult {
if limit <= 0 {
limit = 4096
}
s.mu.RLock()
defer s.mu.RUnlock()
items := s.ifaces[scanID]
var filtered []InterfaceResult
if filterIP != "" {
for _, r := range items {
if r.IP == filterIP {
filtered = append(filtered, r)
}
}
} else {
filtered = append(filtered, items...)
}
sort.Slice(filtered, func(i, j int) bool { return filtered[i].IfIndex < filtered[j].IfIndex })
if len(filtered) <= limit {
out := make([]InterfaceResult, len(filtered))
copy(out, filtered)
return out
}
out := make([]InterfaceResult, limit)
copy(out, filtered[:limit])
return out
}
func (s *MemoryStore) SavePortDeviceResult(scanID string, result PortDeviceResult) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.jobs[scanID]; !ok {
return errors.New("scan not found")
}
s.fdb[scanID] = append(s.fdb[scanID], result)
return nil
}
func (s *MemoryStore) ListPortDeviceResults(scanID string, filterIP string, filterIfIndex int, limit int) []PortDeviceResult {
if limit <= 0 {
limit = 20000
}
s.mu.RLock()
defer s.mu.RUnlock()
items := s.fdb[scanID]
filtered := make([]PortDeviceResult, 0, len(items))
for _, r := range items {
if filterIP != "" && r.IP != filterIP {
continue
}
if filterIfIndex > 0 && r.IfIndex != filterIfIndex && r.BridgePort != filterIfIndex {
continue
}
filtered = append(filtered, r)
}
sort.Slice(filtered, func(i, j int) bool {
if filtered[i].IfIndex != filtered[j].IfIndex {
return filtered[i].IfIndex < filtered[j].IfIndex
}
if filtered[i].Vlan != filtered[j].Vlan {
return filtered[i].Vlan < filtered[j].Vlan
}
if filtered[i].MAC != filtered[j].MAC {
return filtered[i].MAC < filtered[j].MAC
}
return filtered[i].LearnedIP < filtered[j].LearnedIP
})
if len(filtered) <= limit {
out := make([]PortDeviceResult, len(filtered))
copy(out, filtered)
return out
}
out := make([]PortDeviceResult, limit)
copy(out, filtered[:limit])
return out
}
func applyDefaultOptions(opts *ScanOptions) {
if opts.PingTimeoutMS <= 0 {
opts.PingTimeoutMS = 700
}
if opts.PingRetries < 0 {
opts.PingRetries = 0
}
if opts.MaxParallelHosts <= 0 {
opts.MaxParallelHosts = 128
}
if len(opts.Ports) == 0 {
opts.Ports = []int{22, 80, 443, 161}
}
}
func validateCreateScanRequest(req *CreateScanRequest) error {
if len(req.CIDRs) == 0 {
return errors.New("cidrs must not be empty")
}
expanded, err := expandCIDRSFromRequest(req.CIDRs)
if err != nil {
return err
}
req.CIDRs = expanded
for _, ip := range req.ExcludeIPs {
if net.ParseIP(ip) == nil {
return errors.New("invalid exclude ip: " + ip)
}
}
return nil
}
func newID() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
return hex.EncodeToString(b[:]), nil
}
func dedupeHostsLatest(items []HostResult, limit int) []HostResult {
if limit <= 0 {
limit = 200000
}
m := make(map[string]HostResult, len(items))
for _, h := range items {
cur, ok := m[h.IP]
if !ok || h.CheckedAt.After(cur.CheckedAt) {
m[h.IP] = h
}
}
out := make([]HostResult, 0, len(m))
for _, h := range m {
out = append(out, h)
}
sort.Slice(out, func(i, j int) bool { return out[i].CheckedAt.After(out[j].CheckedAt) })
if len(out) > limit {
out = out[:limit]
}
return out
}
func dedupeSNMPLatest(items []SNMPResult, limit int) []SNMPResult {
if limit <= 0 {
limit = 200000
}
m := make(map[string]SNMPResult, len(items))
for _, r := range items {
cur, ok := m[r.IP]
if !ok || r.CheckedAt.After(cur.CheckedAt) {
m[r.IP] = r
}
}
out := make([]SNMPResult, 0, len(m))
for _, r := range m {
out = append(out, r)
}
sort.Slice(out, func(i, j int) bool { return out[i].CheckedAt.After(out[j].CheckedAt) })
if len(out) > limit {
out = out[:limit]
}
return out
}