package discovery import ( "context" "errors" "sync" "time" "github.com/prometheus/client_golang/prometheus" "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/websocket" pkgdiscovery "github.com/rcourtman/pulse-go-rewrite/pkg/discovery" "github.com/rs/zerolog/log" ) // Service handles background network discovery type Service struct { scanner discoveryScanner wsHub *websocket.Hub cache *DiscoveryCache interval time.Duration subnet string mu sync.RWMutex lastScan time.Time isScanning bool stopChan chan struct{} ctx context.Context cfgProvider func() config.DiscoveryConfig history []historyEntry historyLimit int scannerFactory scannerFactory } // DiscoveryCache stores the latest discovery results type DiscoveryCache struct { mu sync.RWMutex result *pkgdiscovery.DiscoveryResult updated time.Time } type historyEntry struct { startedAt time.Time completedAt time.Time subnet string serverCount int errorCount int duration time.Duration blocklistLength int status string } func (h historyEntry) StartedAt() time.Time { return h.startedAt } func (h historyEntry) CompletedAt() time.Time { return h.completedAt } func (h historyEntry) Subnet() string { return h.subnet } func (h historyEntry) ServerCount() int { return h.serverCount } func (h historyEntry) ErrorCount() int { return h.errorCount } func (h historyEntry) Duration() time.Duration { return h.duration } func (h historyEntry) BlocklistLength() int { return h.blocklistLength } func (h historyEntry) Status() string { return h.status } const defaultHistoryLimit = 20 type discoveryScanner interface { DiscoverServersWithCallbacks(ctx context.Context, subnet string, serverCallback pkgdiscovery.ServerCallback, progressCallback pkgdiscovery.ProgressCallback) (*pkgdiscovery.DiscoveryResult, error) } type scannerFactory func(config.DiscoveryConfig) (discoveryScanner, error) var ( discoveryScanResults = prometheus.NewCounterVec( prometheus.CounterOpts{ Namespace: "pulse", Subsystem: "discovery", Name: "scans_total", Help: "Total number of discovery scans by result status.", }, []string{"result"}, ) discoveryScanDuration = prometheus.NewHistogram( prometheus.HistogramOpts{ Namespace: "pulse", Subsystem: "discovery", Name: "scan_duration_seconds", Help: "Duration of discovery scans in seconds.", Buckets: []float64{5, 10, 20, 30, 45, 60, 90, 120, 180}, }, ) discoveryScanServers = prometheus.NewGauge( prometheus.GaugeOpts{ Namespace: "pulse", Subsystem: "discovery", Name: "last_scan_servers", Help: "Number of servers found in the most recent discovery scan.", }, ) discoveryScanErrors = prometheus.NewGauge( prometheus.GaugeOpts{ Namespace: "pulse", Subsystem: "discovery", Name: "last_scan_errors", Help: "Number of errors encountered in the most recent discovery scan.", }, ) ) func init() { prometheus.MustRegister(discoveryScanResults, discoveryScanDuration, discoveryScanServers, discoveryScanErrors) } // NewService creates a new discovery service func NewService(wsHub *websocket.Hub, interval time.Duration, subnet string, cfgProvider func() config.DiscoveryConfig) *Service { if interval == 0 { interval = 5 * time.Minute // Default to 5 minutes } if subnet == "" { subnet = "auto" } if cfgProvider == nil { cfgProvider = func() config.DiscoveryConfig { return config.DefaultDiscoveryConfig() } } return &Service{ scanner: pkgdiscovery.NewScanner(), wsHub: wsHub, cache: &DiscoveryCache{}, interval: interval, subnet: subnet, stopChan: make(chan struct{}), cfgProvider: cfgProvider, history: make([]historyEntry, 0, defaultHistoryLimit), historyLimit: defaultHistoryLimit, scannerFactory: func(cfg config.DiscoveryConfig) (discoveryScanner, error) { return BuildScanner(cfg) }, } } // Start begins the background discovery service func (s *Service) Start(ctx context.Context) { s.ctx = ctx log.Info(). Dur("interval", s.interval). Str("subnet", s.subnet). Msg("Starting background discovery service") // Do initial scan immediately // Initial scan with panic recovery go func() { defer func() { if r := recover(); r != nil { log.Error(). Interface("panic", r). Stack(). Msg("Recovered from panic in initial discovery scan") } }() s.performScan() }() // Start background scanning loop with panic recovery go func() { defer func() { if r := recover(); r != nil { log.Error(). Interface("panic", r). Stack(). Msg("Recovered from panic in discovery scan loop") } }() s.scanLoop() }() } // Stop stops the background discovery service func (s *Service) Stop() { close(s.stopChan) } // scanLoop runs periodic scans func (s *Service) scanLoop() { ticker := time.NewTicker(s.interval) defer ticker.Stop() for { select { case <-ticker.C: s.performScan() case <-s.stopChan: log.Info().Msg("Stopping background discovery service") return case <-s.ctx.Done(): log.Info().Msg("Background discovery service context cancelled") return } } } func (s *Service) appendHistory(entry historyEntry) { s.mu.Lock() defer s.mu.Unlock() if s.historyLimit <= 0 { s.historyLimit = defaultHistoryLimit } s.history = append(s.history, entry) if len(s.history) > s.historyLimit { offset := len(s.history) - s.historyLimit s.history = append([]historyEntry(nil), s.history[offset:]...) } } // GetHistory returns up to limit recent discovery history entries (most recent first). func (s *Service) GetHistory(limit int) []historyEntry { s.mu.RLock() defer s.mu.RUnlock() if limit <= 0 || limit > len(s.history) { limit = len(s.history) } if limit == 0 { return nil } result := make([]historyEntry, 0, limit) for i := len(s.history) - 1; i >= 0 && len(result) < limit; i-- { result = append(result, s.history[i]) } return result } // performScan executes a network scan func (s *Service) performScan() { startTime := time.Now() var scanErr error var blocklistLength int s.mu.Lock() if s.isScanning { s.mu.Unlock() log.Debug().Msg("Discovery scan already in progress, skipping") return } s.isScanning = true s.mu.Unlock() var result *pkgdiscovery.DiscoveryResult defer func() { duration := time.Since(startTime) completedAt := time.Now() serverCount := 0 errorCount := 0 status := "success" if result != nil { serverCount = len(result.Servers) if len(result.StructuredErrors) > 0 { errorCount = len(result.StructuredErrors) } else { errorCount = len(result.Errors) } } if scanErr != nil { if result == nil || serverCount == 0 { status = "failure" } else { status = "partial" } } discoveryScanDuration.Observe(duration.Seconds()) discoveryScanServers.Set(float64(serverCount)) discoveryScanErrors.Set(float64(errorCount)) discoveryScanResults.WithLabelValues(status).Inc() s.appendHistory(historyEntry{ startedAt: startTime, completedAt: completedAt, subnet: s.subnet, serverCount: serverCount, errorCount: errorCount, duration: duration, blocklistLength: blocklistLength, status: status, }) s.mu.Lock() s.isScanning = false s.lastScan = completedAt s.mu.Unlock() // Send scan complete notification if s.wsHub != nil { data := map[string]interface{}{ "scanning": false, "timestamp": completedAt.Unix(), } if result != nil && result.Environment != nil { data["environment"] = result.Environment } s.wsHub.Broadcast(websocket.Message{ Type: "discovery_complete", Data: data, }) } }() log.Info().Str("subnet", s.subnet).Msg("Starting background discovery scan") // Send scan started notification if s.wsHub != nil { s.wsHub.Broadcast(websocket.Message{ Type: "discovery_started", Data: map[string]interface{}{ "scanning": true, "subnet": s.subnet, "timestamp": time.Now().Unix(), }, }) } // Create a context with timeout for the scan // Scanning multiple subnets takes longer, allow 2 minutes scanCtx, cancel := context.WithTimeout(s.ctx, 2*time.Minute) defer cancel() cfg := config.NormalizeDiscoveryConfig(config.DefaultDiscoveryConfig()) if s.cfgProvider != nil { cfg = config.NormalizeDiscoveryConfig(config.CloneDiscoveryConfig(s.cfgProvider())) } blocklistLength = len(cfg.SubnetBlocklist) var ( newScanner discoveryScanner err error ) if s.scannerFactory != nil { newScanner, err = s.scannerFactory(cfg) } else { newScanner, err = BuildScanner(cfg) } if err != nil { log.Warn().Err(err).Msg("Environment detection failed during discovery; falling back to default scanner configuration") newScanner = pkgdiscovery.NewScanner() } if newScanner == nil { log.Warn().Msg("Discovery scanner factory returned nil; using default scanner configuration") newScanner = pkgdiscovery.NewScanner() } s.mu.Lock() s.scanner = newScanner s.mu.Unlock() // Perform the scan with real-time callbacks serverCallback := func(server pkgdiscovery.DiscoveredServer, phase string) { // Send immediate update for each discovered server if s.wsHub != nil { s.wsHub.Broadcast(websocket.Message{ Type: "discovery_server_found", Data: map[string]interface{}{ "server": server, "phase": phase, "timestamp": time.Now().Unix(), }, }) log.Debug(). Str("phase", phase). Str("ip", server.IP). Str("type", server.Type). Msg("Broadcasting discovered server to clients") } } progressCallback := func(progress pkgdiscovery.ScanProgress) { // Send progress update via WebSocket if s.wsHub != nil { s.wsHub.Broadcast(websocket.Message{ Type: "discovery_progress", Data: map[string]interface{}{ "progress": progress, "timestamp": time.Now().Unix(), }, }) } } result, err = newScanner.DiscoverServersWithCallbacks(scanCtx, s.subnet, serverCallback, progressCallback) scanErr = err if err != nil { // Even if scan timed out, we might have partial results if result == nil || (len(result.Servers) == 0 && !errors.Is(err, context.DeadlineExceeded)) { log.Error().Err(err).Msg("Background discovery scan failed") return } log.Warn(). Err(err). Int("servers_found", len(result.Servers)). Msg("Discovery scan incomplete but found some servers") } // Always update cache with results (even if empty) to prevent stale data if result != nil { s.cache.mu.Lock() s.cache.result = result s.cache.updated = time.Now() s.cache.mu.Unlock() if result.Environment != nil { log.Info(). Str("environment", result.Environment.Type). Float64("confidence", result.Environment.Confidence). Int("phases", len(result.Environment.Phases)). Msg("Environment detection summary") } log.Info(). Int("servers", len(result.Servers)). Int("errors", len(result.Errors)). Msg("Background discovery scan completed") // Send final update via WebSocket with all servers if s.wsHub != nil { data := map[string]interface{}{ "servers": result.Servers, "errors": result.Errors, // Legacy format (deprecated) "structured_errors": result.StructuredErrors, // New structured format "scanning": false, "timestamp": time.Now().Unix(), } if result.Environment != nil { data["environment"] = result.Environment } s.wsHub.Broadcast(websocket.Message{ Type: "discovery_update", Data: data, }) } } } // GetCachedResult returns the cached discovery result func (s *Service) GetCachedResult() (*pkgdiscovery.DiscoveryResult, time.Time) { s.cache.mu.RLock() defer s.cache.mu.RUnlock() if s.cache.result == nil { return &pkgdiscovery.DiscoveryResult{ Servers: []pkgdiscovery.DiscoveredServer{}, Errors: []string{}, }, time.Time{} } return s.cache.result, s.cache.updated } // IsScanning returns whether a scan is currently in progress func (s *Service) IsScanning() bool { s.mu.RLock() defer s.mu.RUnlock() return s.isScanning } // ForceRefresh triggers an immediate scan func (s *Service) ForceRefresh() { // Check if scan is already in progress to prevent goroutine leak s.mu.RLock() if s.isScanning { s.mu.RUnlock() log.Debug().Msg("Scan already in progress, skipping ForceRefresh") return } s.mu.RUnlock() go func() { defer func() { if r := recover(); r != nil { log.Error(). Interface("panic", r). Stack(). Msg("Recovered from panic in ForceRefresh scan") } }() s.performScan() }() } // SetInterval updates the scan interval func (s *Service) SetInterval(interval time.Duration) { s.mu.Lock() defer s.mu.Unlock() s.interval = interval log.Info().Dur("interval", interval).Msg("Updated discovery scan interval") } // SetSubnet updates the subnet to scan func (s *Service) SetSubnet(subnet string) { s.mu.Lock() s.subnet = subnet // Check if scan is already in progress to prevent goroutine leak alreadyScanning := s.isScanning s.mu.Unlock() log.Info().Str("subnet", subnet).Msg("Updated discovery subnet") // Trigger immediate rescan with new subnet if not already scanning if !alreadyScanning { go func() { defer func() { if r := recover(); r != nil { log.Error(). Interface("panic", r). Stack(). Msg("Recovered from panic in SetSubnet scan") } }() s.performScan() }() } else { log.Debug().Msg("Scan already in progress, new subnet will be used in next scan") } } // GetStatus returns the current service status func (s *Service) GetStatus() map[string]interface{} { s.mu.RLock() defer s.mu.RUnlock() return map[string]interface{}{ "is_scanning": s.isScanning, "last_scan": s.lastScan, "interval": s.interval.String(), "subnet": s.subnet, } }