diff --git a/internal/api/handler.go b/internal/api/handler.go index d3396e3..58c931c 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -50,6 +50,19 @@ type ScanDiffResponse struct { MissingLinks []string `json:"missing_links"` } +// MacLocationRow — где в FDB найден MAC: коммутатор (ip), порт, клиентский IP из ARP. +type MacLocationRow struct { + SwitchIP string `json:"switch_ip"` + SwitchSysName string `json:"switch_sys_name,omitempty"` + IfIndex int `json:"if_index"` + BridgePort int `json:"bridge_port,omitempty"` + Vlan int `json:"vlan"` + MAC string `json:"mac"` + ClientIP string `json:"client_ip,omitempty"` + ClientName string `json:"client_name,omitempty"` + CheckedAt time.Time `json:"checked_at"` +} + func NewHandler(store scans.Store, run *scans.Runner, resetDBSecret string) *Handler { return &Handler{store: store, run: run, resetDBSecret: strings.TrimSpace(resetDBSecret)} } @@ -70,6 +83,7 @@ func (h *Handler) Routes() http.Handler { mux.HandleFunc("GET /api/scans/{id}/lldp", h.getScanLLDP) mux.HandleFunc("GET /api/scans/{id}/interfaces", h.getScanInterfaces) mux.HandleFunc("GET /api/scans/{id}/port-devices", h.getScanPortDevices) + mux.HandleFunc("GET /api/scans/{id}/mac-locations", h.getScanMacLocations) mux.HandleFunc("GET /api/scans/{id}/links", h.getScanLinks) mux.HandleFunc("GET /api/scans/{id}/topology", h.getScanTopology) mux.HandleFunc("POST /api/admin/purge-scans", h.postAdminPurgeScans) @@ -391,6 +405,77 @@ func (h *Handler) getScanPortDevices(w http.ResponseWriter, r *http.Request) { }) } +func (h *Handler) getScanMacLocations(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 + } + raw := strings.TrimSpace(r.URL.Query().Get("mac")) + if raw == "" { + writeError(w, http.StatusBadRequest, "query parameter mac is required") + return + } + norm := scans.NormalizeMAC(raw) + if norm == "" { + writeError(w, http.StatusBadRequest, "invalid mac address") + return + } + limit := 2000 + if lim := strings.TrimSpace(r.URL.Query().Get("limit")); lim != "" { + n, err := strconv.Atoi(lim) + if err != nil || n <= 0 || n > 10000 { + writeError(w, http.StatusBadRequest, "limit must be between 1 and 10000") + return + } + limit = n + } + snmpItems := h.store.ListSNMPResults(id, 200000) + ipToName := make(map[string]string, len(snmpItems)) + for _, s := range snmpItems { + if !s.Success { + continue + } + name := strings.TrimSpace(s.SysName) + if name == "" { + continue + } + if _, ok := ipToName[s.IP]; !ok { + ipToName[s.IP] = name + } + } + items := h.store.ListPortDevicesByMac(id, norm, limit) + out := make([]MacLocationRow, 0, len(items)) + for _, r := range items { + row := MacLocationRow{ + SwitchIP: r.IP, + IfIndex: r.IfIndex, + BridgePort: r.BridgePort, + Vlan: r.Vlan, + MAC: r.MAC, + ClientIP: strings.TrimSpace(r.LearnedIP), + CheckedAt: r.CheckedAt, + } + if nm, ok := ipToName[r.IP]; ok { + row.SwitchSysName = nm + } + if row.ClientIP != "" { + if nm, ok := ipToName[row.ClientIP]; ok { + row.ClientName = nm + } + } + out = append(out, row) + } + writeJSON(w, http.StatusOK, map[string]any{ + "mac": norm, + "items": out, + }) +} + func (h *Handler) getScanLinks(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if id == "" { diff --git a/internal/scans/mac.go b/internal/scans/mac.go new file mode 100644 index 0000000..ddfec82 --- /dev/null +++ b/internal/scans/mac.go @@ -0,0 +1,52 @@ +package scans + +import ( + "encoding/hex" + "fmt" + "strconv" + "strings" +) + +// NormalizeMAC приводит ввод пользователя к виду aa:bb:cc:dd:ee:ff (как в FDB/ARP). +func NormalizeMAC(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + if s == "" { + return "" + } + s = strings.ReplaceAll(s, "-", ":") + if strings.Count(s, ":") == 5 { + parts := strings.Split(s, ":") + if len(parts) != 6 { + return "" + } + var b [6]byte + for i, p := range parts { + n, err := strconv.ParseUint(strings.TrimSpace(p), 16, 8) + if err != nil { + return "" + } + b[i] = byte(n) + } + return fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x", b[0], b[1], b[2], b[3], b[4], b[5]) + } + if strings.HasPrefix(s, "0x") { + h := strings.TrimPrefix(s, "0x") + h = strings.ReplaceAll(h, ":", "") + if len(h) != 12 { + return "" + } + raw, err := hex.DecodeString(h) + if err != nil || len(raw) != 6 { + return "" + } + return fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x", raw[0], raw[1], raw[2], raw[3], raw[4], raw[5]) + } + if len(s) == 12 { + raw, err := hex.DecodeString(s) + if err != nil || len(raw) != 6 { + return "" + } + return fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x", raw[0], raw[1], raw[2], raw[3], raw[4], raw[5]) + } + return "" +} diff --git a/internal/scans/postgres_store.go b/internal/scans/postgres_store.go index 9314821..f9d299e 100644 --- a/internal/scans/postgres_store.go +++ b/internal/scans/postgres_store.go @@ -534,6 +534,37 @@ limit $2`, return out } +func (s *PostgresStore) ListPortDevicesByMac(scanID string, mac string, limit int) []PortDeviceResult { + if limit <= 0 { + limit = 5000 + } + if mac == "" { + return nil + } + query := ` +select ip::text, if_index, bridge_port, vlan, coalesce(mac, ''), coalesce(learned_ip::text, ''), checked_at +from scan_port_devices +where scan_id = $1 + and lower(replace(trim(coalesce(mac, '')), '-', ':')) = lower(replace(trim($2::text), '-', ':')) +order by ip::text asc, if_index asc, vlan asc, mac asc, learned_ip asc +limit $3` + rows, err := s.db.QueryContext(context.Background(), query, scanID, mac, limit) + if err != nil { + return nil + } + defer rows.Close() + + out := make([]PortDeviceResult, 0, 32) + for rows.Next() { + var r PortDeviceResult + if err = rows.Scan(&r.IP, &r.IfIndex, &r.BridgePort, &r.Vlan, &r.MAC, &r.LearnedIP, &r.CheckedAt); err != nil { + return nil + } + out = append(out, r) + } + return out +} + func (s *PostgresStore) PurgeAllScanData() error { // Дочерние таблицы ссылаются на scan_jobs — CASCADE очищает всё; RESTART IDENTITY сбрасывает bigserial. _, err := s.db.ExecContext(context.Background(), `truncate table scan_jobs restart identity cascade`) diff --git a/internal/scans/runner.go b/internal/scans/runner.go index 3fd11a1..737e1bd 100644 --- a/internal/scans/runner.go +++ b/internal/scans/runner.go @@ -364,48 +364,8 @@ func snmpNumericString(v any) string { } } -// normalizeMACKey приводит MAC к виду aa:bb:cc:dd:ee:ff для сопоставления FDB и ARP. func normalizeMACKey(s string) string { - s = strings.ToLower(strings.TrimSpace(s)) - if s == "" { - return "" - } - s = strings.ReplaceAll(s, "-", ":") - if strings.Count(s, ":") == 5 { - parts := strings.Split(s, ":") - if len(parts) != 6 { - return "" - } - var b [6]byte - for i, p := range parts { - n, err := strconv.ParseUint(strings.TrimSpace(p), 16, 8) - if err != nil { - return "" - } - b[i] = byte(n) - } - return fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x", b[0], b[1], b[2], b[3], b[4], b[5]) - } - if strings.HasPrefix(s, "0x") { - h := strings.TrimPrefix(s, "0x") - h = strings.ReplaceAll(h, ":", "") - if len(h) != 12 { - return "" - } - raw, err := hex.DecodeString(h) - if err != nil || len(raw) != 6 { - return "" - } - return fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x", raw[0], raw[1], raw[2], raw[3], raw[4], raw[5]) - } - if len(s) == 12 { - raw, err := hex.DecodeString(s) - if err != nil || len(raw) != 6 { - return "" - } - return fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x", raw[0], raw[1], raw[2], raw[3], raw[4], raw[5]) - } - return "" + return NormalizeMAC(s) } func dottedDecimalToMACTail(s string) string { diff --git a/internal/scans/store.go b/internal/scans/store.go index de717d7..8d4e816 100644 --- a/internal/scans/store.go +++ b/internal/scans/store.go @@ -71,6 +71,8 @@ type Store interface { 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 // PurgeAllScanData удаляет все сканы и связанные строки (хосты, порты, SNMP, LLDP, интерфейсы). PurgeAllScanData() error } @@ -439,6 +441,47 @@ func (s *MemoryStore) ListPortDeviceResults(scanID string, filterIP string, filt 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 applyDefaultOptions(opts *ScanOptions) { if opts.PingTimeoutMS <= 0 { opts.PingTimeoutMS = 700 diff --git a/internal/store/migrate.go b/internal/store/migrate.go index 967aa21..1351505 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -20,6 +20,9 @@ var scanPortDevicesBridgePortSQL string //go:embed sql/005_scan_port_devices_vlan.sql var scanPortDevicesVlanSQL string +//go:embed sql/006_scan_port_devices_mac_index.sql +var scanPortDevicesMacIndexSQL string + func RunMigrations(db *sql.DB) error { if _, err := db.Exec(initSQL); err != nil { return err @@ -36,5 +39,8 @@ func RunMigrations(db *sql.DB) error { if _, err := db.Exec(scanPortDevicesVlanSQL); err != nil { return err } + if _, err := db.Exec(scanPortDevicesMacIndexSQL); err != nil { + return err + } return nil } diff --git a/internal/store/sql/003_scan_port_devices.sql b/internal/store/sql/003_scan_port_devices.sql index 8607c2d..6ddf577 100644 --- a/internal/store/sql/003_scan_port_devices.sql +++ b/internal/store/sql/003_scan_port_devices.sql @@ -15,3 +15,6 @@ create index if not exists idx_scan_port_devices_scan_ip_if create index if not exists idx_scan_port_devices_scan_ip_mac on scan_port_devices (scan_id, ip, mac); + +create index if not exists idx_scan_port_devices_scan_mac + on scan_port_devices (scan_id, mac); diff --git a/internal/store/sql/006_scan_port_devices_mac_index.sql b/internal/store/sql/006_scan_port_devices_mac_index.sql new file mode 100644 index 0000000..9c18f96 --- /dev/null +++ b/internal/store/sql/006_scan_port_devices_mac_index.sql @@ -0,0 +1,3 @@ +-- Ускорение поиска MAC по scan_id (таблица FDB). +create index if not exists idx_scan_port_devices_scan_mac + on scan_port_devices (scan_id, mac); diff --git a/internal/webui/index.html b/internal/webui/index.html index 3732675..da92e59 100644 --- a/internal/webui/index.html +++ b/internal/webui/index.html @@ -71,6 +71,37 @@
Ожидание действия. Строка статуса показывает текущую операцию и прогресс скана.
+
+
+ Поиск MAC в сохранённом скане + По FDB в БД: коммутатор (IP коммутатора из скана), порт (ifIndex / bridge), VLAN; IP и имя клиента — из ARP на коммутаторе и SNMP этого scan. +
+
+ + + +
+
+ + + + + + + + + + + + + + + + +
Коммутатор (IP)Имя (sysName)ifIndexbridgeVLANMACIP за портомИмя клиента
Укажите scan_id выше и MAC, нажмите «Найти».
+
+
+
Найденные устройства @@ -188,6 +219,10 @@ const deviceTableBody = document.getElementById("deviceTableBody"); const deviceTableHead = document.getElementById("deviceTableHead"); const refreshDevicesBtn = document.getElementById("refreshDevicesBtn"); + const macSearchInput = document.getElementById("macSearchInput"); + const macSearchBtn = document.getElementById("macSearchBtn"); + const macSearchTableBody = document.getElementById("macSearchTableBody"); + const macSearchSummary = document.getElementById("macSearchSummary"); const edgeListPanel = document.getElementById("edgeListPanel"); const edgeListPre = document.getElementById("edgeListPre"); const NS = "http://www.w3.org/2000/svg"; @@ -1221,11 +1256,84 @@ } } + async function runMacSearch() { + const scanId = scanIdInput.value.trim(); + const mac = (macSearchInput && macSearchInput.value.trim()) || ""; + if (!scanId) { + setStatus("Укажите scan_id для поиска MAC."); + return; + } + if (!mac) { + setStatus("Введите MAC-адрес."); + return; + } + if (macSearchSummary) macSearchSummary.textContent = ""; + if (macSearchTableBody) { + macSearchTableBody.innerHTML = + 'Загрузка…'; + } + setStatus(`Поиск MAC в scan ${scanId}…`); + try { + const res = await fetch( + `/api/scans/${encodeURIComponent(scanId)}/mac-locations?mac=${encodeURIComponent(mac)}&limit=2000` + ); + const text = await res.text(); + if (!res.ok) { + if (macSearchTableBody) { + macSearchTableBody.innerHTML = `${escapeHtml( + text || res.statusText + )}`; + } + setStatus(`Ошибка поиска MAC: ${res.status}`); + return; + } + const data = JSON.parse(text); + const items = data.items || []; + const canon = data.mac || mac; + if (macSearchSummary) { + macSearchSummary.textContent = items.length + ? `Нормализовано: ${canon} · записей: ${items.length}` + : `Нормализовано: ${canon} · в FDB этого скана нет`; + } + if (!macSearchTableBody) return; + if (items.length === 0) { + macSearchTableBody.innerHTML = + 'Нет совпадений в scan_port_devices.'; + setStatus(`Поиск MAC: совпадений нет (${canon}).`); + return; + } + macSearchTableBody.innerHTML = items + .map( + (row) => + `${escapeHtml(row.switch_ip || "")}${escapeHtml( + row.switch_sys_name || "—" + )}${escapeHtml(String(row.if_index ?? ""))}${escapeHtml( + String(row.bridge_port ?? "") + )}${escapeHtml(String(row.vlan ?? ""))}${escapeHtml( + row.mac || "—" + )}${escapeHtml(row.client_ip || "—")}${escapeHtml(row.client_name || "—")}` + ) + .join(""); + setStatus(`Поиск MAC: найдено ${items.length} строк (${canon}).`); + } catch (e) { + if (macSearchTableBody) { + macSearchTableBody.innerHTML = `${escapeHtml( + e.message + )}`; + } + setStatus(`Ошибка поиска MAC: ${e.message}`); + } + } + loadBtn.addEventListener("click", async () => { await loadTopology(); await loadDeviceTable(scanIdInput.value.trim()); }); refreshDevicesBtn.addEventListener("click", () => loadDeviceTable(scanIdInput.value.trim())); + macSearchBtn?.addEventListener("click", runMacSearch); + macSearchInput?.addEventListener("keydown", (ev) => { + if (ev.key === "Enter") runMacSearch(); + }); createScanBtn.addEventListener("click", createScan); document.getElementById("purgeDbBtn")?.addEventListener("click", async () => { const inp = document.getElementById("purgeSecretInput");