Files
PTah 0086302605 fix: 503 при сбое GetScan вместо ложного 404 scan not found
- Store.GetScan возвращает (job, ok, error); Postgres отдаёт err при ошибке БД/JSON

- API: getScanOrWriteError → 503 scan storage temporarily unavailable

- Web UI: понятное сообщение для HTTP 503 при опросе скана

Made-with: Cursor
2026-04-13 15:38:04 +10:00

633 lines
17 KiB
Go

package scans
import (
"crypto/rand"
"encoding/hex"
"errors"
"net"
"sort"
"strings"
"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"`
// HostsPingPhaseDone — завершены ping и (если включено) проверка TCP-портов; SNMP ещё может идти.
HostsPingPhaseDone int `json:"hosts_ping_phase_done"`
// HostsProcessed — хост полностью обработан (включая SNMP/LLDP/FDB при необходимости).
HostsProcessed int `json:"hosts_processed"`
}
type ScanUpdate struct {
Status string
Progress int
Stats ScanStats
StartedAt *time.Time
FinishedAt *time.Time
}
type Store interface {
CreateScan(CreateScanRequest) (ScanJob, error)
// GetScan: при found==false и err==nil записи нет; err!=nil — сбой хранилища (например БД).
GetScan(id string) (ScanJob, bool, error)
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
// ListPortDevicesByMac — строки FDB по точному MAC (mac уже в каноническом виде, см. NormalizeMAC).
ListPortDevicesByMac(scanID string, mac string, limit int) []PortDeviceResult
// CountDistinctMACsPerSwitchPort — число уникальных MAC на каждом (коммутатор, ifIndex, bridge_port, vlan) в скане.
CountDistinctMACsPerSwitchPort(scanID string) (map[string]int, error)
// 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) {
cidrSnapshot := append([]string(nil), req.CIDRs...)
if err := validateCreateScanRequest(&req); err != nil {
return ScanJob{}, err
}
applyDefaultOptions(&req.Options)
id, err := newScanID(cidrSnapshot)
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, error) {
s.mu.RLock()
job, ok := s.jobs[id]
s.mu.RUnlock()
return job, ok, nil
}
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 (s *MemoryStore) ListPortDevicesByMac(scanID string, mac string, limit int) []PortDeviceResult {
if limit <= 0 {
limit = 5000
}
if mac == "" {
return nil
}
s.mu.RLock()
defer s.mu.RUnlock()
items := s.fdb[scanID]
filtered := make([]PortDeviceResult, 0, 8)
for _, r := range items {
if NormalizeMAC(r.MAC) == mac {
filtered = append(filtered, r)
}
}
sort.Slice(filtered, func(i, j int) bool {
if filtered[i].IP != filtered[j].IP {
return filtered[i].IP < filtered[j].IP
}
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 (s *MemoryStore) CountDistinctMACsPerSwitchPort(scanID string) (map[string]int, error) {
s.mu.RLock()
defer s.mu.RUnlock()
items := s.fdb[scanID]
per := make(map[string]map[string]struct{})
for _, r := range items {
mk := NormalizeMAC(r.MAC)
if mk == "" {
continue
}
k := PortFDBLocationKey(r.IP, r.IfIndex, r.BridgePort, r.Vlan)
if per[k] == nil {
per[k] = make(map[string]struct{})
}
per[k][mk] = struct{}{}
}
out := make(map[string]int, len(per))
for k, set := range per {
out[k] = len(set)
}
return out, nil
}
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
}
// newScanID — человекочитаемый scan_id: UTC-время с наносекундами + CIDR из запроса (как ввёл пользователь, до expand) + 8 hex (коллизии по времени/CIDR).
func newScanID(cidrLabels []string) (string, error) {
var tail [4]byte
if _, err := rand.Read(tail[:]); err != nil {
return "", err
}
sfx := hex.EncodeToString(tail[:])
ts := time.Now().UTC().Format("20060102T150405.000000000")
raw := strings.TrimSpace(strings.Join(cidrLabels, "+"))
if raw == "" {
raw = "scan"
}
slug := sanitizeScanSlug(raw)
const maxSlug = 180
if len(slug) > maxSlug {
slug = slug[:maxSlug]
}
return ts + "_" + slug + "_" + sfx, nil
}
func sanitizeScanSlug(s string) string {
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
switch {
case r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9':
b.WriteRune(r)
case r == '.', r == '-', r == '_':
b.WriteRune(r)
case r == '/', r == ':', r == ',', r == '+', r == ' ', r == '\\':
b.WriteByte('_')
default:
b.WriteByte('_')
}
}
return strings.Trim(strings.TrimSpace(b.String()), "_")
}
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
}