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
This commit is contained in:
@@ -13,6 +13,7 @@
|
|||||||
- `GET /api/scans/{id}/snmp` - результаты SNMP v2c (`sysName`, `sysDescr`, `sysObjectID`).
|
- `GET /api/scans/{id}/snmp` - результаты SNMP v2c (`sysName`, `sysDescr`, `sysObjectID`).
|
||||||
- `GET /api/scans/{id}/lldp` - LLDP соседи (если устройство отдает LLDP-MIB по SNMP).
|
- `GET /api/scans/{id}/lldp` - LLDP соседи (если устройство отдает LLDP-MIB по SNMP).
|
||||||
- `GET /api/scans/{id}/links` - базовые связи из LLDP с попыткой сопоставления по `remote_sys_name`.
|
- `GET /api/scans/{id}/links` - базовые связи из LLDP с попыткой сопоставления по `remote_sys_name`.
|
||||||
|
- `GET /api/scans/{id}/topology` - граф `nodes/edges` для визуализации.
|
||||||
- Валидация CIDR и exclude IP.
|
- Валидация CIDR и exclude IP.
|
||||||
- Два backend-хранилища: in-memory и PostgreSQL.
|
- Два backend-хранилища: in-memory и PostgreSQL.
|
||||||
- После создания scan запускается фоновый discovery с обновлением статуса и прогресса.
|
- После создания scan запускается фоновый discovery с обновлением статуса и прогресса.
|
||||||
@@ -166,6 +167,12 @@ curl -s http://localhost:8080/api/scans/<scan_id>/lldp?limit=1000
|
|||||||
curl -s http://localhost:8080/api/scans/<scan_id>/links?limit=1000
|
curl -s http://localhost:8080/api/scans/<scan_id>/links?limit=1000
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Получить topology (nodes/edges):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://localhost:8080/api/scans/<scan_id>/topology?limit=2000
|
||||||
|
```
|
||||||
|
|
||||||
## Запуск как сервис на Linux (systemd)
|
## Запуск как сервис на Linux (systemd)
|
||||||
|
|
||||||
1. Собрать бинарник:
|
1. Собрать бинарник:
|
||||||
|
|||||||
+30
-29
@@ -18,14 +18,14 @@
|
|||||||
|
|
||||||
## Функциональные требования v1
|
## Функциональные требования v1
|
||||||
|
|
||||||
- ручной запуск скана по заданным CIDR;
|
- [x] ручной запуск скана по заданным CIDR;
|
||||||
- ping sweep по диапазонам;
|
- [x] ping sweep по диапазонам;
|
||||||
- проверка базовых TCP-портов (`22, 80, 443, 161`);
|
- [x] проверка базовых TCP-портов (`22, 80, 443, 161`);
|
||||||
- SNMP v2c: `sysName`, `sysDescr`, `sysObjectID`;
|
- [x] SNMP v2c: `sysName`, `sysDescr`, `sysObjectID`;
|
||||||
- сбор LLDP соседей по SNMP;
|
- [x] сбор LLDP соседей по SNMP;
|
||||||
- сохранение результатов скана в БД;
|
- [x] сохранение результатов скана в БД;
|
||||||
- построение графа связей устройств;
|
- [x] построение базового графа связей устройств (`/links`, `/topology`);
|
||||||
- история сканов и сравнение изменений.
|
- [ ] история сканов и сравнение изменений.
|
||||||
|
|
||||||
## Нефункциональные требования
|
## Нефункциональные требования
|
||||||
|
|
||||||
@@ -46,18 +46,19 @@
|
|||||||
|
|
||||||
## Текущее состояние реализации
|
## Текущее состояние реализации
|
||||||
|
|
||||||
- готов API-каркас (`health`, создание и чтение scan jobs);
|
- [x] готов API-каркас (`health`, создание и чтение scan jobs);
|
||||||
- реализован выбор хранилища: `memory` или `postgres` через env;
|
- [x] реализован выбор хранилища: `memory` или `postgres` через env;
|
||||||
- добавлена автоматическая миграция таблицы `scan_jobs` при старте;
|
- [x] добавлены автоматические миграции таблиц при старте;
|
||||||
- подготовлены конфиги для запуска как сервис на Linux и за IIS на Windows.
|
- [x] добавлены endpoints результатов: hosts, ports, snmp, lldp, links, topology;
|
||||||
|
- [x] подготовлены конфиги для запуска как сервис на Linux и за IIS на Windows.
|
||||||
|
|
||||||
## Минимальные API
|
## Минимальные API
|
||||||
|
|
||||||
- `GET /health`
|
- [x] `GET /health`
|
||||||
- `POST /api/scans`
|
- [x] `POST /api/scans`
|
||||||
- `GET /api/scans/{id}`
|
- [x] `GET /api/scans/{id}`
|
||||||
- `GET /api/topology`
|
- [x] `GET /api/scans/{id}/topology`
|
||||||
- `GET /api/topology/diff?from=&to=`
|
- [ ] `GET /api/topology/diff?from=&to=`
|
||||||
|
|
||||||
## Модель данных (минимум)
|
## Модель данных (минимум)
|
||||||
|
|
||||||
@@ -80,18 +81,18 @@
|
|||||||
|
|
||||||
## Этапы
|
## Этапы
|
||||||
|
|
||||||
1. Каркас API и scan jobs
|
1. [x] Каркас API и scan jobs
|
||||||
2. PostgreSQL и миграции
|
2. [x] PostgreSQL и миграции
|
||||||
3. Ping + port scan
|
3. [x] Ping + port scan
|
||||||
4. SNMP system + LLDP
|
4. [x] SNMP system + LLDP
|
||||||
5. Граф топологии в UI
|
5. [ ] Граф топологии в UI
|
||||||
6. История и diff
|
6. [ ] История и diff
|
||||||
|
|
||||||
## Критерии приемки v1
|
## Критерии приемки v1
|
||||||
|
|
||||||
- скан по CIDR создается и завершается;
|
- [x] скан по CIDR создается и завершается;
|
||||||
- живые узлы фиксируются в результатах;
|
- [x] живые узлы фиксируются в результатах;
|
||||||
- SNMP-доступные устройства читаются;
|
- [x] SNMP-доступные устройства читаются;
|
||||||
- LLDP-связи строятся минимум на тестовой сети;
|
- [x] LLDP-связи строятся минимум на тестовой сети;
|
||||||
- топология отображается в интерфейсе;
|
- [ ] топология отображается в интерфейсе;
|
||||||
- приложение запускается на Linux и Windows.
|
- [ ] приложение запускается на Linux и Windows (нужна ваша проверка на 2 ОС).
|
||||||
|
|||||||
@@ -25,6 +25,20 @@ type LinkView struct {
|
|||||||
ResolvedBy string `json:"resolved_by"`
|
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 {
|
func NewHandler(store scans.Store, run *scans.Runner) *Handler {
|
||||||
return &Handler{store: store, run: run}
|
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}/snmp", h.getScanSNMP)
|
||||||
mux.HandleFunc("GET /api/scans/{id}/lldp", h.getScanLLDP)
|
mux.HandleFunc("GET /api/scans/{id}/lldp", h.getScanLLDP)
|
||||||
mux.HandleFunc("GET /api/scans/{id}/links", h.getScanLinks)
|
mux.HandleFunc("GET /api/scans/{id}/links", h.getScanLinks)
|
||||||
|
mux.HandleFunc("GET /api/scans/{id}/topology", h.getScanTopology)
|
||||||
return withJSON(mux)
|
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 {
|
func withJSON(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
|||||||
Reference in New Issue
Block a user