diff --git a/internal/api/handler.go b/internal/api/handler.go index 58c931c..81f8648 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -3,6 +3,7 @@ package api import ( "encoding/json" "net/http" + "sort" "strconv" "strings" "time" @@ -60,6 +61,8 @@ type MacLocationRow struct { MAC string `json:"mac"` ClientIP string `json:"client_ip,omitempty"` ClientName string `json:"client_name,omitempty"` + MacsOnPort int `json:"macs_on_port"` + PortKind string `json:"port_kind"` // access | ambiguous | strong_transit CheckedAt time.Time `json:"checked_at"` } @@ -434,6 +437,35 @@ func (h *Handler) getScanMacLocations(w http.ResponseWriter, r *http.Request) { } limit = n } + accessMax := 8 + trunkMin := 32 + if v := strings.TrimSpace(r.URL.Query().Get("access_max_macs")); v != "" { + n, err := strconv.Atoi(v) + if err != nil || n < 1 || n > 4096 { + writeError(w, http.StatusBadRequest, "access_max_macs must be between 1 and 4096") + return + } + accessMax = n + } + if v := strings.TrimSpace(r.URL.Query().Get("trunk_min_macs")); v != "" { + n, err := strconv.Atoi(v) + if err != nil || n < 2 || n > 65536 { + writeError(w, http.StatusBadRequest, "trunk_min_macs must be between 2 and 65536") + return + } + trunkMin = n + } + if accessMax >= trunkMin { + writeError(w, http.StatusBadRequest, "access_max_macs must be less than trunk_min_macs") + return + } + + counts, err := h.store.CountDistinctMACsPerSwitchPort(id) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + snmpItems := h.store.ListSNMPResults(id, 200000) ipToName := make(map[string]string, len(snmpItems)) for _, s := range snmpItems { @@ -451,6 +483,11 @@ func (h *Handler) getScanMacLocations(w http.ResponseWriter, r *http.Request) { items := h.store.ListPortDevicesByMac(id, norm, limit) out := make([]MacLocationRow, 0, len(items)) for _, r := range items { + key := scans.PortFDBLocationKey(r.IP, r.IfIndex, r.BridgePort, r.Vlan) + nOn := counts[key] + if nOn < 1 { + nOn = 1 + } row := MacLocationRow{ SwitchIP: r.IP, IfIndex: r.IfIndex, @@ -458,6 +495,8 @@ func (h *Handler) getScanMacLocations(w http.ResponseWriter, r *http.Request) { Vlan: r.Vlan, MAC: r.MAC, ClientIP: strings.TrimSpace(r.LearnedIP), + MacsOnPort: nOn, + PortKind: scans.ClassifyPortKind(nOn, accessMax, trunkMin), CheckedAt: r.CheckedAt, } if nm, ok := ipToName[r.IP]; ok { @@ -470,9 +509,51 @@ func (h *Handler) getScanMacLocations(w http.ResponseWriter, r *http.Request) { } out = append(out, row) } + sort.Slice(out, func(i, j int) bool { + ri, rj := scans.PortKindRank(out[i].PortKind), scans.PortKindRank(out[j].PortKind) + if ri != rj { + return ri < rj + } + if out[i].MacsOnPort != out[j].MacsOnPort { + return out[i].MacsOnPort < out[j].MacsOnPort + } + if out[i].SwitchIP != out[j].SwitchIP { + return out[i].SwitchIP < out[j].SwitchIP + } + if out[i].IfIndex != out[j].IfIndex { + return out[i].IfIndex < out[j].IfIndex + } + if out[i].Vlan != out[j].Vlan { + return out[i].Vlan < out[j].Vlan + } + return out[i].ClientIP < out[j].ClientIP + }) + + likely := make([]MacLocationRow, 0) + for _, row := range out { + if row.PortKind == "access" { + likely = append(likely, row) + } + } + sort.Slice(likely, func(i, j int) bool { + if likely[i].MacsOnPort != likely[j].MacsOnPort { + return likely[i].MacsOnPort < likely[j].MacsOnPort + } + if likely[i].SwitchIP != likely[j].SwitchIP { + return likely[i].SwitchIP < likely[j].SwitchIP + } + if likely[i].IfIndex != likely[j].IfIndex { + return likely[i].IfIndex < likely[j].IfIndex + } + return likely[i].Vlan < likely[j].Vlan + }) + writeJSON(w, http.StatusOK, map[string]any{ - "mac": norm, - "items": out, + "mac": norm, + "access_max_macs": accessMax, + "trunk_min_macs": trunkMin, + "likely_attachment": likely, + "items": out, }) } diff --git a/internal/scans/portfdb.go b/internal/scans/portfdb.go new file mode 100644 index 0000000..073b12e --- /dev/null +++ b/internal/scans/portfdb.go @@ -0,0 +1,33 @@ +package scans + +import "strconv" + +// PortFDBLocationKey — ключ «где в FDB»: IP коммутатора, порт и VLAN (Q-BRIDGE). +func PortFDBLocationKey(ip string, ifIndex, bridgePort, vlan int) string { + return ip + "|" + strconv.Itoa(ifIndex) + "|" + strconv.Itoa(bridgePort) + "|" + strconv.Itoa(vlan) +} + +// ClassifyPortKind по числу уникальных MAC на порту и порогам (ожидается accessMax < trunkMin). +func ClassifyPortKind(macsOnPort, accessMax, trunkMin int) string { + if macsOnPort <= accessMax { + return "access" + } + if macsOnPort >= trunkMin { + return "strong_transit" + } + return "ambiguous" +} + +// PortKindRank для сортировки: access → ambiguous → strong_transit. +func PortKindRank(kind string) int { + switch kind { + case "access": + return 0 + case "ambiguous": + return 1 + case "strong_transit": + return 2 + default: + return 9 + } +} diff --git a/internal/scans/portfdb_test.go b/internal/scans/portfdb_test.go new file mode 100644 index 0000000..ad0267b --- /dev/null +++ b/internal/scans/portfdb_test.go @@ -0,0 +1,22 @@ +package scans + +import "testing" + +func TestClassifyPortKind(t *testing.T) { + const accessMax, trunkMin = 8, 32 + if g := ClassifyPortKind(1, accessMax, trunkMin); g != "access" { + t.Fatalf("1 -> access, got %q", g) + } + if g := ClassifyPortKind(8, accessMax, trunkMin); g != "access" { + t.Fatalf("8 -> access, got %q", g) + } + if g := ClassifyPortKind(9, accessMax, trunkMin); g != "ambiguous" { + t.Fatalf("9 -> ambiguous, got %q", g) + } + if g := ClassifyPortKind(31, accessMax, trunkMin); g != "ambiguous" { + t.Fatalf("31 -> ambiguous, got %q", g) + } + if g := ClassifyPortKind(32, accessMax, trunkMin); g != "strong_transit" { + t.Fatalf("32 -> strong_transit, got %q", g) + } +} diff --git a/internal/scans/postgres_store.go b/internal/scans/postgres_store.go index f4cd35b..4c5674d 100644 --- a/internal/scans/postgres_store.go +++ b/internal/scans/postgres_store.go @@ -570,6 +570,36 @@ limit $3` return out } +func (s *PostgresStore) CountDistinctMACsPerSwitchPort(scanID string) (map[string]int, error) { + query := ` +select ip::text, if_index, bridge_port, vlan, + count(distinct lower(replace(trim(coalesce(mac, '')), '-', ':')))::int as c +from scan_port_devices +where scan_id = $1 + and trim(coalesce(mac, '')) <> '' +group by ip, if_index, bridge_port, vlan` + rows, err := s.db.QueryContext(context.Background(), query, scanID) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make(map[string]int, 4096) + for rows.Next() { + var ip string + var ifIndex, bridgePort, vlan, c int + if err = rows.Scan(&ip, &ifIndex, &bridgePort, &vlan, &c); err != nil { + return nil, err + } + k := PortFDBLocationKey(ip, ifIndex, bridgePort, vlan) + out[k] = c + } + if err = rows.Err(); err != nil { + return nil, err + } + return out, nil +} + 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/store.go b/internal/scans/store.go index 8d4e816..bd74160 100644 --- a/internal/scans/store.go +++ b/internal/scans/store.go @@ -73,6 +73,8 @@ type Store interface { 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 } @@ -482,6 +484,29 @@ func (s *MemoryStore) ListPortDevicesByMac(scanID string, mac string, limit int) 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 diff --git a/internal/webui/index.html b/internal/webui/index.html index 5c46f65..9dd7666 100644 --- a/internal/webui/index.html +++ b/internal/webui/index.html @@ -56,13 +56,19 @@
Где этот MAC подключён? (таблица FDB по выбранному scan)
-

Сначала укажите scan_id в поле выше (или «Последний scan» / «Загрузить»). Введите MAC — увидите коммутатор (IP и sysName), порт, VLAN, IP за портом из ARP.

+

Сначала укажите scan_id в поле выше (или «Последний scan» / «Загрузить»). Введите MAC. Строки сортируются: сначала порты с малым числом уникальных MAC на том же VLAN и порту (вероятный доступ), затем «между порогами», затем транк/uplink.

+
+ Пороги + + + (должно быть: доступ < транк) +
@@ -75,10 +81,12 @@ + + - +
MAC IP за портом Имя клиентаMAC на портуОценка порта
Введите MAC и нажмите кнопку (нужен scan_id в поле выше).
Введите MAC и нажмите кнопку (нужен scan_id в поле выше).
@@ -1256,6 +1264,13 @@ } } + function macPortKindRu(kind) { + if (kind === "access") return "доступ (мало MAC)"; + if (kind === "strong_transit") return "транк / uplink"; + if (kind === "ambiguous") return "между порогами"; + return kind ? String(kind) : "—"; + } + async function runMacSearch() { const scanId = scanIdInput.value.trim(); const mac = (macSearchInput && macSearchInput.value.trim()) || ""; @@ -1267,20 +1282,35 @@ setStatus("Введите MAC-адрес."); return; } + const amEl = document.getElementById("macAccessMax"); + const tmEl = document.getElementById("macTrunkMin"); + const accessMax = amEl && amEl.value !== "" ? parseInt(amEl.value, 10) : 8; + const trunkMin = tmEl && tmEl.value !== "" ? parseInt(tmEl.value, 10) : 32; + if (!Number.isFinite(accessMax) || !Number.isFinite(trunkMin) || accessMax < 1 || trunkMin < 2) { + setStatus("Пороги: введите числа (доступ ≥1, транк ≥2)."); + return; + } + if (accessMax >= trunkMin) { + setStatus("Пороги: «доступ» должно быть строго меньше «транк» (например 8 и 32)."); + 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` - ); + let url = `/api/scans/${encodeURIComponent(scanId)}/mac-locations?mac=${encodeURIComponent( + mac + )}&limit=2000&access_max_macs=${encodeURIComponent(String(accessMax))}&trunk_min_macs=${encodeURIComponent( + String(trunkMin) + )}`; + const res = await fetch(url); const text = await res.text(); if (!res.ok) { if (macSearchTableBody) { - macSearchTableBody.innerHTML = `${escapeHtml( + macSearchTableBody.innerHTML = `${escapeHtml( text || res.statusText )}`; } @@ -1289,16 +1319,21 @@ } const data = JSON.parse(text); const items = data.items || []; + const likely = data.likely_attachment || []; const canon = data.mac || mac; + const am = data.access_max_macs ?? accessMax; + const tm = data.trunk_min_macs ?? trunkMin; if (macSearchSummary) { - macSearchSummary.textContent = items.length - ? `Нормализовано: ${canon} · записей: ${items.length}` - : `Нормализовано: ${canon} · в FDB этого скана нет`; + if (!items.length) { + macSearchSummary.textContent = `Нормализовано: ${canon} · в FDB этого скана нет`; + } else { + macSearchSummary.textContent = `Пороги: доступ ≤${am}, транк ≥${tm}. Вероятный доступ: ${likely.length} из ${items.length} строк (см. колонки).`; + } } if (!macSearchTableBody) return; if (items.length === 0) { macSearchTableBody.innerHTML = - 'Нет совпадений в scan_port_devices.'; + 'Нет совпадений в scan_port_devices.'; setStatus(`Поиск MAC: совпадений нет (${canon}).`); return; } @@ -1311,13 +1346,15 @@ String(row.bridge_port ?? "") )}${escapeHtml(String(row.vlan ?? ""))}${escapeHtml( row.mac || "—" - )}${escapeHtml(row.client_ip || "—")}${escapeHtml(row.client_name || "—")}` + )}${escapeHtml(row.client_ip || "—")}${escapeHtml(row.client_name || "—")}${escapeHtml( + String(row.macs_on_port ?? "") + )}${escapeHtml(macPortKindRu(row.port_kind))}` ) .join(""); - setStatus(`Поиск MAC: найдено ${items.length} строк (${canon}).`); + setStatus(`Поиск MAC: ${items.length} строк (${canon}), вероятный доступ: ${likely.length}.`); } catch (e) { if (macSearchTableBody) { - macSearchTableBody.innerHTML = `${escapeHtml( + macSearchTableBody.innerHTML = `${escapeHtml( e.message )}`; }