From 43cb3396054e1587b5348d02cf2dde8f74292196 Mon Sep 17 00:00:00 2001 From: Andrey Lutsenko Date: Thu, 9 Apr 2026 21:49:38 +1000 Subject: [PATCH] Add scan queue execution and listing endpoint. Run scans in background with progress/status updates and store scan metrics in memory or PostgreSQL. Made-with: Cursor --- .env.example | 3 + README.md | 61 ++++++- TECH_SPEC.md | 9 +- cmd/server/main.go | 46 ++++- go.mod | 2 + internal/api/handler.go | 26 ++- internal/config/config.go | 25 ++- internal/scans/postgres_store.go | 282 +++++++++++++++++++++++++++++++ internal/scans/runner.go | 184 ++++++++++++++++++++ internal/scans/store.go | 105 ++++++++++-- internal/store/migrate.go | 14 ++ internal/store/sql/001_init.sql | 19 +++ 12 files changed, 753 insertions(+), 23 deletions(-) create mode 100644 internal/scans/postgres_store.go create mode 100644 internal/scans/runner.go create mode 100644 internal/store/migrate.go create mode 100644 internal/store/sql/001_init.sql diff --git a/.env.example b/.env.example index b1ad038..833e168 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,4 @@ HTTP_ADDR=:8080 +STORE_BACKEND=memory +# DB_DSN=postgres://nettopo:nettopo@localhost:5432/nettopo?sslmode=disable +RUN_MIGRATIONS=true diff --git a/README.md b/README.md index f4d300e..cf860d8 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,11 @@ - `GET /health` - проверка состояния API. - `POST /api/scans` - создание задания скана. +- `GET /api/scans` - список сканов. - `GET /api/scans/{id}` - просмотр созданного задания. - Валидация CIDR и exclude IP. -- Хранилище в памяти (для старта). БД подключим следующим шагом. +- Два backend-хранилища: in-memory и PostgreSQL. +- После создания scan запускается фоновый discovery с обновлением статуса и прогресса. ## Требования @@ -40,6 +42,49 @@ cp .env.example .env HTTP_ADDR=:8080 go run ./cmd/server ``` +По умолчанию используется `STORE_BACKEND=memory`. + +## PostgreSQL без Docker + +### Вариант A: macOS (Homebrew) + +```bash +brew install postgresql@16 +brew services start postgresql@16 +createdb nettopo +createuser nettopo +``` + +```bash +psql -d postgres -c "alter user nettopo with password 'nettopo';" +psql -d postgres -c "grant all privileges on database nettopo to nettopo;" +``` + +### Вариант B: Ubuntu/Debian + +```bash +sudo apt update +sudo apt install -y postgresql postgresql-contrib +sudo -u postgres psql -c "create user nettopo with password 'nettopo';" +sudo -u postgres psql -c "create database nettopo owner nettopo;" +``` + +### Вариант C: Windows + +- Установить PostgreSQL через официальный installer. +- Создать БД `nettopo` и пользователя `nettopo` (через pgAdmin или `psql`). + +### Запуск API с PostgreSQL + +```bash +export STORE_BACKEND=postgres +export DB_DSN='postgres://nettopo:nettopo@localhost:5432/nettopo?sslmode=disable' +export RUN_MIGRATIONS=true +go run ./cmd/server +``` + +При первом запуске автоматически применится миграция `scan_jobs`. + ## Быстрая проверка API Проверка health: @@ -73,6 +118,18 @@ curl -s -X POST http://localhost:8080/api/scans \ curl -s http://localhost:8080/api/scans/ ``` +Получить список scan jobs: + +```bash +curl -s http://localhost:8080/api/scans?limit=20 +``` + +В ответе у scan есть: + +- `status`: `queued`, `running`, `done`, `failed` +- `progress`: 0..100 +- `stats.hosts_total`, `stats.hosts_up` + ## Запуск как сервис на Linux (systemd) 1. Собрать бинарник: @@ -101,4 +158,4 @@ systemctl status nettopo-go - На IIS настроить Reverse Proxy к `http://localhost:8080`. - Требуются URL Rewrite + ARR. -Следующим шагом добавим готовый шаблон `web.config` для IIS и миграции PostgreSQL. +Шаблон `web.config` лежит в `deploy/web.config.example`. diff --git a/TECH_SPEC.md b/TECH_SPEC.md index e90f1d2..5fbbd01 100644 --- a/TECH_SPEC.md +++ b/TECH_SPEC.md @@ -41,9 +41,16 @@ - `internal/api` — обработчики и роуты - `internal/scans` — логика создания/ведения scan jobs - `internal/scanner/*` — ping, ports, snmp, lldp (поэтапно) -- `internal/store` — PostgreSQL-репозиторий +- `internal/store` — миграции PostgreSQL - `web` — интерфейс (следующий этап) +## Текущее состояние реализации + +- готов API-каркас (`health`, создание и чтение scan jobs); +- реализован выбор хранилища: `memory` или `postgres` через env; +- добавлена автоматическая миграция таблицы `scan_jobs` при старте; +- подготовлены конфиги для запуска как сервис на Linux и за IIS на Windows. + ## Минимальные API - `GET /health` diff --git a/cmd/server/main.go b/cmd/server/main.go index 36f4f50..a3c4b8a 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,18 +1,27 @@ package main import ( + "database/sql" "log" "net/http" "nettopo-go/internal/api" "nettopo-go/internal/config" "nettopo-go/internal/scans" + "nettopo-go/internal/store" + + _ "github.com/jackc/pgx/v5/stdlib" ) func main() { cfg := config.FromEnv() - store := scans.NewMemoryStore() - handler := api.NewHandler(store) + scanStore, dbClose := makeStore(cfg) + if dbClose != nil { + defer dbClose() + } + + runner := scans.NewRunner(scanStore) + handler := api.NewHandler(scanStore, runner) server := &http.Server{ Addr: cfg.HTTPAddr, @@ -24,3 +33,36 @@ func main() { log.Fatalf("server failed: %v", err) } } + +func makeStore(cfg config.Config) (scans.Store, func()) { + if cfg.StoreBackend != "postgres" { + log.Printf("store backend: memory") + return scans.NewMemoryStore(), nil + } + + if cfg.DBDSN == "" { + log.Fatalf("STORE_BACKEND=postgres requires DB_DSN") + } + + db, err := sql.Open("pgx", cfg.DBDSN) + if err != nil { + log.Fatalf("db open failed: %v", err) + } + + if err = db.Ping(); err != nil { + _ = db.Close() + log.Fatalf("db ping failed: %v", err) + } + + if cfg.RunMigrations { + if err = store.RunMigrations(db); err != nil { + _ = db.Close() + log.Fatalf("db migration failed: %v", err) + } + } + + log.Printf("store backend: postgres") + return scans.NewPostgresStore(db), func() { + _ = db.Close() + } +} diff --git a/go.mod b/go.mod index 0f64359..af8c73d 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module nettopo-go go 1.22 + +require github.com/jackc/pgx/v5 v5.7.6 diff --git a/internal/api/handler.go b/internal/api/handler.go index 68d1269..c0cf86f 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -3,6 +3,7 @@ package api import ( "encoding/json" "net/http" + "strconv" "strings" "time" @@ -11,16 +12,18 @@ import ( type Handler struct { store scans.Store + run *scans.Runner } -func NewHandler(store scans.Store) *Handler { - return &Handler{store: store} +func NewHandler(store scans.Store, run *scans.Runner) *Handler { + return &Handler{store: store, run: run} } func (h *Handler) Routes() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /health", h.health) mux.HandleFunc("POST /api/scans", h.createScan) + mux.HandleFunc("GET /api/scans", h.listScans) mux.HandleFunc("GET /api/scans/{id}", h.getScan) return withJSON(mux) } @@ -54,6 +57,25 @@ func (h *Handler) createScan(w http.ResponseWriter, r *http.Request) { "scan_id": job.ID, "status": job.Status, }) + + if h.run != nil { + h.run.Start(job) + } +} + +func (h *Handler) listScans(w http.ResponseWriter, r *http.Request) { + limit := 50 + if raw := r.URL.Query().Get("limit"); raw != "" { + n, err := strconv.Atoi(raw) + if err != nil || n <= 0 || n > 500 { + writeError(w, http.StatusBadRequest, "limit must be between 1 and 500") + return + } + limit = n + } + writeJSON(w, http.StatusOK, map[string]any{ + "items": h.store.ListScans(limit), + }) } func (h *Handler) getScan(w http.ResponseWriter, r *http.Request) { diff --git a/internal/config/config.go b/internal/config/config.go index 8edbe96..9443bc8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,7 +3,10 @@ package config import "os" type Config struct { - HTTPAddr string + HTTPAddr string + DBDSN string + StoreBackend string + RunMigrations bool } func FromEnv() Config { @@ -12,7 +15,25 @@ func FromEnv() Config { addr = ":8080" } + dbDSN := os.Getenv("DB_DSN") + storeBackend := os.Getenv("STORE_BACKEND") + if storeBackend == "" { + if dbDSN != "" { + storeBackend = "postgres" + } else { + storeBackend = "memory" + } + } + + runMigrations := true + if os.Getenv("RUN_MIGRATIONS") == "false" { + runMigrations = false + } + return Config{ - HTTPAddr: addr, + HTTPAddr: addr, + DBDSN: dbDSN, + StoreBackend: storeBackend, + RunMigrations: runMigrations, } } diff --git a/internal/scans/postgres_store.go b/internal/scans/postgres_store.go new file mode 100644 index 0000000..e3430e0 --- /dev/null +++ b/internal/scans/postgres_store.go @@ -0,0 +1,282 @@ +package scans + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "sort" + "time" +) + +type PostgresStore struct { + db *sql.DB +} + +func NewPostgresStore(db *sql.DB) *PostgresStore { + return &PostgresStore{db: db} +} + +func (s *PostgresStore) CreateScan(req CreateScanRequest) (ScanJob, error) { + if err := validateCreateScanRequest(req); err != nil { + return ScanJob{}, err + } + applyDefaultOptions(&req.Options) + + id, err := newID() + if err != nil { + return ScanJob{}, err + } + + now := time.Now().UTC() + job := ScanJob{ + ID: id, + Name: req.Name, + Status: "queued", + CIDRs: req.CIDRs, + ExcludeIPs: req.ExcludeIPs, + SNMPCredentialsID: req.SNMPCredentialsID, + Options: req.Options, + Progress: 0, + Stats: ScanStats{}, + CreatedAt: now, + } + + cidrsJSON, err := json.Marshal(job.CIDRs) + if err != nil { + return ScanJob{}, err + } + excludeJSON, err := json.Marshal(job.ExcludeIPs) + if err != nil { + return ScanJob{}, err + } + optionsJSON, err := json.Marshal(job.Options) + if err != nil { + return ScanJob{}, err + } + statsJSON, err := json.Marshal(job.Stats) + if err != nil { + return ScanJob{}, err + } + + var snmpID any + if job.SNMPCredentialsID != "" { + snmpID = job.SNMPCredentialsID + } + + query := ` +insert into scan_jobs ( + id, name, status, cidrs, exclude_ips, options, progress, stats, snmp_credentials_id, created_at +) values ( + $1, $2, $3, $4::jsonb, $5::jsonb, $6::jsonb, $7, $8::jsonb, $9, $10 +)` + + _, err = s.db.ExecContext(context.Background(), query, + job.ID, + job.Name, + job.Status, + string(cidrsJSON), + string(excludeJSON), + string(optionsJSON), + job.Progress, + string(statsJSON), + snmpID, + job.CreatedAt, + ) + if err != nil { + return ScanJob{}, err + } + + return job, nil +} + +func (s *PostgresStore) GetScan(id string) (ScanJob, bool) { + query := ` +select id, name, status, cidrs::text, exclude_ips::text, options::text, + progress, stats::text, coalesce(snmp_credentials_id::text, ''), + created_at, started_at, finished_at +from scan_jobs +where id = $1 +limit 1` + + var job ScanJob + var cidrsRaw string + var excludeRaw string + var optionsRaw string + var statsRaw string + var startedAt sql.NullTime + var finishedAt sql.NullTime + + err := s.db.QueryRowContext(context.Background(), query, id).Scan( + &job.ID, + &job.Name, + &job.Status, + &cidrsRaw, + &excludeRaw, + &optionsRaw, + &job.Progress, + &statsRaw, + &job.SNMPCredentialsID, + &job.CreatedAt, + &startedAt, + &finishedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return ScanJob{}, false + } + if err != nil { + return ScanJob{}, false + } + + if err = json.Unmarshal([]byte(cidrsRaw), &job.CIDRs); err != nil { + return ScanJob{}, false + } + if err = json.Unmarshal([]byte(excludeRaw), &job.ExcludeIPs); err != nil { + return ScanJob{}, false + } + if err = json.Unmarshal([]byte(optionsRaw), &job.Options); err != nil { + return ScanJob{}, false + } + if err = json.Unmarshal([]byte(statsRaw), &job.Stats); err != nil { + return ScanJob{}, false + } + if startedAt.Valid { + job.StartedAt = startedAt.Time + } + if finishedAt.Valid { + job.FinishedAt = finishedAt.Time + } + + return job, true +} + +func (s *PostgresStore) ListScans(limit int) []ScanJob { + if limit <= 0 { + limit = 50 + } + query := ` +select id, name, status, cidrs::text, exclude_ips::text, options::text, progress, + stats::text, coalesce(snmp_credentials_id::text, ''), created_at, started_at, finished_at +from scan_jobs +order by created_at desc +limit $1` + + rows, err := s.db.QueryContext(context.Background(), query, limit) + if err != nil { + return nil + } + defer rows.Close() + + jobs := make([]ScanJob, 0, limit) + for rows.Next() { + var job ScanJob + var cidrsRaw string + var excludeRaw string + var optionsRaw string + var statsRaw string + var startedAt sql.NullTime + var finishedAt sql.NullTime + + err = rows.Scan( + &job.ID, + &job.Name, + &job.Status, + &cidrsRaw, + &excludeRaw, + &optionsRaw, + &job.Progress, + &statsRaw, + &job.SNMPCredentialsID, + &job.CreatedAt, + &startedAt, + &finishedAt, + ) + if err != nil { + return nil + } + + if json.Unmarshal([]byte(cidrsRaw), &job.CIDRs) != nil || + json.Unmarshal([]byte(excludeRaw), &job.ExcludeIPs) != nil || + json.Unmarshal([]byte(optionsRaw), &job.Options) != nil || + json.Unmarshal([]byte(statsRaw), &job.Stats) != nil { + return nil + } + if startedAt.Valid { + job.StartedAt = startedAt.Time + } + if finishedAt.Valid { + job.FinishedAt = finishedAt.Time + } + jobs = append(jobs, job) + } + + sort.Slice(jobs, func(i, j int) bool { + return jobs[i].CreatedAt.After(jobs[j].CreatedAt) + }) + return jobs +} + +func (s *PostgresStore) UpdateScan(id string, update ScanUpdate) (ScanJob, bool) { + current, ok := s.GetScan(id) + if !ok { + return ScanJob{}, false + } + + if update.Status != "" { + current.Status = update.Status + } + if update.Progress >= 0 { + current.Progress = update.Progress + } + current.Stats = update.Stats + if update.StartedAt != nil { + current.StartedAt = *update.StartedAt + } + if update.FinishedAt != nil { + current.FinishedAt = *update.FinishedAt + } + + optionsJSON, err := json.Marshal(current.Options) + if err != nil { + return ScanJob{}, false + } + statsJSON, err := json.Marshal(current.Stats) + if err != nil { + return ScanJob{}, false + } + + var startedAt any + if !current.StartedAt.IsZero() { + startedAt = current.StartedAt + } + var finishedAt any + if !current.FinishedAt.IsZero() { + finishedAt = current.FinishedAt + } + + query := ` +update scan_jobs +set status = $2, + options = $3::jsonb, + progress = $4, + stats = $5::jsonb, + started_at = $6, + finished_at = $7 +where id = $1` + _, err = s.db.ExecContext( + context.Background(), + query, + id, + current.Status, + string(optionsJSON), + current.Progress, + string(statsJSON), + startedAt, + finishedAt, + ) + if err != nil { + return ScanJob{}, false + } + + return current, true +} diff --git a/internal/scans/runner.go b/internal/scans/runner.go new file mode 100644 index 0000000..14b9387 --- /dev/null +++ b/internal/scans/runner.go @@ -0,0 +1,184 @@ +package scans + +import ( + "log" + "net" + "os/exec" + "runtime" + "strconv" + "sync" + "time" +) + +type Runner struct { + store Store +} + +func NewRunner(store Store) *Runner { + return &Runner{store: store} +} + +func (r *Runner) Start(job ScanJob) { + go r.run(job) +} + +func (r *Runner) run(job ScanJob) { + started := time.Now().UTC() + _, _ = r.store.UpdateScan(job.ID, ScanUpdate{ + Status: "running", + Progress: 0, + Stats: ScanStats{}, + StartedAt: &started, + }) + + ips, err := expandTargets(job.CIDRs, job.ExcludeIPs) + if err != nil { + finished := time.Now().UTC() + _, _ = r.store.UpdateScan(job.ID, ScanUpdate{ + Status: "failed", + Progress: 100, + Stats: ScanStats{}, + FinishedAt: &finished, + }) + return + } + + total := len(ips) + if total == 0 { + finished := time.Now().UTC() + _, _ = r.store.UpdateScan(job.ID, ScanUpdate{ + Status: "done", + Progress: 100, + Stats: ScanStats{HostsTotal: 0, HostsUp: 0}, + FinishedAt: &finished, + }) + return + } + + maxWorkers := job.Options.MaxParallelHosts + if maxWorkers <= 0 { + maxWorkers = 64 + } + + var mu sync.Mutex + var done int + var up int + workCh := make(chan string) + wg := sync.WaitGroup{} + + worker := func() { + defer wg.Done() + for ip := range workCh { + isUp := probeHost(ip, job.Options.PingTimeoutMS) + + mu.Lock() + done++ + if isUp { + up++ + } + progress := done * 100 / total + stats := ScanStats{HostsTotal: total, HostsUp: up} + mu.Unlock() + + _, _ = r.store.UpdateScan(job.ID, ScanUpdate{ + Status: "running", + Progress: progress, + Stats: stats, + }) + } + } + + for i := 0; i < maxWorkers; i++ { + wg.Add(1) + go worker() + } + + for _, ip := range ips { + workCh <- ip + } + close(workCh) + wg.Wait() + + finished := time.Now().UTC() + _, _ = r.store.UpdateScan(job.ID, ScanUpdate{ + Status: "done", + Progress: 100, + Stats: ScanStats{HostsTotal: total, HostsUp: up}, + FinishedAt: &finished, + }) +} + +func expandTargets(cidrs []string, excludeIPs []string) ([]string, error) { + excluded := make(map[string]struct{}, len(excludeIPs)) + for _, ip := range excludeIPs { + excluded[ip] = struct{}{} + } + + seen := make(map[string]struct{}) + out := make([]string, 0, 256) + for _, c := range cidrs { + ip, ipnet, err := net.ParseCIDR(c) + if err != nil { + return nil, err + } + start := ip.Mask(ipnet.Mask).To4() + if start == nil { + continue + } + for current := dupIP(start); ipnet.Contains(current); incIP(current) { + host := current.String() + if _, ok := excluded[host]; ok { + continue + } + if _, ok := seen[host]; ok { + continue + } + seen[host] = struct{}{} + out = append(out, host) + } + } + return out, nil +} + +func probeHost(ip string, timeoutMS int) bool { + if timeoutMS <= 0 { + timeoutMS = 700 + } + timeoutSec := strconv.Itoa(max(1, timeoutMS/1000)) + + var cmd *exec.Cmd + if runtime.GOOS == "windows" { + // -n 1 one packet, -w timeout in ms + cmd = exec.Command("ping", "-n", "1", "-w", strconv.Itoa(timeoutMS), ip) + } else { + // -c 1 one packet, -W timeout in sec on Linux/macOS + cmd = exec.Command("ping", "-c", "1", "-W", timeoutSec, ip) + } + if err := cmd.Run(); err != nil { + log.Printf("probe failed for %s: %v", ip, err) + return false + } + return true +} + +func dupIP(ip net.IP) net.IP { + out := make(net.IP, len(ip)) + copy(out, ip) + return out +} + +func incIP(ip net.IP) { + for j := len(ip) - 1; j >= 0; j-- { + ip[j]++ + if ip[j] > 0 { + break + } + } +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/internal/scans/store.go b/internal/scans/store.go index 2f42e94..f02c73b 100644 --- a/internal/scans/store.go +++ b/internal/scans/store.go @@ -5,6 +5,7 @@ import ( "encoding/hex" "errors" "net" + "sort" "sync" "time" ) @@ -33,14 +34,31 @@ type ScanJob struct { ExcludeIPs []string `json:"exclude_ips"` SNMPCredentialsID string `json:"snmp_credentials_id"` Options ScanOptions `json:"options"` + Progress int `json:"progress"` + Stats ScanStats `json:"stats"` CreatedAt time.Time `json:"created_at"` StartedAt time.Time `json:"started_at,omitempty"` FinishedAt time.Time `json:"finished_at,omitempty"` } +type ScanStats struct { + HostsTotal int `json:"hosts_total"` + HostsUp int `json:"hosts_up"` +} + +type ScanUpdate struct { + Status string + Progress int + Stats ScanStats + StartedAt *time.Time + FinishedAt *time.Time +} + type Store interface { CreateScan(CreateScanRequest) (ScanJob, error) GetScan(id string) (ScanJob, bool) + ListScans(limit int) []ScanJob + UpdateScan(id string, update ScanUpdate) (ScanJob, bool) } type MemoryStore struct { @@ -55,20 +73,8 @@ func NewMemoryStore() *MemoryStore { } func (s *MemoryStore) CreateScan(req CreateScanRequest) (ScanJob, error) { - if len(req.CIDRs) == 0 { - return ScanJob{}, errors.New("cidrs must not be empty") - } - - for _, cidr := range req.CIDRs { - if _, _, err := net.ParseCIDR(cidr); err != nil { - return ScanJob{}, errors.New("invalid cidr: " + cidr) - } - } - - for _, ip := range req.ExcludeIPs { - if net.ParseIP(ip) == nil { - return ScanJob{}, errors.New("invalid exclude ip: " + ip) - } + if err := validateCreateScanRequest(req); err != nil { + return ScanJob{}, err } applyDefaultOptions(&req.Options) @@ -87,6 +93,8 @@ func (s *MemoryStore) CreateScan(req CreateScanRequest) (ScanJob, error) { ExcludeIPs: req.ExcludeIPs, SNMPCredentialsID: req.SNMPCredentialsID, Options: req.Options, + Progress: 0, + Stats: ScanStats{}, CreatedAt: now, } @@ -104,6 +112,55 @@ func (s *MemoryStore) GetScan(id string) (ScanJob, bool) { return job, ok } +func (s *MemoryStore) ListScans(limit int) []ScanJob { + if limit <= 0 { + limit = 50 + } + + s.mu.RLock() + jobs := make([]ScanJob, 0, len(s.jobs)) + for _, j := range s.jobs { + jobs = append(jobs, j) + } + s.mu.RUnlock() + + sort.Slice(jobs, func(i, j int) bool { + return jobs[i].CreatedAt.After(jobs[j].CreatedAt) + }) + if len(jobs) > limit { + jobs = jobs[:limit] + } + + return jobs +} + +func (s *MemoryStore) UpdateScan(id string, update ScanUpdate) (ScanJob, bool) { + s.mu.Lock() + defer s.mu.Unlock() + + job, ok := s.jobs[id] + if !ok { + return ScanJob{}, false + } + + if update.Status != "" { + job.Status = update.Status + } + if update.Progress >= 0 { + job.Progress = update.Progress + } + job.Stats = update.Stats + if update.StartedAt != nil { + job.StartedAt = *update.StartedAt + } + if update.FinishedAt != nil { + job.FinishedAt = *update.FinishedAt + } + + s.jobs[id] = job + return job, true +} + func applyDefaultOptions(opts *ScanOptions) { if opts.PingTimeoutMS <= 0 { opts.PingTimeoutMS = 700 @@ -119,6 +176,26 @@ func applyDefaultOptions(opts *ScanOptions) { } } +func validateCreateScanRequest(req CreateScanRequest) error { + if len(req.CIDRs) == 0 { + return errors.New("cidrs must not be empty") + } + + for _, cidr := range req.CIDRs { + if _, _, err := net.ParseCIDR(cidr); err != nil { + return errors.New("invalid cidr: " + cidr) + } + } + + for _, ip := range req.ExcludeIPs { + if net.ParseIP(ip) == nil { + return errors.New("invalid exclude ip: " + ip) + } + } + + return nil +} + func newID() (string, error) { var b [16]byte if _, err := rand.Read(b[:]); err != nil { diff --git a/internal/store/migrate.go b/internal/store/migrate.go new file mode 100644 index 0000000..8edf486 --- /dev/null +++ b/internal/store/migrate.go @@ -0,0 +1,14 @@ +package store + +import ( + "database/sql" + _ "embed" +) + +//go:embed sql/001_init.sql +var initSQL string + +func RunMigrations(db *sql.DB) error { + _, err := db.Exec(initSQL) + return err +} diff --git a/internal/store/sql/001_init.sql b/internal/store/sql/001_init.sql new file mode 100644 index 0000000..acbb651 --- /dev/null +++ b/internal/store/sql/001_init.sql @@ -0,0 +1,19 @@ +create table if not exists scan_jobs ( + id text primary key, + name text not null, + status text not null check (status in ('queued','running','done','failed','canceled')), + cidrs jsonb not null, + exclude_ips jsonb not null default '[]'::jsonb, + options jsonb not null, + progress int not null default 0, + stats jsonb not null default '{}'::jsonb, + snmp_credentials_id text null, + created_at timestamptz not null default now(), + started_at timestamptz null, + finished_at timestamptz null +); + +create index if not exists idx_scan_jobs_created_at on scan_jobs (created_at desc); + +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;