diff --git a/.env.example b/.env.example
index 10894f1..511a2c2 100644
--- a/.env.example
+++ b/.env.example
@@ -5,3 +5,5 @@ RUN_MIGRATIONS=true
SNMP_ENABLED=true
SNMP_COMMUNITY=public
# NETTOPO_LOG_PING_FAILURES=true # логировать каждый неответивший ping (по умолчанию выключено — иначе засоряет journal)
+# «Начать заново»: POST /api/admin/purge-scans с заголовком X-Nettopo-Reset-Key: <секрет>
+# NETTOPO_RESET_DB_SECRET=change-me-long-random
diff --git a/cmd/server/main.go b/cmd/server/main.go
index e6c5c0b..c7a4513 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -24,7 +24,7 @@ func main() {
SNMPEnabled: cfg.SNMPEnabled,
SNMPCommunity: cfg.SNMPCommunity,
})
- handler := api.NewHandler(scanStore, runner)
+ handler := api.NewHandler(scanStore, runner, cfg.ResetDBSecret)
server := &http.Server{
Addr: cfg.HTTPAddr,
diff --git a/internal/api/handler.go b/internal/api/handler.go
index 7e76f48..b947299 100644
--- a/internal/api/handler.go
+++ b/internal/api/handler.go
@@ -12,8 +12,9 @@ import (
)
type Handler struct {
- store scans.Store
- run *scans.Runner
+ store scans.Store
+ run *scans.Runner
+ resetDBSecret string
}
type LinkView struct {
@@ -49,8 +50,8 @@ type ScanDiffResponse struct {
MissingLinks []string `json:"missing_links"`
}
-func NewHandler(store scans.Store, run *scans.Runner) *Handler {
- return &Handler{store: store, run: run}
+func NewHandler(store scans.Store, run *scans.Runner, resetDBSecret string) *Handler {
+ return &Handler{store: store, run: run, resetDBSecret: strings.TrimSpace(resetDBSecret)}
}
func (h *Handler) Routes() http.Handler {
@@ -70,9 +71,34 @@ func (h *Handler) Routes() http.Handler {
mux.HandleFunc("GET /api/scans/{id}/interfaces", h.getScanInterfaces)
mux.HandleFunc("GET /api/scans/{id}/links", h.getScanLinks)
mux.HandleFunc("GET /api/scans/{id}/topology", h.getScanTopology)
+ mux.HandleFunc("POST /api/admin/purge-scans", h.postAdminPurgeScans)
return withJSON(mux)
}
+func (h *Handler) postAdminPurgeScans(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ writeError(w, http.StatusMethodNotAllowed, "use POST")
+ return
+ }
+ if h.resetDBSecret == "" {
+ writeError(w, http.StatusForbidden, "purge disabled: set NETTOPO_RESET_DB_SECRET in environment")
+ return
+ }
+ key := strings.TrimSpace(r.Header.Get("X-Nettopo-Reset-Key"))
+ if key == "" || key != h.resetDBSecret {
+ writeError(w, http.StatusUnauthorized, "invalid or missing X-Nettopo-Reset-Key header")
+ return
+ }
+ if err := h.store.PurgeAllScanData(); err != nil {
+ writeError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{
+ "ok": true,
+ "message": "all scan data removed (scan_jobs and related rows)",
+ })
+}
+
func (h *Handler) health(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"status": "ok",
diff --git a/internal/config/config.go b/internal/config/config.go
index 3c6e52f..06f0c3e 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -1,14 +1,18 @@
package config
-import "os"
+import (
+ "os"
+ "strings"
+)
type Config struct {
- HTTPAddr string
- DBDSN string
- StoreBackend string
- RunMigrations bool
- SNMPEnabled bool
- SNMPCommunity string
+ HTTPAddr string
+ DBDSN string
+ StoreBackend string
+ RunMigrations bool
+ SNMPEnabled bool
+ SNMPCommunity string
+ ResetDBSecret string // NETTOPO_RESET_DB_SECRET — секрет для POST /api/admin/purge-scans
}
func FromEnv() Config {
@@ -47,5 +51,6 @@ func FromEnv() Config {
RunMigrations: runMigrations,
SNMPEnabled: snmpEnabled,
SNMPCommunity: snmpCommunity,
+ ResetDBSecret: strings.TrimSpace(os.Getenv("NETTOPO_RESET_DB_SECRET")),
}
}
diff --git a/internal/scans/postgres_store.go b/internal/scans/postgres_store.go
index ae88c59..b28378a 100644
--- a/internal/scans/postgres_store.go
+++ b/internal/scans/postgres_store.go
@@ -532,3 +532,9 @@ limit $2`,
}
return out
}
+
+func (s *PostgresStore) PurgeAllScanData() error {
+ // Дочерние таблицы ссылаются на scan_jobs — CASCADE очищает всё; RESTART IDENTITY сбрасывает bigserial.
+ _, err := s.db.ExecContext(context.Background(), `truncate table scan_jobs restart identity cascade`)
+ return err
+}
diff --git a/internal/scans/store.go b/internal/scans/store.go
index 0124a19..5de3758 100644
--- a/internal/scans/store.go
+++ b/internal/scans/store.go
@@ -69,6 +69,8 @@ type Store interface {
ListLLDPResults(scanID string, limit int) []LLDPResult
SaveInterfaceResult(scanID string, result InterfaceResult) error
ListInterfaceResults(scanID string, filterIP string, limit int) []InterfaceResult
+ // PurgeAllScanData удаляет все сканы и связанные строки (хосты, порты, SNMP, LLDP, интерфейсы).
+ PurgeAllScanData() error
}
type HostResult struct {
@@ -330,6 +332,18 @@ func (s *MemoryStore) SaveInterfaceResult(scanID string, result InterfaceResult)
return nil
}
+func (s *MemoryStore) PurgeAllScanData() error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.jobs = make(map[string]ScanJob)
+ s.hosts = make(map[string][]HostResult)
+ s.ports = make(map[string][]OpenPortResult)
+ s.snmp = make(map[string][]SNMPResult)
+ s.lldp = make(map[string][]LLDPResult)
+ s.ifaces = make(map[string][]InterfaceResult)
+ return nil
+}
+
func (s *MemoryStore) ListInterfaceResults(scanID string, filterIP string, limit int) []InterfaceResult {
if limit <= 0 {
limit = 4096
diff --git a/internal/webui/index.html b/internal/webui/index.html
index 0afc93f..49bda7a 100644
--- a/internal/webui/index.html
+++ b/internal/webui/index.html
@@ -55,6 +55,14 @@
+ На сервере должен быть задан Админ: начать всё заново — очистить данные сканов в БД
+ NETTOPO_RESET_DB_SECRET. Ключ ниже передаётся только в заголовке запроса и нигде не сохраняется.