From e9452dcc0a8ad3a0003c727cb91c2bf99cf59961 Mon Sep 17 00:00:00 2001 From: Andrey Lutsenko Date: Thu, 9 Apr 2026 21:54:17 +1000 Subject: [PATCH] Add TCP port scan persistence and API endpoint. Probe configured ports for alive hosts, persist port states, and expose scan port results via REST. Made-with: Cursor --- README.md | 7 ++++++ internal/api/handler.go | 27 ++++++++++++++++++++ internal/scans/postgres_store.go | 43 ++++++++++++++++++++++++++++++++ internal/scans/runner.go | 31 ++++++++++++++++++++++- internal/scans/store.go | 40 +++++++++++++++++++++++++++++ internal/store/sql/001_init.sql | 12 +++++++++ 6 files changed, 159 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ee12e76..7eb6bdd 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ - `GET /api/scans` - список сканов. - `GET /api/scans/{id}` - просмотр созданного задания. - `GET /api/scans/{id}/hosts` - результаты проверки хостов по скану. +- `GET /api/scans/{id}/ports` - результаты проверки TCP-портов по скану. - Валидация CIDR и exclude IP. - Два backend-хранилища: in-memory и PostgreSQL. - После создания scan запускается фоновый discovery с обновлением статуса и прогресса. @@ -137,6 +138,12 @@ curl -s http://localhost:8080/api/scans?limit=20 curl -s http://localhost:8080/api/scans//hosts?limit=200 ``` +Получить результаты по портам: + +```bash +curl -s http://localhost:8080/api/scans//ports?limit=500 +``` + ## Запуск как сервис на Linux (systemd) 1. Собрать бинарник: diff --git a/internal/api/handler.go b/internal/api/handler.go index 41334de..16c57f9 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -26,6 +26,7 @@ func (h *Handler) Routes() http.Handler { mux.HandleFunc("GET /api/scans", h.listScans) 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) return withJSON(mux) } @@ -121,6 +122,32 @@ func (h *Handler) getScanHosts(w http.ResponseWriter, r *http.Request) { }) } +func (h *Handler) getScanPorts(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.ListOpenPortResults(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 0335108..120c72d 100644 --- a/internal/scans/postgres_store.go +++ b/internal/scans/postgres_store.go @@ -315,3 +315,46 @@ limit $2` } return out } + +func (s *PostgresStore) SaveOpenPortResult(scanID string, result OpenPortResult) error { + query := ` +insert into scan_ports (scan_id, ip, port, is_open, checked_at) +values ($1, $2, $3, $4, $5)` + _, err := s.db.ExecContext( + context.Background(), + query, + scanID, + result.IP, + result.Port, + result.IsOpen, + result.CheckedAt, + ) + return err +} + +func (s *PostgresStore) ListOpenPortResults(scanID string, limit int) []OpenPortResult { + if limit <= 0 { + limit = 1000 + } + query := ` +select ip::text, port, is_open, checked_at +from scan_ports +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([]OpenPortResult, 0, limit) + for rows.Next() { + var p OpenPortResult + if err = rows.Scan(&p.IP, &p.Port, &p.IsOpen, &p.CheckedAt); err != nil { + return nil + } + out = append(out, p) + } + return out +} diff --git a/internal/scans/runner.go b/internal/scans/runner.go index 0beacd0..b89e0f1 100644 --- a/internal/scans/runner.go +++ b/internal/scans/runner.go @@ -3,6 +3,7 @@ package scans import ( "log" "net" + "net/netip" "os/exec" "runtime" "strconv" @@ -70,11 +71,22 @@ func (r *Runner) run(job ScanJob) { defer wg.Done() for ip := range workCh { isUp := probeHost(ip, job.Options.PingTimeoutMS) + checkedAt := time.Now().UTC() _ = r.store.SaveHostResult(job.ID, HostResult{ IP: ip, IsUp: isUp, - CheckedAt: time.Now().UTC(), + CheckedAt: checkedAt, }) + if isUp && job.Options.PortScanEnabled { + for _, p := range job.Options.Ports { + _ = r.store.SaveOpenPortResult(job.ID, OpenPortResult{ + IP: ip, + Port: p, + IsOpen: probeTCPPort(ip, p, job.Options.PingTimeoutMS), + CheckedAt: checkedAt, + }) + } + } mu.Lock() done++ @@ -166,6 +178,23 @@ func probeHost(ip string, timeoutMS int) bool { return true } +func probeTCPPort(ip string, port int, timeoutMS int) bool { + if timeoutMS <= 0 { + timeoutMS = 700 + } + addr, err := netip.ParseAddr(ip) + if err != nil { + return false + } + target := net.JoinHostPort(addr.String(), strconv.Itoa(port)) + conn, err := net.DialTimeout("tcp", target, time.Duration(timeoutMS)*time.Millisecond) + if err != nil { + return false + } + _ = conn.Close() + return true +} + func dupIP(ip net.IP) net.IP { out := make(net.IP, len(ip)) copy(out, ip) diff --git a/internal/scans/store.go b/internal/scans/store.go index d132852..122eb76 100644 --- a/internal/scans/store.go +++ b/internal/scans/store.go @@ -61,6 +61,8 @@ type Store interface { UpdateScan(id string, update ScanUpdate) (ScanJob, bool) SaveHostResult(scanID string, host HostResult) error ListHostResults(scanID string, limit int) []HostResult + SaveOpenPortResult(scanID string, result OpenPortResult) error + ListOpenPortResults(scanID string, limit int) []OpenPortResult } type HostResult struct { @@ -69,16 +71,25 @@ type HostResult struct { CheckedAt time.Time `json:"checked_at"` } +type OpenPortResult struct { + IP string `json:"ip"` + Port int `json:"port"` + IsOpen bool `json:"is_open"` + CheckedAt time.Time `json:"checked_at"` +} + type MemoryStore struct { mu sync.RWMutex jobs map[string]ScanJob hosts map[string][]HostResult + ports map[string][]OpenPortResult } func NewMemoryStore() *MemoryStore { return &MemoryStore{ jobs: make(map[string]ScanJob), hosts: make(map[string][]HostResult), + ports: make(map[string][]OpenPortResult), } } @@ -200,6 +211,35 @@ func (s *MemoryStore) ListHostResults(scanID string, limit int) []HostResult { return out } +func (s *MemoryStore) SaveOpenPortResult(scanID string, result OpenPortResult) error { + s.mu.Lock() + defer s.mu.Unlock() + + if _, ok := s.jobs[scanID]; !ok { + return errors.New("scan not found") + } + s.ports[scanID] = append(s.ports[scanID], result) + return nil +} + +func (s *MemoryStore) ListOpenPortResults(scanID string, limit int) []OpenPortResult { + if limit <= 0 { + limit = 1000 + } + s.mu.RLock() + defer s.mu.RUnlock() + + items := s.ports[scanID] + if len(items) <= limit { + out := make([]OpenPortResult, len(items)) + copy(out, items) + return out + } + out := make([]OpenPortResult, 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 34a9baa..47d91a9 100644 --- a/internal/store/sql/001_init.sql +++ b/internal/store/sql/001_init.sql @@ -28,3 +28,15 @@ create table if not exists scan_hosts ( create index if not exists idx_scan_hosts_scan_id_checked_at on scan_hosts (scan_id, checked_at desc); + +create table if not exists scan_ports ( + id bigserial primary key, + scan_id text not null references scan_jobs(id) on delete cascade, + ip inet not null, + port int not null, + is_open boolean not null, + checked_at timestamptz not null +); + +create index if not exists idx_scan_ports_scan_id_checked_at + on scan_ports (scan_id, checked_at desc);