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
This commit is contained in:
Andrey Lutsenko
2026-04-09 22:00:14 +10:00
parent ec7abe384d
commit 9e678fcfc7
2 changed files with 81 additions and 0 deletions
+7
View File
@@ -12,6 +12,7 @@
- `GET /api/scans/{id}/ports` - результаты проверки TCP-портов по скану. - `GET /api/scans/{id}/ports` - результаты проверки TCP-портов по скану.
- `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`.
- Валидация CIDR и exclude IP. - Валидация CIDR и exclude IP.
- Два backend-хранилища: in-memory и PostgreSQL. - Два backend-хранилища: in-memory и PostgreSQL.
- После создания scan запускается фоновый discovery с обновлением статуса и прогресса. - После создания scan запускается фоновый discovery с обновлением статуса и прогресса.
@@ -159,6 +160,12 @@ curl -s http://localhost:8080/api/scans/<scan_id>/snmp?limit=500
curl -s http://localhost:8080/api/scans/<scan_id>/lldp?limit=1000 curl -s http://localhost:8080/api/scans/<scan_id>/lldp?limit=1000
``` ```
Получить связи:
```bash
curl -s http://localhost:8080/api/scans/<scan_id>/links?limit=1000
```
## Запуск как сервис на Linux (systemd) ## Запуск как сервис на Linux (systemd)
1. Собрать бинарник: 1. Собрать бинарник:
+74
View File
@@ -15,6 +15,16 @@ type Handler struct {
run *scans.Runner 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 { func NewHandler(store scans.Store, run *scans.Runner) *Handler {
return &Handler{store: store, run: run} return &Handler{store: store, run: run}
} }
@@ -29,6 +39,7 @@ func (h *Handler) Routes() http.Handler {
mux.HandleFunc("GET /api/scans/{id}/ports", h.getScanPorts) mux.HandleFunc("GET /api/scans/{id}/ports", h.getScanPorts)
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)
return withJSON(mux) return withJSON(mux)
} }
@@ -202,6 +213,69 @@ func (h *Handler) getScanLLDP(w http.ResponseWriter, r *http.Request) {
}) })
} }
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 { 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")