Files
nettopo-go/internal/api/handler.go
T
Andrey Lutsenko ab94409a52 Add topology endpoint and checklist tracking in spec.
Expose nodes/edges topology view and convert technical spec progress items to checkbox status.

Made-with: Cursor
2026-04-09 22:02:33 +10:00

385 lines
9.5 KiB
Go

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})
}