package api import ( "encoding/json" "log" "net/http" "net/url" "sort" "strconv" "strings" "time" "nettopo-go/internal/scans" "nettopo-go/internal/webui" ) type Handler struct { store scans.Store run *scans.Runner resetDBSecret string } type LinkView struct { SourceIP string `json:"source_ip"` SourcePort string `json:"source_port"` TargetIP string `json:"target_ip,omitempty"` TargetSysName string `json:"target_sys_name,omitempty"` TargetPort string `json:"target_port,omitempty"` RemoteChassis string `json:"remote_chassis_id,omitempty"` ResolvedBy string `json:"resolved_by"` } type TopologyNode struct { ID string `json:"id"` IP string `json:"ip"` Name string `json:"name,omitempty"` } type TopologyEdge struct { SourceIP string `json:"source_ip"` SourcePort string `json:"source_port,omitempty"` TargetIP string `json:"target_ip,omitempty"` TargetName string `json:"target_name,omitempty"` TargetPort string `json:"target_port,omitempty"` } type ScanDiffResponse struct { FromScanID string `json:"from_scan_id"` ToScanID string `json:"to_scan_id"` NewHosts []string `json:"new_hosts"` MissingHosts []string `json:"missing_hosts"` NewLinks []string `json:"new_links"` 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"` MacsOnPort int `json:"macs_on_port"` PortKind string `json:"port_kind"` // access | ambiguous | strong_transit 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)} } func scanPathID(r *http.Request) string { raw := r.PathValue("id") if raw == "" { return "" } if dec, err := url.PathUnescape(raw); err == nil { return strings.TrimSpace(dec) } return strings.TrimSpace(raw) } func (h *Handler) Routes() http.Handler { mux := http.NewServeMux() webFS := http.FileServer(http.FS(webui.FS)) mux.Handle("GET /", webFS) mux.Handle("GET /web/", http.StripPrefix("/web/", webFS)) mux.HandleFunc("GET /health", h.health) mux.HandleFunc("POST /api/scans", h.createScan) mux.HandleFunc("GET /api/scans", h.listScans) mux.HandleFunc("GET /api/scans/diff", h.getScansDiff) mux.HandleFunc("GET /api/scans/{id}", h.getScan) mux.HandleFunc("POST /api/scans/{id}/cancel", h.postCancelScan) mux.HandleFunc("GET /api/scans/{id}/hosts", h.getScanHosts) 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}/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) return withJSON(mux) } func (h *Handler) postAdminPurgeScans(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeError(w, http.StatusMethodNotAllowed, "use POST") return } if h.resetDBSecret == "" { writeError(w, http.StatusForbidden, "purge disabled: set NETTOPO_RESET_DB_SECRET in environment") return } key := strings.TrimSpace(r.Header.Get("X-Nettopo-Reset-Key")) if key == "" || key != h.resetDBSecret { writeError(w, http.StatusUnauthorized, "invalid or missing X-Nettopo-Reset-Key header") return } if err := h.store.PurgeAllScanData(); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, map[string]any{ "ok": true, "message": "all scan data removed (scan_jobs and related rows)", }) } func (h *Handler) health(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]any{ "status": "ok", "time": time.Now().UTC(), }) } func (h *Handler) createScan(w http.ResponseWriter, r *http.Request) { var req scans.CreateScanRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid json body") return } req.Name = strings.TrimSpace(req.Name) if req.Name == "" { req.Name = "manual-scan" } job, err := h.store.CreateScan(req) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } // Start до ответа клиенту: иначе возможен POST /cancel до регистрации cancel в Runner (задача останется queued/running). if h.run != nil { h.run.Start(job) } writeJSON(w, http.StatusCreated, map[string]any{ "scan_id": job.ID, "status": job.Status, }) } func (h *Handler) listScans(w http.ResponseWriter, r *http.Request) { limit := 50 if raw := r.URL.Query().Get("limit"); raw != "" { n, err := strconv.Atoi(raw) if err != nil || n <= 0 || n > 500 { writeError(w, http.StatusBadRequest, "limit must be between 1 and 500") return } limit = n } writeJSON(w, http.StatusOK, map[string]any{ "items": h.store.ListScans(limit), }) } func (h *Handler) getScansDiff(w http.ResponseWriter, r *http.Request) { fromID := strings.TrimSpace(r.URL.Query().Get("from")) toID := strings.TrimSpace(r.URL.Query().Get("to")) if fromID == "" || toID == "" { writeError(w, http.StatusBadRequest, "both from and to are required") return } if _, ok := h.store.GetScan(fromID); !ok { writeError(w, http.StatusNotFound, "from scan not found") return } if _, ok := h.store.GetScan(toID); !ok { writeError(w, http.StatusNotFound, "to scan not found") return } fromHosts := hostUpSet(h.store.ListHostResults(fromID, 200000)) toHosts := hostUpSet(h.store.ListHostResults(toID, 200000)) fromLinks := linkSet(h.store.ListLLDPResults(fromID, 200000)) toLinks := linkSet(h.store.ListLLDPResults(toID, 200000)) resp := ScanDiffResponse{ FromScanID: fromID, ToScanID: toID, NewHosts: setDiff(toHosts, fromHosts), MissingHosts: setDiff(fromHosts, toHosts), NewLinks: setDiff(toLinks, fromLinks), MissingLinks: setDiff(fromLinks, toLinks), } writeJSON(w, http.StatusOK, resp) } func (h *Handler) getScan(w http.ResponseWriter, r *http.Request) { id := scanPathID(r) if id == "" { writeError(w, http.StatusBadRequest, "scan id is required") return } job, ok := h.store.GetScan(id) if !ok { log.Printf("getScan: not found id=%q (len=%d)", id, len(id)) writeError(w, http.StatusNotFound, "scan not found") return } writeJSON(w, http.StatusOK, job) } func (h *Handler) postCancelScan(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeError(w, http.StatusMethodNotAllowed, "use POST") return } id := scanPathID(r) if id == "" { writeError(w, http.StatusBadRequest, "scan id is required") return } if _, ok := h.store.GetScan(id); !ok { log.Printf("postCancelScan: not found id=%q (len=%d)", id, len(id)) writeError(w, http.StatusNotFound, "scan not found") return } runnerSignaled := false if h.run != nil { runnerSignaled = h.run.CancelScan(id) } if runnerSignaled { writeJSON(w, http.StatusOK, map[string]any{"ok": true, "runner_canceled": true}) return } cur, _ := h.store.GetScan(id) if cur.Status == "queued" { fin := time.Now().UTC() _, _ = h.store.UpdateScan(id, scans.ScanUpdate{Status: "canceled", FinishedAt: &fin}) writeJSON(w, http.StatusOK, map[string]any{"ok": true, "runner_canceled": false, "status": "canceled"}) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true, "runner_canceled": false}) } func (h *Handler) getScanHosts(w http.ResponseWriter, r *http.Request) { id := scanPathID(r) 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 } limit := 200000 if raw := r.URL.Query().Get("limit"); raw != "" { n, err := strconv.Atoi(raw) if err != nil || n <= 0 || n > 500000 { writeError(w, http.StatusBadRequest, "limit must be between 1 and 500000") return } limit = n } writeJSON(w, http.StatusOK, map[string]any{ "items": h.store.ListHostResults(id, limit), }) } func (h *Handler) getScanPorts(w http.ResponseWriter, r *http.Request) { id := scanPathID(r) 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 } limit := 1000 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.ListOpenPortResults(id, limit), }) } func (h *Handler) getScanSNMP(w http.ResponseWriter, r *http.Request) { id := scanPathID(r) 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 } limit := 200000 if raw := r.URL.Query().Get("limit"); raw != "" { n, err := strconv.Atoi(raw) if err != nil || n <= 0 || n > 500000 { writeError(w, http.StatusBadRequest, "limit must be between 1 and 500000") return } limit = n } writeJSON(w, http.StatusOK, map[string]any{ "items": h.store.ListSNMPResults(id, limit), }) } func (h *Handler) getScanLLDP(w http.ResponseWriter, r *http.Request) { id := scanPathID(r) 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 } limit := 2000 if raw := r.URL.Query().Get("limit"); raw != "" { n, err := strconv.Atoi(raw) if err != nil || n <= 0 || n > 20000 { writeError(w, http.StatusBadRequest, "limit must be between 1 and 20000") return } limit = n } writeJSON(w, http.StatusOK, map[string]any{ "items": h.store.ListLLDPResults(id, limit), }) } func (h *Handler) getScanInterfaces(w http.ResponseWriter, r *http.Request) { id := scanPathID(r) 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) getScanPortDevices(w http.ResponseWriter, r *http.Request) { id := scanPathID(r) 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 } ifIndex := 0 if raw := strings.TrimSpace(r.URL.Query().Get("if_index")); raw != "" { n, err := strconv.Atoi(raw) if err != nil || n < 0 { writeError(w, http.StatusBadRequest, "if_index must be >= 0") return } ifIndex = n } limit := 20000 if raw := r.URL.Query().Get("limit"); raw != "" { n, err := strconv.Atoi(raw) if err != nil || n <= 0 || n > 100000 { writeError(w, http.StatusBadRequest, "limit must be between 1 and 100000") 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.ListPortDeviceResults(id, ip, ifIndex, limit) for i := range items { if lip := strings.TrimSpace(items[i].LearnedIP); lip != "" { if nm, ok := ipToName[lip]; ok { items[i].Hostname = nm } } } writeJSON(w, http.StatusOK, map[string]any{ "items": items, }) } func (h *Handler) getScanMacLocations(w http.ResponseWriter, r *http.Request) { id := scanPathID(r) 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 } 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 { 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 { 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, BridgePort: r.BridgePort, 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 { row.SwitchSysName = nm } if row.ClientIP != "" { if nm, ok := ipToName[row.ClientIP]; ok { row.ClientName = nm } } 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, "access_max_macs": accessMax, "trunk_min_macs": trunkMin, "likely_attachment": likely, "items": out, }) } func (h *Handler) getScanLinks(w http.ResponseWriter, r *http.Request) { id := scanPathID(r) 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 } limit := 2000 if raw := r.URL.Query().Get("limit"); raw != "" { n, err := strconv.Atoi(raw) if err != nil || n <= 0 || n > 20000 { writeError(w, http.StatusBadRequest, "limit must be between 1 and 20000") return } limit = n } snmpItems := h.store.ListSNMPResults(id, 200000) lldpItems := h.store.ListLLDPResults(id, limit) sysNameToIP := make(map[string]string, len(snmpItems)) for _, s := range snmpItems { name := strings.TrimSpace(strings.ToLower(s.SysName)) if name == "" { continue } if _, exists := sysNameToIP[name]; !exists { sysNameToIP[name] = s.IP } } links := make([]LinkView, 0, len(lldpItems)) for _, l := range lldpItems { targetNameNorm := strings.TrimSpace(strings.ToLower(l.RemoteSysName)) targetIP := "" resolvedBy := "unresolved" if targetNameNorm != "" { if ip, ok := sysNameToIP[targetNameNorm]; ok { targetIP = ip resolvedBy = "remote_sys_name" } } links = append(links, LinkView{ SourceIP: l.IP, SourcePort: l.LocalPortNum, TargetIP: targetIP, TargetSysName: l.RemoteSysName, TargetPort: l.RemotePortID, RemoteChassis: l.RemoteChassisID, ResolvedBy: resolvedBy, }) } writeJSON(w, http.StatusOK, map[string]any{ "items": links, }) } func (h *Handler) getScanTopology(w http.ResponseWriter, r *http.Request) { id := scanPathID(r) 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 } limit := 5000 if raw := r.URL.Query().Get("limit"); raw != "" { n, err := strconv.Atoi(raw) if err != nil || n <= 0 || n > 50000 { writeError(w, http.StatusBadRequest, "limit must be between 1 and 50000") return } limit = n } snmpItems := h.store.ListSNMPResults(id, 200000) lldpItems := h.store.ListLLDPResults(id, limit) sysNameToIP := make(map[string]string, len(snmpItems)) nodesByIP := make(map[string]TopologyNode, len(snmpItems)) for _, s := range snmpItems { nameNorm := strings.TrimSpace(strings.ToLower(s.SysName)) if nameNorm != "" { if _, exists := sysNameToIP[nameNorm]; !exists { sysNameToIP[nameNorm] = s.IP } } nodesByIP[s.IP] = TopologyNode{ ID: s.IP, IP: s.IP, Name: s.SysName, } } edges := make([]TopologyEdge, 0, len(lldpItems)) for _, l := range lldpItems { targetIP := "" targetNameNorm := strings.TrimSpace(strings.ToLower(l.RemoteSysName)) if targetNameNorm != "" { targetIP = sysNameToIP[targetNameNorm] } if _, exists := nodesByIP[l.IP]; !exists { nodesByIP[l.IP] = TopologyNode{ID: l.IP, IP: l.IP} } if targetIP != "" { if _, exists := nodesByIP[targetIP]; !exists { nodesByIP[targetIP] = TopologyNode{ID: targetIP, IP: targetIP, Name: l.RemoteSysName} } } edges = append(edges, TopologyEdge{ SourceIP: l.IP, SourcePort: l.LocalPortNum, TargetIP: targetIP, TargetName: l.RemoteSysName, TargetPort: l.RemotePortID, }) } nodes := make([]TopologyNode, 0, len(nodesByIP)) for _, n := range nodesByIP { nodes = append(nodes, n) } writeJSON(w, http.StatusOK, map[string]any{ "nodes": nodes, "edges": edges, }) } func withJSON(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/health" { w.Header().Set("Content-Type", "application/json; charset=utf-8") } next.ServeHTTP(w, r) }) } func hostUpSet(items []scans.HostResult) map[string]struct{} { out := make(map[string]struct{}, len(items)) for _, h := range items { if h.IsUp { out[h.IP] = struct{}{} } } return out } func linkSet(items []scans.LLDPResult) map[string]struct{} { out := make(map[string]struct{}, len(items)) for _, l := range items { key := l.IP + "|" + l.LocalPortNum + "|" + l.RemoteSysName + "|" + l.RemotePortID + "|" + l.RemoteChassisID out[key] = struct{}{} } return out } func setDiff(a, b map[string]struct{}) []string { out := make([]string, 0) for k := range a { if _, ok := b[k]; !ok { out = append(out, k) } } return out } func writeJSON(w http.ResponseWriter, status int, payload any) { w.WriteHeader(status) _ = json.NewEncoder(w).Encode(payload) } func writeError(w http.ResponseWriter, status int, message string) { writeJSON(w, status, map[string]string{"error": message}) }