package api import ( "encoding/json" "net/http" "strconv" "strings" "time" "nettopo-go/internal/scans" ) type Handler struct { store scans.Store run *scans.Runner } 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"` } func NewHandler(store scans.Store, run *scans.Runner) *Handler { return &Handler{store: store, run: run} } func (h *Handler) Routes() http.Handler { mux := http.NewServeMux() 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/{id}", h.getScan) 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}/links", h.getScanLinks) mux.HandleFunc("GET /api/scans/{id}/topology", h.getScanTopology) return withJSON(mux) } 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 } writeJSON(w, http.StatusCreated, map[string]any{ "scan_id": job.ID, "status": job.Status, }) if h.run != nil { h.run.Start(job) } } 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) getScan(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if id == "" { writeError(w, http.StatusBadRequest, "scan id is required") return } job, ok := h.store.GetScan(id) if !ok { writeError(w, http.StatusNotFound, "scan not found") return } writeJSON(w, http.StatusOK, job) } func (h *Handler) getScanHosts(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 } limit := 500 if raw := r.URL.Query().Get("limit"); raw != "" { n, err := strconv.Atoi(raw) if err != nil || n <= 0 || n > 5000 { writeError(w, http.StatusBadRequest, "limit must be between 1 and 5000") 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 := 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 } 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 := 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 } 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.ListSNMPResults(id, limit), }) } func (h *Handler) getScanLLDP(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 } 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) getScanLinks(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 } 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, 10000) 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 := 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 } 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, 10000) 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) { w.Header().Set("Content-Type", "application/json; charset=utf-8") next.ServeHTTP(w, r) }) } 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}) }