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 @@
Сначала укажите scan_id в поле выше (или «Последний scan» / «Загрузить»). Введите MAC — увидите коммутатор (IP и sysName), порт, VLAN, IP за портом из ARP.
+Сначала укажите scan_id в поле выше (или «Последний scan» / «Загрузить»). Введите MAC. Строки сортируются: сначала порты с малым числом уникальных MAC на том же VLAN и порту (вероятный доступ), затем «между порогами», затем транк/uplink.
| MAC | IP за портом | Имя клиента | +MAC на порту | +Оценка порта | -|||||
|---|---|---|---|---|---|---|---|---|---|
| Введите MAC и нажмите кнопку (нужен scan_id в поле выше). | |||||||||
| Введите MAC и нажмите кнопку (нужен scan_id в поле выше). | |||||||||