diff --git a/.env.example b/.env.example index 833e168..872c34b 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,5 @@ HTTP_ADDR=:8080 STORE_BACKEND=memory # DB_DSN=postgres://nettopo:nettopo@localhost:5432/nettopo?sslmode=disable RUN_MIGRATIONS=true +SNMP_ENABLED=true +SNMP_COMMUNITY=public diff --git a/README.md b/README.md index 7eb6bdd..1c1cc95 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ - `GET /api/scans/{id}` - просмотр созданного задания. - `GET /api/scans/{id}/hosts` - результаты проверки хостов по скану. - `GET /api/scans/{id}/ports` - результаты проверки TCP-портов по скану. +- `GET /api/scans/{id}/snmp` - результаты SNMP v2c (`sysName`, `sysDescr`, `sysObjectID`). - Валидация CIDR и exclude IP. - Два backend-хранилища: in-memory и PostgreSQL. - После создания scan запускается фоновый discovery с обновлением статуса и прогресса. @@ -45,6 +46,7 @@ HTTP_ADDR=:8080 go run ./cmd/server ``` По умолчанию используется `STORE_BACKEND=memory`. +SNMP-проверка по умолчанию включена (`SNMP_ENABLED=true`, community `public`). ## PostgreSQL без Docker @@ -144,6 +146,12 @@ curl -s http://localhost:8080/api/scans//hosts?limit=200 curl -s http://localhost:8080/api/scans//ports?limit=500 ``` +Получить SNMP результаты: + +```bash +curl -s http://localhost:8080/api/scans//snmp?limit=500 +``` + ## Запуск как сервис на Linux (systemd) 1. Собрать бинарник: diff --git a/cmd/server/main.go b/cmd/server/main.go index a3c4b8a..e6c5c0b 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -20,7 +20,10 @@ func main() { defer dbClose() } - runner := scans.NewRunner(scanStore) + runner := scans.NewRunner(scanStore, scans.RunnerConfig{ + SNMPEnabled: cfg.SNMPEnabled, + SNMPCommunity: cfg.SNMPCommunity, + }) handler := api.NewHandler(scanStore, runner) server := &http.Server{ diff --git a/go.mod b/go.mod index af8c73d..760d92f 100644 --- a/go.mod +++ b/go.mod @@ -3,3 +3,5 @@ module nettopo-go go 1.22 require github.com/jackc/pgx/v5 v5.7.6 + +require github.com/gosnmp/gosnmp v1.41.0 diff --git a/internal/api/handler.go b/internal/api/handler.go index 16c57f9..0f91111 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -27,6 +27,7 @@ func (h *Handler) Routes() http.Handler { 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) + mux.HandleFunc("GET /api/scans/{id}/snmp", h.getScanSNMP) return withJSON(mux) } @@ -148,6 +149,32 @@ func (h *Handler) getScanPorts(w http.ResponseWriter, r *http.Request) { }) } +func (h *Handler) getScanSNMP(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 := 1000 + if raw := r.URL.Query().Get("limit"); raw != "" { + n, err := strconv.Atoi(raw) + if err != nil || n <= 0 || n > 10000 { + writeError(w, http.StatusBadRequest, "limit must be between 1 and 10000") + return + } + limit = n + } + + writeJSON(w, http.StatusOK, map[string]any{ + "items": h.store.ListSNMPResults(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/config/config.go b/internal/config/config.go index 9443bc8..3c6e52f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,6 +7,8 @@ type Config struct { DBDSN string StoreBackend string RunMigrations bool + SNMPEnabled bool + SNMPCommunity string } func FromEnv() Config { @@ -29,11 +31,21 @@ func FromEnv() Config { if os.Getenv("RUN_MIGRATIONS") == "false" { runMigrations = false } + snmpEnabled := true + if os.Getenv("SNMP_ENABLED") == "false" { + snmpEnabled = false + } + snmpCommunity := os.Getenv("SNMP_COMMUNITY") + if snmpCommunity == "" { + snmpCommunity = "public" + } return Config{ HTTPAddr: addr, DBDSN: dbDSN, StoreBackend: storeBackend, RunMigrations: runMigrations, + SNMPEnabled: snmpEnabled, + SNMPCommunity: snmpCommunity, } } diff --git a/internal/scans/postgres_store.go b/internal/scans/postgres_store.go index 120c72d..b8dd70d 100644 --- a/internal/scans/postgres_store.go +++ b/internal/scans/postgres_store.go @@ -358,3 +358,58 @@ limit $2` } return out } + +func (s *PostgresStore) SaveSNMPResult(scanID string, result SNMPResult) error { + query := ` +insert into scan_snmp (scan_id, ip, success, sys_name, sys_descr, sys_object_id, error_text, checked_at) +values ($1, $2, $3, $4, $5, $6, $7, $8)` + _, err := s.db.ExecContext( + context.Background(), + query, + scanID, + result.IP, + result.Success, + result.SysName, + result.SysDescr, + result.SysObjectID, + result.Error, + result.CheckedAt, + ) + return err +} + +func (s *PostgresStore) ListSNMPResults(scanID string, limit int) []SNMPResult { + if limit <= 0 { + limit = 1000 + } + query := ` +select ip::text, success, coalesce(sys_name, ''), coalesce(sys_descr, ''), coalesce(sys_object_id, ''), + coalesce(error_text, ''), checked_at +from scan_snmp +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([]SNMPResult, 0, limit) + for rows.Next() { + var sres SNMPResult + if err = rows.Scan( + &sres.IP, + &sres.Success, + &sres.SysName, + &sres.SysDescr, + &sres.SysObjectID, + &sres.Error, + &sres.CheckedAt, + ); err != nil { + return nil + } + out = append(out, sres) + } + return out +} diff --git a/internal/scans/runner.go b/internal/scans/runner.go index b89e0f1..5804a3a 100644 --- a/internal/scans/runner.go +++ b/internal/scans/runner.go @@ -1,6 +1,7 @@ package scans import ( + "fmt" "log" "net" "net/netip" @@ -9,14 +10,22 @@ import ( "strconv" "sync" "time" + + "github.com/gosnmp/gosnmp" ) type Runner struct { store Store + cfg RunnerConfig } -func NewRunner(store Store) *Runner { - return &Runner{store: store} +type RunnerConfig struct { + SNMPEnabled bool + SNMPCommunity string +} + +func NewRunner(store Store, cfg RunnerConfig) *Runner { + return &Runner{store: store, cfg: cfg} } func (r *Runner) Start(job ScanJob) { @@ -87,6 +96,9 @@ 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)) + } mu.Lock() done++ @@ -125,6 +137,58 @@ func (r *Runner) run(job ScanJob) { }) } +func probeSNMP(ip, community string, checkedAt time.Time, timeoutMS int) SNMPResult { + if timeoutMS <= 0 { + timeoutMS = 700 + } + if community == "" { + community = "public" + } + + client := &gosnmp.GoSNMP{ + Target: ip, + Port: 161, + Version: gosnmp.Version2c, + Community: community, + Timeout: time.Duration(timeoutMS) * time.Millisecond, + Retries: 1, + } + if err := client.Connect(); err != nil { + return SNMPResult{IP: ip, Success: false, Error: err.Error(), CheckedAt: checkedAt} + } + defer client.Conn.Close() + + oids := []string{ + ".1.3.6.1.2.1.1.5.0", + ".1.3.6.1.2.1.1.1.0", + ".1.3.6.1.2.1.1.2.0", + } + pkt, err := client.Get(oids) + if err != nil { + return SNMPResult{IP: ip, Success: false, Error: err.Error(), CheckedAt: checkedAt} + } + + out := SNMPResult{IP: ip, Success: true, CheckedAt: checkedAt} + for _, vb := range pkt.Variables { + switch vb.Name { + case ".1.3.6.1.2.1.1.5.0": + out.SysName = snmpValueToString(vb.Value) + case ".1.3.6.1.2.1.1.1.0": + out.SysDescr = snmpValueToString(vb.Value) + case ".1.3.6.1.2.1.1.2.0": + out.SysObjectID = snmpValueToString(vb.Value) + } + } + return out +} + +func snmpValueToString(v any) string { + if b, ok := v.([]byte); ok { + return string(b) + } + return fmt.Sprint(v) +} + 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 122eb76..b42e588 100644 --- a/internal/scans/store.go +++ b/internal/scans/store.go @@ -63,6 +63,8 @@ type Store interface { ListHostResults(scanID string, limit int) []HostResult SaveOpenPortResult(scanID string, result OpenPortResult) error ListOpenPortResults(scanID string, limit int) []OpenPortResult + SaveSNMPResult(scanID string, result SNMPResult) error + ListSNMPResults(scanID string, limit int) []SNMPResult } type HostResult struct { @@ -78,11 +80,22 @@ type OpenPortResult struct { CheckedAt time.Time `json:"checked_at"` } +type SNMPResult struct { + IP string `json:"ip"` + Success bool `json:"success"` + SysName string `json:"sys_name"` + SysDescr string `json:"sys_descr"` + SysObjectID string `json:"sys_object_id"` + Error string `json:"error,omitempty"` + 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 } func NewMemoryStore() *MemoryStore { @@ -90,6 +103,7 @@ func NewMemoryStore() *MemoryStore { jobs: make(map[string]ScanJob), hosts: make(map[string][]HostResult), ports: make(map[string][]OpenPortResult), + snmp: make(map[string][]SNMPResult), } } @@ -240,6 +254,35 @@ func (s *MemoryStore) ListOpenPortResults(scanID string, limit int) []OpenPortRe return out } +func (s *MemoryStore) SaveSNMPResult(scanID string, result SNMPResult) error { + s.mu.Lock() + defer s.mu.Unlock() + + if _, ok := s.jobs[scanID]; !ok { + return errors.New("scan not found") + } + s.snmp[scanID] = append(s.snmp[scanID], result) + return nil +} + +func (s *MemoryStore) ListSNMPResults(scanID string, limit int) []SNMPResult { + if limit <= 0 { + limit = 1000 + } + s.mu.RLock() + defer s.mu.RUnlock() + + items := s.snmp[scanID] + if len(items) <= limit { + out := make([]SNMPResult, len(items)) + copy(out, items) + return out + } + out := make([]SNMPResult, 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 47d91a9..cff9738 100644 --- a/internal/store/sql/001_init.sql +++ b/internal/store/sql/001_init.sql @@ -40,3 +40,18 @@ create table if not exists scan_ports ( create index if not exists idx_scan_ports_scan_id_checked_at on scan_ports (scan_id, checked_at desc); + +create table if not exists scan_snmp ( + id bigserial primary key, + scan_id text not null references scan_jobs(id) on delete cascade, + ip inet not null, + success boolean not null, + sys_name text null, + sys_descr text null, + sys_object_id text null, + error_text text null, + checked_at timestamptz not null +); + +create index if not exists idx_scan_snmp_scan_id_checked_at + on scan_snmp (scan_id, checked_at desc);