From df32d43ac5fbcc84ec847f9777d84289c7889ad3 Mon Sep 17 00:00:00 2001 From: Andrey Lutsenko Date: Thu, 9 Apr 2026 22:05:09 +1000 Subject: [PATCH] Add scan diff endpoint and update spec checklist. Compare two scans by alive hosts and LLDP-derived links, then reflect completed diff milestone in technical spec. Made-with: Cursor --- README.md | 7 ++++ TECH_SPEC.md | 6 ++-- internal/api/handler.go | 71 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a86711d..c004fab 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ - `POST /api/scans` - создание задания скана. - `GET /api/scans` - список сканов. - `GET /api/scans/{id}` - просмотр созданного задания. +- `GET /api/scans/diff?from=&to=` - сравнение двух сканов (hosts/links). - `GET /api/scans/{id}/hosts` - результаты проверки хостов по скану. - `GET /api/scans/{id}/ports` - результаты проверки TCP-портов по скану. - `GET /api/scans/{id}/snmp` - результаты SNMP v2c (`sysName`, `sysDescr`, `sysObjectID`). @@ -131,6 +132,12 @@ curl -s http://localhost:8080/api/scans/ curl -s http://localhost:8080/api/scans?limit=20 ``` +Сравнить два scan: + +```bash +curl -s "http://localhost:8080/api/scans/diff?from=&to=" +``` + В ответе у scan есть: - `status`: `queued`, `running`, `done`, `failed` diff --git a/TECH_SPEC.md b/TECH_SPEC.md index b5bd4cb..bad3ce6 100644 --- a/TECH_SPEC.md +++ b/TECH_SPEC.md @@ -25,7 +25,7 @@ - [x] сбор LLDP соседей по SNMP; - [x] сохранение результатов скана в БД; - [x] построение базового графа связей устройств (`/links`, `/topology`); -- [ ] история сканов и сравнение изменений. +- [x] история сканов и сравнение изменений (`/api/scans/diff?from=&to=`). ## Нефункциональные требования @@ -58,7 +58,7 @@ - [x] `POST /api/scans` - [x] `GET /api/scans/{id}` - [x] `GET /api/scans/{id}/topology` -- [ ] `GET /api/topology/diff?from=&to=` +- [x] `GET /api/scans/diff?from=&to=` ## Модель данных (минимум) @@ -86,7 +86,7 @@ 3. [x] Ping + port scan 4. [x] SNMP system + LLDP 5. [ ] Граф топологии в UI -6. [ ] История и diff +6. [x] История и diff ## Критерии приемки v1 diff --git a/internal/api/handler.go b/internal/api/handler.go index 3aea0ee..9b6d101 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -39,6 +39,15 @@ type TopologyEdge struct { 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"` +} + func NewHandler(store scans.Store, run *scans.Runner) *Handler { return &Handler{store: store, run: run} } @@ -48,6 +57,7 @@ func (h *Handler) Routes() http.Handler { 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("GET /api/scans/{id}/hosts", h.getScanHosts) mux.HandleFunc("GET /api/scans/{id}/ports", h.getScanPorts) @@ -108,6 +118,38 @@ func (h *Handler) listScans(w http.ResponseWriter, r *http.Request) { }) } +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 := r.PathValue("id") if id == "" { @@ -374,6 +416,35 @@ func withJSON(next http.Handler) http.Handler { }) } +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)