Files
nettopo-go/internal/api/handler.go
T
Andrey Lutsenko 9e678fcfc7 Add derived links endpoint from LLDP and SNMP data.
Build link views from LLDP neighbors and resolve targets by matching remote system names to SNMP sysName entries.

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

294 lines
7.2 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"`
}
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)
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 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})
}