From ec7abe384dbc961547c1716926f30f0aa136b648 Mon Sep 17 00:00:00 2001 From: Andrey Lutsenko Date: Thu, 9 Apr 2026 21:58:30 +1000 Subject: [PATCH] Add LLDP neighbor collection and endpoint. Collect LLDP-MIB neighbor fields during SNMP scans, persist records, and expose LLDP results via REST. Made-with: Cursor --- README.md | 7 +++ internal/api/handler.go | 27 ++++++++++++ internal/scans/postgres_store.go | 53 +++++++++++++++++++++++ internal/scans/runner.go | 74 +++++++++++++++++++++++++++++--- internal/scans/store.go | 42 ++++++++++++++++++ internal/store/sql/001_init.sql | 14 ++++++ 6 files changed, 212 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 1c1cc95..2081f9d 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ - `GET /api/scans/{id}/hosts` - результаты проверки хостов по скану. - `GET /api/scans/{id}/ports` - результаты проверки TCP-портов по скану. - `GET /api/scans/{id}/snmp` - результаты SNMP v2c (`sysName`, `sysDescr`, `sysObjectID`). +- `GET /api/scans/{id}/lldp` - LLDP соседи (если устройство отдает LLDP-MIB по SNMP). - Валидация CIDR и exclude IP. - Два backend-хранилища: in-memory и PostgreSQL. - После создания scan запускается фоновый discovery с обновлением статуса и прогресса. @@ -152,6 +153,12 @@ curl -s http://localhost:8080/api/scans//ports?limit=500 curl -s http://localhost:8080/api/scans//snmp?limit=500 ``` +Получить LLDP результаты: + +```bash +curl -s http://localhost:8080/api/scans//lldp?limit=1000 +``` + ## Запуск как сервис на Linux (systemd) 1. Собрать бинарник: diff --git a/internal/api/handler.go b/internal/api/handler.go index 0f91111..6142b31 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -28,6 +28,7 @@ func (h *Handler) Routes() http.Handler { 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) return withJSON(mux) } @@ -175,6 +176,32 @@ func (h *Handler) getScanSNMP(w http.ResponseWriter, r *http.Request) { }) } +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 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") diff --git a/internal/scans/postgres_store.go b/internal/scans/postgres_store.go index b8dd70d..8d80bf5 100644 --- a/internal/scans/postgres_store.go +++ b/internal/scans/postgres_store.go @@ -413,3 +413,56 @@ limit $2` } return out } + +func (s *PostgresStore) SaveLLDPResult(scanID string, result LLDPResult) error { + query := ` +insert into scan_lldp (scan_id, ip, local_port_num, remote_chassis_id, remote_port_id, remote_sys_name, checked_at) +values ($1, $2, $3, $4, $5, $6, $7)` + _, err := s.db.ExecContext( + context.Background(), + query, + scanID, + result.IP, + result.LocalPortNum, + result.RemoteChassisID, + result.RemotePortID, + result.RemoteSysName, + result.CheckedAt, + ) + return err +} + +func (s *PostgresStore) ListLLDPResults(scanID string, limit int) []LLDPResult { + if limit <= 0 { + limit = 2000 + } + query := ` +select ip::text, coalesce(local_port_num, ''), coalesce(remote_chassis_id, ''), + coalesce(remote_port_id, ''), coalesce(remote_sys_name, ''), checked_at +from scan_lldp +where scan_id = $1 +order by checked_at desc +limit $2` + rows, err := s.db.QueryContext(context.Background(), query, scanID, limit) + if err != nil { + return nil + } + defer rows.Close() + + out := make([]LLDPResult, 0, limit) + for rows.Next() { + var lr LLDPResult + if err = rows.Scan( + &lr.IP, + &lr.LocalPortNum, + &lr.RemoteChassisID, + &lr.RemotePortID, + &lr.RemoteSysName, + &lr.CheckedAt, + ); err != nil { + return nil + } + out = append(out, lr) + } + return out +} diff --git a/internal/scans/runner.go b/internal/scans/runner.go index 5804a3a..819e7f5 100644 --- a/internal/scans/runner.go +++ b/internal/scans/runner.go @@ -7,6 +7,7 @@ import ( "net/netip" "os/exec" "runtime" + "strings" "strconv" "sync" "time" @@ -97,7 +98,11 @@ func (r *Runner) run(job ScanJob) { } } if isUp && r.cfg.SNMPEnabled { - _ = r.store.SaveSNMPResult(job.ID, probeSNMP(ip, r.cfg.SNMPCommunity, checkedAt, job.Options.PingTimeoutMS)) + snmpRes, lldpRes := probeSNMPAndLLDP(ip, r.cfg.SNMPCommunity, checkedAt, job.Options.PingTimeoutMS) + _ = r.store.SaveSNMPResult(job.ID, snmpRes) + for _, lr := range lldpRes { + _ = r.store.SaveLLDPResult(job.ID, lr) + } } mu.Lock() @@ -137,7 +142,7 @@ func (r *Runner) run(job ScanJob) { }) } -func probeSNMP(ip, community string, checkedAt time.Time, timeoutMS int) SNMPResult { +func probeSNMPAndLLDP(ip, community string, checkedAt time.Time, timeoutMS int) (SNMPResult, []LLDPResult) { if timeoutMS <= 0 { timeoutMS = 700 } @@ -154,7 +159,7 @@ func probeSNMP(ip, community string, checkedAt time.Time, timeoutMS int) SNMPRes Retries: 1, } if err := client.Connect(); err != nil { - return SNMPResult{IP: ip, Success: false, Error: err.Error(), CheckedAt: checkedAt} + return SNMPResult{IP: ip, Success: false, Error: err.Error(), CheckedAt: checkedAt}, nil } defer client.Conn.Close() @@ -165,7 +170,7 @@ func probeSNMP(ip, community string, checkedAt time.Time, timeoutMS int) SNMPRes } pkt, err := client.Get(oids) if err != nil { - return SNMPResult{IP: ip, Success: false, Error: err.Error(), CheckedAt: checkedAt} + return SNMPResult{IP: ip, Success: false, Error: err.Error(), CheckedAt: checkedAt}, nil } out := SNMPResult{IP: ip, Success: true, CheckedAt: checkedAt} @@ -179,7 +184,7 @@ func probeSNMP(ip, community string, checkedAt time.Time, timeoutMS int) SNMPRes out.SysObjectID = snmpValueToString(vb.Value) } } - return out + return out, probeLLDP(client, ip, checkedAt) } func snmpValueToString(v any) string { @@ -189,6 +194,65 @@ func snmpValueToString(v any) string { return fmt.Sprint(v) } +func probeLLDP(client *gosnmp.GoSNMP, ip string, checkedAt time.Time) []LLDPResult { + const ( + lldpRemLocalPortNum = ".1.0.8802.1.1.2.1.4.1.1.2" + lldpRemChassisID = ".1.0.8802.1.1.2.1.4.1.1.5" + lldpRemPortID = ".1.0.8802.1.1.2.1.4.1.1.7" + lldpRemSysName = ".1.0.8802.1.1.2.1.4.1.1.9" + ) + + ports := walkAsMap(client, lldpRemLocalPortNum) + chassis := walkAsMap(client, lldpRemChassisID) + remotePorts := walkAsMap(client, lldpRemPortID) + sysNames := walkAsMap(client, lldpRemSysName) + + keys := make(map[string]struct{}) + for k := range ports { + keys[k] = struct{}{} + } + for k := range chassis { + keys[k] = struct{}{} + } + for k := range remotePorts { + keys[k] = struct{}{} + } + for k := range sysNames { + keys[k] = struct{}{} + } + + out := make([]LLDPResult, 0, len(keys)) + for k := range keys { + item := LLDPResult{ + IP: ip, + LocalPortNum: ports[k], + RemoteChassisID: chassis[k], + RemotePortID: remotePorts[k], + RemoteSysName: sysNames[k], + CheckedAt: checkedAt, + } + if item.LocalPortNum == "" && item.RemoteChassisID == "" && item.RemotePortID == "" && item.RemoteSysName == "" { + continue + } + out = append(out, item) + } + return out +} + +func walkAsMap(client *gosnmp.GoSNMP, oid string) map[string]string { + out := map[string]string{} + pdus, err := client.BulkWalkAll(oid) + if err != nil { + return out + } + prefix := oid + "." + for _, p := range pdus { + key := strings.TrimPrefix(p.Name, prefix) + out[key] = snmpValueToString(p.Value) + } + return out +} + func expandTargets(cidrs []string, excludeIPs []string) ([]string, error) { excluded := make(map[string]struct{}, len(excludeIPs)) for _, ip := range excludeIPs { diff --git a/internal/scans/store.go b/internal/scans/store.go index b42e588..306e07e 100644 --- a/internal/scans/store.go +++ b/internal/scans/store.go @@ -65,6 +65,8 @@ type Store interface { ListOpenPortResults(scanID string, limit int) []OpenPortResult SaveSNMPResult(scanID string, result SNMPResult) error ListSNMPResults(scanID string, limit int) []SNMPResult + SaveLLDPResult(scanID string, result LLDPResult) error + ListLLDPResults(scanID string, limit int) []LLDPResult } type HostResult struct { @@ -90,12 +92,22 @@ type SNMPResult struct { CheckedAt time.Time `json:"checked_at"` } +type LLDPResult struct { + IP string `json:"ip"` + LocalPortNum string `json:"local_port_num"` + RemoteChassisID string `json:"remote_chassis_id"` + RemotePortID string `json:"remote_port_id"` + RemoteSysName string `json:"remote_sys_name"` + CheckedAt time.Time `json:"checked_at"` +} + type MemoryStore struct { mu sync.RWMutex jobs map[string]ScanJob hosts map[string][]HostResult ports map[string][]OpenPortResult snmp map[string][]SNMPResult + lldp map[string][]LLDPResult } func NewMemoryStore() *MemoryStore { @@ -104,6 +116,7 @@ func NewMemoryStore() *MemoryStore { hosts: make(map[string][]HostResult), ports: make(map[string][]OpenPortResult), snmp: make(map[string][]SNMPResult), + lldp: make(map[string][]LLDPResult), } } @@ -283,6 +296,35 @@ func (s *MemoryStore) ListSNMPResults(scanID string, limit int) []SNMPResult { return out } +func (s *MemoryStore) SaveLLDPResult(scanID string, result LLDPResult) error { + s.mu.Lock() + defer s.mu.Unlock() + + if _, ok := s.jobs[scanID]; !ok { + return errors.New("scan not found") + } + s.lldp[scanID] = append(s.lldp[scanID], result) + return nil +} + +func (s *MemoryStore) ListLLDPResults(scanID string, limit int) []LLDPResult { + if limit <= 0 { + limit = 2000 + } + s.mu.RLock() + defer s.mu.RUnlock() + + items := s.lldp[scanID] + if len(items) <= limit { + out := make([]LLDPResult, len(items)) + copy(out, items) + return out + } + out := make([]LLDPResult, limit) + copy(out, items[:limit]) + return out +} + func applyDefaultOptions(opts *ScanOptions) { if opts.PingTimeoutMS <= 0 { opts.PingTimeoutMS = 700 diff --git a/internal/store/sql/001_init.sql b/internal/store/sql/001_init.sql index cff9738..4f63068 100644 --- a/internal/store/sql/001_init.sql +++ b/internal/store/sql/001_init.sql @@ -55,3 +55,17 @@ create table if not exists scan_snmp ( create index if not exists idx_scan_snmp_scan_id_checked_at on scan_snmp (scan_id, checked_at desc); + +create table if not exists scan_lldp ( + id bigserial primary key, + scan_id text not null references scan_jobs(id) on delete cascade, + ip inet not null, + local_port_num text null, + remote_chassis_id text null, + remote_port_id text null, + remote_sys_name text null, + checked_at timestamptz not null +); + +create index if not exists idx_scan_lldp_scan_id_checked_at + on scan_lldp (scan_id, checked_at desc);