diff --git a/README.md b/README.md index 6ab970e..a86711d 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ - `GET /api/scans/{id}/snmp` - результаты SNMP v2c (`sysName`, `sysDescr`, `sysObjectID`). - `GET /api/scans/{id}/lldp` - LLDP соседи (если устройство отдает LLDP-MIB по SNMP). - `GET /api/scans/{id}/links` - базовые связи из LLDP с попыткой сопоставления по `remote_sys_name`. +- `GET /api/scans/{id}/topology` - граф `nodes/edges` для визуализации. - Валидация CIDR и exclude IP. - Два backend-хранилища: in-memory и PostgreSQL. - После создания scan запускается фоновый discovery с обновлением статуса и прогресса. @@ -166,6 +167,12 @@ curl -s http://localhost:8080/api/scans//lldp?limit=1000 curl -s http://localhost:8080/api/scans//links?limit=1000 ``` +Получить topology (nodes/edges): + +```bash +curl -s http://localhost:8080/api/scans//topology?limit=2000 +``` + ## Запуск как сервис на Linux (systemd) 1. Собрать бинарник: diff --git a/TECH_SPEC.md b/TECH_SPEC.md index 5fbbd01..b5bd4cb 100644 --- a/TECH_SPEC.md +++ b/TECH_SPEC.md @@ -18,14 +18,14 @@ ## Функциональные требования v1 -- ручной запуск скана по заданным CIDR; -- ping sweep по диапазонам; -- проверка базовых TCP-портов (`22, 80, 443, 161`); -- SNMP v2c: `sysName`, `sysDescr`, `sysObjectID`; -- сбор LLDP соседей по SNMP; -- сохранение результатов скана в БД; -- построение графа связей устройств; -- история сканов и сравнение изменений. +- [x] ручной запуск скана по заданным CIDR; +- [x] ping sweep по диапазонам; +- [x] проверка базовых TCP-портов (`22, 80, 443, 161`); +- [x] SNMP v2c: `sysName`, `sysDescr`, `sysObjectID`; +- [x] сбор LLDP соседей по SNMP; +- [x] сохранение результатов скана в БД; +- [x] построение базового графа связей устройств (`/links`, `/topology`); +- [ ] история сканов и сравнение изменений. ## Нефункциональные требования @@ -46,18 +46,19 @@ ## Текущее состояние реализации -- готов API-каркас (`health`, создание и чтение scan jobs); -- реализован выбор хранилища: `memory` или `postgres` через env; -- добавлена автоматическая миграция таблицы `scan_jobs` при старте; -- подготовлены конфиги для запуска как сервис на Linux и за IIS на Windows. +- [x] готов API-каркас (`health`, создание и чтение scan jobs); +- [x] реализован выбор хранилища: `memory` или `postgres` через env; +- [x] добавлены автоматические миграции таблиц при старте; +- [x] добавлены endpoints результатов: hosts, ports, snmp, lldp, links, topology; +- [x] подготовлены конфиги для запуска как сервис на Linux и за IIS на Windows. ## Минимальные API -- `GET /health` -- `POST /api/scans` -- `GET /api/scans/{id}` -- `GET /api/topology` -- `GET /api/topology/diff?from=&to=` +- [x] `GET /health` +- [x] `POST /api/scans` +- [x] `GET /api/scans/{id}` +- [x] `GET /api/scans/{id}/topology` +- [ ] `GET /api/topology/diff?from=&to=` ## Модель данных (минимум) @@ -80,18 +81,18 @@ ## Этапы -1. Каркас API и scan jobs -2. PostgreSQL и миграции -3. Ping + port scan -4. SNMP system + LLDP -5. Граф топологии в UI -6. История и diff +1. [x] Каркас API и scan jobs +2. [x] PostgreSQL и миграции +3. [x] Ping + port scan +4. [x] SNMP system + LLDP +5. [ ] Граф топологии в UI +6. [ ] История и diff ## Критерии приемки v1 -- скан по CIDR создается и завершается; -- живые узлы фиксируются в результатах; -- SNMP-доступные устройства читаются; -- LLDP-связи строятся минимум на тестовой сети; -- топология отображается в интерфейсе; -- приложение запускается на Linux и Windows. +- [x] скан по CIDR создается и завершается; +- [x] живые узлы фиксируются в результатах; +- [x] SNMP-доступные устройства читаются; +- [x] LLDP-связи строятся минимум на тестовой сети; +- [ ] топология отображается в интерфейсе; +- [ ] приложение запускается на Linux и Windows (нужна ваша проверка на 2 ОС). diff --git a/internal/api/handler.go b/internal/api/handler.go index cc459c0..3aea0ee 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -25,6 +25,20 @@ type LinkView struct { 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} } @@ -40,6 +54,7 @@ func (h *Handler) Routes() http.Handler { 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) } @@ -276,6 +291,82 @@ func (h *Handler) getScanLinks(w http.ResponseWriter, r *http.Request) { }) } +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")