From 9afbbbb76260e18d320c13916d43568e232bca25 Mon Sep 17 00:00:00 2001 From: Andrey Lutsenko Date: Thu, 9 Apr 2026 21:51:35 +1000 Subject: [PATCH] Persist per-host scan results and expose hosts endpoint. Store checked hosts for each scan and add API to fetch host-level up/down probe results. Made-with: Cursor --- README.md | 7 +++++ internal/api/handler.go | 27 +++++++++++++++++++ internal/scans/postgres_store.go | 35 +++++++++++++++++++++++++ internal/scans/runner.go | 5 ++++ internal/scans/store.go | 45 +++++++++++++++++++++++++++++--- internal/store/sql/001_init.sql | 11 ++++++++ 6 files changed, 127 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index cf860d8..ee12e76 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ - `POST /api/scans` - создание задания скана. - `GET /api/scans` - список сканов. - `GET /api/scans/{id}` - просмотр созданного задания. +- `GET /api/scans/{id}/hosts` - результаты проверки хостов по скану. - Валидация CIDR и exclude IP. - Два backend-хранилища: in-memory и PostgreSQL. - После создания scan запускается фоновый discovery с обновлением статуса и прогресса. @@ -130,6 +131,12 @@ curl -s http://localhost:8080/api/scans?limit=20 - `progress`: 0..100 - `stats.hosts_total`, `stats.hosts_up` +Получить результаты по хостам: + +```bash +curl -s http://localhost:8080/api/scans//hosts?limit=200 +``` + ## Запуск как сервис на Linux (systemd) 1. Собрать бинарник: diff --git a/internal/api/handler.go b/internal/api/handler.go index c0cf86f..41334de 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -25,6 +25,7 @@ func (h *Handler) Routes() http.Handler { mux.HandleFunc("POST /api/scans", h.createScan) mux.HandleFunc("GET /api/scans", h.listScans) mux.HandleFunc("GET /api/scans/{id}", h.getScan) + mux.HandleFunc("GET /api/scans/{id}/hosts", h.getScanHosts) return withJSON(mux) } @@ -94,6 +95,32 @@ func (h *Handler) getScan(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, job) } +func (h *Handler) getScanHosts(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 := 500 + if raw := r.URL.Query().Get("limit"); raw != "" { + n, err := strconv.Atoi(raw) + if err != nil || n <= 0 || n > 5000 { + writeError(w, http.StatusBadRequest, "limit must be between 1 and 5000") + return + } + limit = n + } + + writeJSON(w, http.StatusOK, map[string]any{ + "items": h.store.ListHostResults(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 e3430e0..0335108 100644 --- a/internal/scans/postgres_store.go +++ b/internal/scans/postgres_store.go @@ -280,3 +280,38 @@ where id = $1` return current, true } + +func (s *PostgresStore) SaveHostResult(scanID string, host HostResult) error { + query := ` +insert into scan_hosts (scan_id, ip, is_up, checked_at) +values ($1, $2, $3, $4)` + _, err := s.db.ExecContext(context.Background(), query, scanID, host.IP, host.IsUp, host.CheckedAt) + return err +} + +func (s *PostgresStore) ListHostResults(scanID string, limit int) []HostResult { + if limit <= 0 { + limit = 500 + } + query := ` +select ip::text, is_up, checked_at +from scan_hosts +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([]HostResult, 0, limit) + for rows.Next() { + var h HostResult + if err = rows.Scan(&h.IP, &h.IsUp, &h.CheckedAt); err != nil { + return nil + } + out = append(out, h) + } + return out +} diff --git a/internal/scans/runner.go b/internal/scans/runner.go index 14b9387..0beacd0 100644 --- a/internal/scans/runner.go +++ b/internal/scans/runner.go @@ -70,6 +70,11 @@ func (r *Runner) run(job ScanJob) { defer wg.Done() for ip := range workCh { isUp := probeHost(ip, job.Options.PingTimeoutMS) + _ = r.store.SaveHostResult(job.ID, HostResult{ + IP: ip, + IsUp: isUp, + CheckedAt: time.Now().UTC(), + }) mu.Lock() done++ diff --git a/internal/scans/store.go b/internal/scans/store.go index f02c73b..d132852 100644 --- a/internal/scans/store.go +++ b/internal/scans/store.go @@ -59,16 +59,26 @@ type Store interface { GetScan(id string) (ScanJob, bool) ListScans(limit int) []ScanJob UpdateScan(id string, update ScanUpdate) (ScanJob, bool) + SaveHostResult(scanID string, host HostResult) error + ListHostResults(scanID string, limit int) []HostResult +} + +type HostResult struct { + IP string `json:"ip"` + IsUp bool `json:"is_up"` + CheckedAt time.Time `json:"checked_at"` } type MemoryStore struct { - mu sync.RWMutex - jobs map[string]ScanJob + mu sync.RWMutex + jobs map[string]ScanJob + hosts map[string][]HostResult } func NewMemoryStore() *MemoryStore { return &MemoryStore{ - jobs: make(map[string]ScanJob), + jobs: make(map[string]ScanJob), + hosts: make(map[string][]HostResult), } } @@ -161,6 +171,35 @@ func (s *MemoryStore) UpdateScan(id string, update ScanUpdate) (ScanJob, bool) { return job, true } +func (s *MemoryStore) SaveHostResult(scanID string, host HostResult) error { + s.mu.Lock() + defer s.mu.Unlock() + + if _, ok := s.jobs[scanID]; !ok { + return errors.New("scan not found") + } + s.hosts[scanID] = append(s.hosts[scanID], host) + return nil +} + +func (s *MemoryStore) ListHostResults(scanID string, limit int) []HostResult { + if limit <= 0 { + limit = 500 + } + s.mu.RLock() + defer s.mu.RUnlock() + + items := s.hosts[scanID] + if len(items) <= limit { + out := make([]HostResult, len(items)) + copy(out, items) + return out + } + out := make([]HostResult, 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 acbb651..34a9baa 100644 --- a/internal/store/sql/001_init.sql +++ b/internal/store/sql/001_init.sql @@ -17,3 +17,14 @@ create index if not exists idx_scan_jobs_created_at on scan_jobs (created_at des alter table scan_jobs add column if not exists progress int not null default 0; alter table scan_jobs add column if not exists stats jsonb not null default '{}'::jsonb; + +create table if not exists scan_hosts ( + id bigserial primary key, + scan_id text not null references scan_jobs(id) on delete cascade, + ip inet not null, + is_up boolean not null, + checked_at timestamptz not null +); + +create index if not exists idx_scan_hosts_scan_id_checked_at + on scan_hosts (scan_id, checked_at desc);