diff --git a/internal/api/handler.go b/internal/api/handler.go index 81f8648..c901923 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -2,7 +2,9 @@ package api import ( "encoding/json" + "log" "net/http" + "net/url" "sort" "strconv" "strings" @@ -70,6 +72,17 @@ func NewHandler(store scans.Store, run *scans.Runner, resetDBSecret string) *Han return &Handler{store: store, run: run, resetDBSecret: strings.TrimSpace(resetDBSecret)} } +func scanPathID(r *http.Request) string { + raw := r.PathValue("id") + if raw == "" { + return "" + } + if dec, err := url.PathUnescape(raw); err == nil { + return strings.TrimSpace(dec) + } + return strings.TrimSpace(raw) +} + func (h *Handler) Routes() http.Handler { mux := http.NewServeMux() webFS := http.FileServer(http.FS(webui.FS)) @@ -80,6 +93,7 @@ func (h *Handler) Routes() http.Handler { mux.HandleFunc("GET /api/scans", h.listScans) mux.HandleFunc("GET /api/scans/diff", h.getScansDiff) mux.HandleFunc("GET /api/scans/{id}", h.getScan) + mux.HandleFunc("POST /api/scans/{id}/cancel", h.postCancelScan) 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) @@ -142,14 +156,15 @@ func (h *Handler) createScan(w http.ResponseWriter, r *http.Request) { return } + // Start до ответа клиенту: иначе возможен POST /cancel до регистрации cancel в Runner (задача останется queued/running). + if h.run != nil { + h.run.Start(job) + } + writeJSON(w, http.StatusCreated, map[string]any{ "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) { @@ -200,7 +215,7 @@ func (h *Handler) getScansDiff(w http.ResponseWriter, r *http.Request) { } func (h *Handler) getScan(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") + id := scanPathID(r) if id == "" { writeError(w, http.StatusBadRequest, "scan id is required") return @@ -208,6 +223,7 @@ func (h *Handler) getScan(w http.ResponseWriter, r *http.Request) { job, ok := h.store.GetScan(id) if !ok { + log.Printf("getScan: not found id=%q (len=%d)", id, len(id)) writeError(w, http.StatusNotFound, "scan not found") return } @@ -215,8 +231,41 @@ func (h *Handler) getScan(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, job) } +func (h *Handler) postCancelScan(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "use POST") + return + } + id := scanPathID(r) + if id == "" { + writeError(w, http.StatusBadRequest, "scan id is required") + return + } + if _, ok := h.store.GetScan(id); !ok { + log.Printf("postCancelScan: not found id=%q (len=%d)", id, len(id)) + writeError(w, http.StatusNotFound, "scan not found") + return + } + runnerSignaled := false + if h.run != nil { + runnerSignaled = h.run.CancelScan(id) + } + if runnerSignaled { + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "runner_canceled": true}) + return + } + cur, _ := h.store.GetScan(id) + if cur.Status == "queued" { + fin := time.Now().UTC() + _, _ = h.store.UpdateScan(id, scans.ScanUpdate{Status: "canceled", FinishedAt: &fin}) + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "runner_canceled": false, "status": "canceled"}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "runner_canceled": false}) +} + func (h *Handler) getScanHosts(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") + id := scanPathID(r) if id == "" { writeError(w, http.StatusBadRequest, "scan id is required") return @@ -242,7 +291,7 @@ func (h *Handler) getScanHosts(w http.ResponseWriter, r *http.Request) { } func (h *Handler) getScanPorts(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") + id := scanPathID(r) if id == "" { writeError(w, http.StatusBadRequest, "scan id is required") return @@ -268,7 +317,7 @@ func (h *Handler) getScanPorts(w http.ResponseWriter, r *http.Request) { } func (h *Handler) getScanSNMP(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") + id := scanPathID(r) if id == "" { writeError(w, http.StatusBadRequest, "scan id is required") return @@ -294,7 +343,7 @@ func (h *Handler) getScanSNMP(w http.ResponseWriter, r *http.Request) { } func (h *Handler) getScanLLDP(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") + id := scanPathID(r) if id == "" { writeError(w, http.StatusBadRequest, "scan id is required") return @@ -320,7 +369,7 @@ func (h *Handler) getScanLLDP(w http.ResponseWriter, r *http.Request) { } func (h *Handler) getScanInterfaces(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") + id := scanPathID(r) if id == "" { writeError(w, http.StatusBadRequest, "scan id is required") return @@ -349,7 +398,7 @@ func (h *Handler) getScanInterfaces(w http.ResponseWriter, r *http.Request) { } func (h *Handler) getScanPortDevices(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") + id := scanPathID(r) if id == "" { writeError(w, http.StatusBadRequest, "scan id is required") return @@ -409,7 +458,7 @@ func (h *Handler) getScanPortDevices(w http.ResponseWriter, r *http.Request) { } func (h *Handler) getScanMacLocations(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") + id := scanPathID(r) if id == "" { writeError(w, http.StatusBadRequest, "scan id is required") return @@ -558,7 +607,7 @@ func (h *Handler) getScanMacLocations(w http.ResponseWriter, r *http.Request) { } func (h *Handler) getScanLinks(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") + id := scanPathID(r) if id == "" { writeError(w, http.StatusBadRequest, "scan id is required") return @@ -621,7 +670,7 @@ func (h *Handler) getScanLinks(w http.ResponseWriter, r *http.Request) { } func (h *Handler) getScanTopology(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") + id := scanPathID(r) if id == "" { writeError(w, http.StatusBadRequest, "scan id is required") return diff --git a/internal/scans/postgres_store.go b/internal/scans/postgres_store.go index 2a7df22..e075b5c 100644 --- a/internal/scans/postgres_store.go +++ b/internal/scans/postgres_store.go @@ -5,6 +5,7 @@ import ( "database/sql" "encoding/json" "errors" + "log" "sort" "strings" "time" @@ -132,19 +133,24 @@ limit 1` return ScanJob{}, false } if err != nil { + log.Printf("postgres GetScan id=%q: row scan: %v", id, err) return ScanJob{}, false } if err = json.Unmarshal([]byte(cidrsRaw), &job.CIDRs); err != nil { + log.Printf("postgres GetScan id=%q: cidrs json (len=%d): %v", id, len(cidrsRaw), err) return ScanJob{}, false } if err = json.Unmarshal([]byte(excludeRaw), &job.ExcludeIPs); err != nil { + log.Printf("postgres GetScan id=%q: exclude_ips json: %v", id, err) return ScanJob{}, false } if err = json.Unmarshal([]byte(optionsRaw), &job.Options); err != nil { + log.Printf("postgres GetScan id=%q: options json: %v", id, err) return ScanJob{}, false } if err = json.Unmarshal([]byte(statsRaw), &job.Stats); err != nil { + log.Printf("postgres GetScan id=%q: stats json: %v", id, err) return ScanJob{}, false } if startedAt.Valid { diff --git a/internal/scans/runner.go b/internal/scans/runner.go index dc73339..f3db7ab 100644 --- a/internal/scans/runner.go +++ b/internal/scans/runner.go @@ -1,7 +1,9 @@ package scans import ( + "context" "encoding/hex" + "errors" "fmt" "log" "net" @@ -20,8 +22,10 @@ import ( ) type Runner struct { - store Store - cfg RunnerConfig + store Store + cfg RunnerConfig + mu sync.Mutex + cancels map[string]context.CancelFunc } type RunnerConfig struct { @@ -52,10 +56,64 @@ func scanProgressBlend(quickDone, fullDone, total int) int { } func (r *Runner) Start(job ScanJob) { - go r.run(job) + ctx, cancel := context.WithCancel(context.Background()) + r.mu.Lock() + if r.cancels == nil { + r.cancels = make(map[string]context.CancelFunc) + } + r.cancels[job.ID] = cancel + r.mu.Unlock() + + go func() { + defer func() { + cancel() + r.mu.Lock() + delete(r.cancels, job.ID) + r.mu.Unlock() + }() + r.run(ctx, job) + }() } -func (r *Runner) run(job ScanJob) { +// CancelScan отменяет активный воркер скана в этом процессе. Возвращает false, если скан не числится в памяти Runner (уже завершён или не стартовал). +func (r *Runner) CancelScan(id string) bool { + r.mu.Lock() + c, ok := r.cancels[id] + r.mu.Unlock() + if !ok || c == nil { + return false + } + c() + return true +} + +func (r *Runner) markJobCanceledFromStore(jobID string) { + cur, ok := r.store.GetScan(jobID) + if !ok { + return + } + switch cur.Status { + case "done", "failed", "canceled": + return + } + fin := time.Now().UTC() + _, _ = r.store.UpdateScan(jobID, ScanUpdate{ + Status: "canceled", + Progress: cur.Progress, + Stats: cur.Stats, + FinishedAt: &fin, + }) +} + +func (r *Runner) run(ctx context.Context, job ScanJob) { + if cur, ok := r.store.GetScan(job.ID); ok && cur.Status == "canceled" { + return + } + if err := ctx.Err(); err != nil && errors.Is(err, context.Canceled) { + r.markJobCanceledFromStore(job.ID) + return + } + started := time.Now().UTC() _, _ = r.store.UpdateScan(job.ID, ScanUpdate{ Status: "running", @@ -64,6 +122,11 @@ func (r *Runner) run(job ScanJob) { StartedAt: &started, }) + if err := ctx.Err(); err != nil && errors.Is(err, context.Canceled) { + r.markJobCanceledFromStore(job.ID) + return + } + ips, err := expandTargets(job.CIDRs, job.ExcludeIPs) if err != nil { finished := time.Now().UTC() @@ -109,10 +172,10 @@ func (r *Runner) run(job ScanJob) { Status: "running", Progress: prog, Stats: ScanStats{ - HostsTotal: total, - HostsUp: u, - HostsPingPhaseDone: q, - HostsProcessed: f, + HostsTotal: total, + HostsUp: u, + HostsPingPhaseDone: q, + HostsProcessed: f, }, }) } @@ -178,19 +241,49 @@ func (r *Runner) run(job ScanJob) { go worker() } - for _, ip := range ips { - workCh <- ip - } - close(workCh) + go func() { + defer close(workCh) + for _, ip := range ips { + select { + case <-ctx.Done(): + return + case workCh <- ip: + } + } + }() + wg.Wait() finished := time.Now().UTC() + mu.Lock() + upFinal := up + full := fullDone + quick := quickDone + mu.Unlock() + + if ctx.Err() != nil && errors.Is(ctx.Err(), context.Canceled) && full < total { + st := ScanStats{ + HostsTotal: total, + HostsUp: upFinal, + HostsPingPhaseDone: quick, + HostsProcessed: full, + } + prog := scanProgressBlend(quick, full, total) + _, _ = r.store.UpdateScan(job.ID, ScanUpdate{ + Status: "canceled", + Progress: prog, + Stats: st, + FinishedAt: &finished, + }) + return + } + _, _ = r.store.UpdateScan(job.ID, ScanUpdate{ Status: "done", Progress: 100, Stats: ScanStats{ HostsTotal: total, - HostsUp: up, + HostsUp: upFinal, HostsPingPhaseDone: total, HostsProcessed: total, }, diff --git a/internal/scans/scan_id_test.go b/internal/scans/scan_id_test.go index e9fbed0..09d5141 100644 --- a/internal/scans/scan_id_test.go +++ b/internal/scans/scan_id_test.go @@ -14,8 +14,8 @@ func TestNewScanIDShape(t *testing.T) { if len(parts) < 3 { t.Fatalf("expected at least time_slug_suffix, got %q", id) } - if len(parts[len(parts)-1]) != 4 { - t.Fatalf("expected 4-hex suffix, got %q", id) + if len(parts[len(parts)-1]) != 8 { + t.Fatalf("expected 8-hex suffix, got %q", id) } if !strings.Contains(id, "192.168.160.0_24") { t.Fatalf("expected sanitized cidr in id: %q", id) diff --git a/internal/scans/store.go b/internal/scans/store.go index 035588a..7fa1be0 100644 --- a/internal/scans/store.go +++ b/internal/scans/store.go @@ -548,9 +548,9 @@ func validateCreateScanRequest(req *CreateScanRequest) error { return nil } -// newScanID — человекочитаемый scan_id: UTC-время с наносекундами + CIDR из запроса (как ввёл пользователь, до expand) + 4 hex (уникальность при коллизии по времени/CIDR). +// newScanID — человекочитаемый scan_id: UTC-время с наносекундами + CIDR из запроса (как ввёл пользователь, до expand) + 8 hex (коллизии по времени/CIDR). func newScanID(cidrLabels []string) (string, error) { - var tail [2]byte + var tail [4]byte if _, err := rand.Read(tail[:]); err != nil { return "", err } diff --git a/internal/webui/index.html b/internal/webui/index.html index 3b606ab..7f699b4 100644 --- a/internal/webui/index.html +++ b/internal/webui/index.html @@ -37,8 +37,9 @@
+Сначала укажите scan_id в поле выше (или «Последний scan» / «Загрузить»). Введите MAC. Строки сортируются: сначала порты с малым числом уникальных MAC на том же VLAN и порту (вероятный доступ), затем «между порогами», затем транк/uplink.
+Нужен текущий scan (после «Создать scan и ждать» или «Ждать завершения»). Введите MAC. Строки сортируются: сначала порты с малым числом уникальных MAC на том же VLAN и порту (вероятный доступ), затем «между порогами», затем транк/uplink.
На сервере должен быть задан NETTOPO_RESET_DB_SECRET. Ключ ниже передаётся только в заголовке запроса и нигде не сохраняется.