diff --git a/internal/api/handler.go b/internal/api/handler.go index 5bda749..7e76f48 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -67,6 +67,7 @@ func (h *Handler) Routes() http.Handler { mux.HandleFunc("GET /api/scans/{id}/ports", h.getScanPorts) mux.HandleFunc("GET /api/scans/{id}/snmp", h.getScanSNMP) mux.HandleFunc("GET /api/scans/{id}/lldp", h.getScanLLDP) + mux.HandleFunc("GET /api/scans/{id}/interfaces", h.getScanInterfaces) mux.HandleFunc("GET /api/scans/{id}/links", h.getScanLinks) mux.HandleFunc("GET /api/scans/{id}/topology", h.getScanTopology) return withJSON(mux) @@ -274,6 +275,35 @@ func (h *Handler) getScanLLDP(w http.ResponseWriter, r *http.Request) { }) } +func (h *Handler) getScanInterfaces(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if id == "" { + writeError(w, http.StatusBadRequest, "scan id is required") + return + } + if _, ok := h.store.GetScan(id); !ok { + writeError(w, http.StatusNotFound, "scan not found") + return + } + ip := strings.TrimSpace(r.URL.Query().Get("ip")) + if ip == "" { + writeError(w, http.StatusBadRequest, "query parameter ip is required") + return + } + limit := 4096 + if raw := r.URL.Query().Get("limit"); raw != "" { + n, err := strconv.Atoi(raw) + if err != nil || n <= 0 || n > 10000 { + writeError(w, http.StatusBadRequest, "limit must be between 1 and 10000") + return + } + limit = n + } + writeJSON(w, http.StatusOK, map[string]any{ + "items": h.store.ListInterfaceResults(id, ip, limit), + }) +} + func (h *Handler) getScanLinks(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if id == "" { @@ -295,7 +325,7 @@ func (h *Handler) getScanLinks(w http.ResponseWriter, r *http.Request) { limit = n } - snmpItems := h.store.ListSNMPResults(id, 10000) + snmpItems := h.store.ListSNMPResults(id, 200000) lldpItems := h.store.ListLLDPResults(id, limit) sysNameToIP := make(map[string]string, len(snmpItems)) @@ -358,7 +388,7 @@ func (h *Handler) getScanTopology(w http.ResponseWriter, r *http.Request) { limit = n } - snmpItems := h.store.ListSNMPResults(id, 10000) + snmpItems := h.store.ListSNMPResults(id, 200000) lldpItems := h.store.ListLLDPResults(id, limit) sysNameToIP := make(map[string]string, len(snmpItems)) diff --git a/internal/scans/postgres_store.go b/internal/scans/postgres_store.go index 02536f5..ae88c59 100644 --- a/internal/scans/postgres_store.go +++ b/internal/scans/postgres_store.go @@ -475,3 +475,60 @@ limit $2` } return out } + +func (s *PostgresStore) SaveInterfaceResult(scanID string, result InterfaceResult) error { + query := ` +insert into scan_interfaces (scan_id, ip, if_index, if_descr, if_name, if_oper_status, checked_at) +values ($1, $2, $3, $4, $5, $6, $7)` + _, err := s.db.ExecContext( + context.Background(), + query, + scanID, + result.IP, + result.IfIndex, + result.IfDescr, + result.IfName, + result.IfOperStatus, + result.CheckedAt, + ) + return err +} + +func (s *PostgresStore) ListInterfaceResults(scanID string, filterIP string, limit int) []InterfaceResult { + if limit <= 0 { + limit = 4096 + } + var rows *sql.Rows + var err error + if filterIP != "" { + rows, err = s.db.QueryContext(context.Background(), ` +select ip::text, if_index, coalesce(if_descr, ''), coalesce(if_name, ''), coalesce(if_oper_status, ''), checked_at +from scan_interfaces +where scan_id = $1 and ip = $2::inet +order by if_index asc +limit $3`, + scanID, filterIP, limit) + } else { + rows, err = s.db.QueryContext(context.Background(), ` +select ip::text, if_index, coalesce(if_descr, ''), coalesce(if_name, ''), coalesce(if_oper_status, ''), checked_at +from scan_interfaces +where scan_id = $1 +order by ip asc, if_index asc +limit $2`, + scanID, limit) + } + if err != nil { + return nil + } + defer rows.Close() + + out := make([]InterfaceResult, 0, 256) + for rows.Next() { + var r InterfaceResult + if err = rows.Scan(&r.IP, &r.IfIndex, &r.IfDescr, &r.IfName, &r.IfOperStatus, &r.CheckedAt); err != nil { + return nil + } + out = append(out, r) + } + return out +} diff --git a/internal/scans/runner.go b/internal/scans/runner.go index 83a1fbb..838cac6 100644 --- a/internal/scans/runner.go +++ b/internal/scans/runner.go @@ -9,6 +9,7 @@ import ( "os" "os/exec" "runtime" + "sort" "strconv" "strings" "sync" @@ -101,13 +102,18 @@ func (r *Runner) run(job ScanJob) { } } if isUp && r.cfg.SNMPEnabled { - snmpRes, lldpRes := probeSNMPAndLLDP(ip, r.cfg.SNMPCommunity, checkedAt, job.Options.PingTimeoutMS) + snmpRes, lldpRes, ifRes := 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) + } + } } mu.Lock() @@ -147,7 +153,7 @@ func (r *Runner) run(job ScanJob) { }) } -func probeSNMPAndLLDP(ip, community string, checkedAt time.Time, timeoutMS int) (SNMPResult, []LLDPResult) { +func probeSNMPAndLLDP(ip, community string, checkedAt time.Time, timeoutMS int) (SNMPResult, []LLDPResult, []InterfaceResult) { if timeoutMS <= 0 { timeoutMS = 700 } @@ -164,7 +170,7 @@ func probeSNMPAndLLDP(ip, community string, checkedAt time.Time, timeoutMS int) Retries: 1, } if err := client.Connect(); err != nil { - return SNMPResult{IP: ip, Success: false, Error: err.Error(), CheckedAt: checkedAt}, nil + return SNMPResult{IP: ip, Success: false, Error: err.Error(), CheckedAt: checkedAt}, nil, nil } defer client.Conn.Close() @@ -175,7 +181,7 @@ func probeSNMPAndLLDP(ip, community string, checkedAt time.Time, timeoutMS int) } pkt, err := client.Get(oids) if err != nil { - return SNMPResult{IP: ip, Success: false, Error: err.Error(), CheckedAt: checkedAt}, nil + return SNMPResult{IP: ip, Success: false, Error: err.Error(), CheckedAt: checkedAt}, nil, nil } out := SNMPResult{IP: ip, Success: true, CheckedAt: checkedAt} @@ -200,7 +206,84 @@ func probeSNMPAndLLDP(ip, community string, checkedAt time.Time, timeoutMS int) client.MaxRepetitions = 12 client.Retries = 2 - return out, probeLLDP(client, ip, checkedAt) + lldpRes := probeLLDP(client, ip, checkedAt) + var ifList []InterfaceResult + if out.Success { + ifList = probeIfTable(client, ip, checkedAt) + } + return out, lldpRes, ifList +} + +// 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. diff --git a/internal/scans/store.go b/internal/scans/store.go index 642bb17..0124a19 100644 --- a/internal/scans/store.go +++ b/internal/scans/store.go @@ -67,6 +67,8 @@ type Store interface { 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 } type HostResult struct { @@ -101,22 +103,34 @@ type LLDPResult struct { 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"` +} + 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 + 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 } 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), + 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), } } @@ -305,6 +319,46 @@ func (s *MemoryStore) ListLLDPResults(scanID string, limit int) []LLDPResult { 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) 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 applyDefaultOptions(opts *ScanOptions) { if opts.PingTimeoutMS <= 0 { opts.PingTimeoutMS = 700 diff --git a/internal/store/migrate.go b/internal/store/migrate.go index 8edf486..3e43225 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -8,7 +8,15 @@ import ( //go:embed sql/001_init.sql var initSQL string +//go:embed sql/002_scan_interfaces.sql +var scanInterfacesSQL string + func RunMigrations(db *sql.DB) error { - _, err := db.Exec(initSQL) - return err + if _, err := db.Exec(initSQL); err != nil { + return err + } + if _, err := db.Exec(scanInterfacesSQL); err != nil { + return err + } + return nil } diff --git a/internal/store/sql/002_scan_interfaces.sql b/internal/store/sql/002_scan_interfaces.sql new file mode 100644 index 0000000..5e0da9e --- /dev/null +++ b/internal/store/sql/002_scan_interfaces.sql @@ -0,0 +1,13 @@ +create table if not exists scan_interfaces ( + id bigserial primary key, + scan_id text not null references scan_jobs(id) on delete cascade, + ip inet not null, + if_index int not null, + if_descr text null, + if_name text null, + if_oper_status text null, + checked_at timestamptz not null +); + +create index if not exists idx_scan_interfaces_scan_ip_idx + on scan_interfaces (scan_id, ip, if_index); diff --git a/internal/webui/index.html b/internal/webui/index.html index c285345..0afc93f 100644 --- a/internal/webui/index.html +++ b/internal/webui/index.html @@ -83,15 +83,16 @@
Кликните по строке в таблице «Найденные устройства». Первая таблица — связи с попыткой сопоставить IP соседа (данные /links); вторая — каждая запись LLDP одной текстовой строкой.
+Кликните по строке в таблице «Найденные устройства». Таблица 1 — все интерфейсы из IF-MIB (SNMP) и LLDP-соседи по ifIndex; сортировка по «Локальный порт». Таблица 2 — сырые строки LLDP.
| Локальный порт | +Локальный порт | +SNMP (интерфейс) | Имя соседа (sysName) | IP соседа | Порт соседа | @@ -100,7 +101,7 @@|
|---|---|---|---|---|---|---|
| Выберите устройство выше. | ||||||
| Выберите устройство выше. | ||||||