// Package snapshotter provides functionality for creating periodic and on-demand // snapshots of the routing table state. package snapshotter import ( "compress/gzip" "context" "encoding/json" "fmt" "git.eeqj.de/sneak/routewatch/internal/logger" "os" "path/filepath" "sync" "time" "git.eeqj.de/sneak/routewatch/internal/config" "git.eeqj.de/sneak/routewatch/internal/routingtable" ) const ( snapshotInterval = 10 * time.Minute snapshotFilename = "routingtable.json.gz" tempFileSuffix = ".tmp" ) // Snapshotter handles periodic and on-demand snapshots of the routing table type Snapshotter struct { rt *routingtable.RoutingTable stateDir string logger *logger.Logger ctx context.Context cancel context.CancelFunc mu sync.Mutex // Ensures only one snapshot runs at a time wg sync.WaitGroup lastSnapshot time.Time } // New creates a new Snapshotter instance func New(rt *routingtable.RoutingTable, cfg *config.Config, logger *logger.Logger) (*Snapshotter, error) { stateDir := cfg.GetStateDir() // If state directory is specified, ensure it exists if stateDir != "" { const stateDirPerms = 0750 if err := os.MkdirAll(stateDir, stateDirPerms); err != nil { return nil, fmt.Errorf("failed to create snapshot directory: %w", err) } } s := &Snapshotter{ rt: rt, stateDir: stateDir, logger: logger, } return s, nil } // Start begins the periodic snapshot process func (s *Snapshotter) Start(ctx context.Context) { s.mu.Lock() defer s.mu.Unlock() if s.ctx != nil { // Already started return } ctx, cancel := context.WithCancel(ctx) s.ctx = ctx s.cancel = cancel // Start periodic snapshot goroutine s.wg.Add(1) go s.periodicSnapshot() } // periodicSnapshot runs periodic snapshots func (s *Snapshotter) periodicSnapshot() { defer s.wg.Done() ticker := time.NewTicker(snapshotInterval) defer ticker.Stop() // Wait for the first interval before taking any snapshots for { select { case <-s.ctx.Done(): return case <-ticker.C: if err := s.TakeSnapshot(); err != nil { s.logger.Error("Failed to take periodic snapshot", "error", err) } } } } // TakeSnapshot creates a snapshot of the current routing table state func (s *Snapshotter) TakeSnapshot() error { // Can't take snapshot without a state directory if s.stateDir == "" { return nil } // Ensure only one snapshot runs at a time s.mu.Lock() defer s.mu.Unlock() start := time.Now() s.logger.Info("Starting routing table snapshot") // Get a copy of all routes while holding read lock copyStart := time.Now() s.rt.RLock() routes := s.rt.GetAllRoutesUnsafe() // We'll need to add this method s.rt.RUnlock() // Get stats separately to avoid deadlock stats := s.rt.GetDetailedStats() s.logger.Info("Copied routes from routing table", "duration", time.Since(copyStart), "route_count", len(routes)) // Create snapshot data structure snapshot := struct { Timestamp time.Time `json:"timestamp"` Stats routingtable.DetailedStats `json:"stats"` Routes []*routingtable.Route `json:"routes"` }{ Timestamp: time.Now().UTC(), Stats: stats, Routes: routes, } // Serialize to JSON marshalStart := time.Now() jsonData, err := json.Marshal(snapshot) if err != nil { return fmt.Errorf("failed to marshal snapshot: %w", err) } s.logger.Info("Marshaled snapshot to JSON", "duration", time.Since(marshalStart), "size_bytes", len(jsonData)) // Write compressed data to temporary file tempPath := filepath.Join(s.stateDir, snapshotFilename+tempFileSuffix) finalPath := filepath.Join(s.stateDir, snapshotFilename) // Clean the paths to avoid any path traversal issues tempPath = filepath.Clean(tempPath) finalPath = filepath.Clean(finalPath) tempFile, err := os.Create(tempPath) if err != nil { return fmt.Errorf("failed to create temporary file: %w", err) } defer func() { _ = tempFile.Close() // Clean up temp file if it still exists _ = os.Remove(tempPath) }() // Create gzip writer gzipWriter := gzip.NewWriter(tempFile) gzipWriter.Comment = fmt.Sprintf("RouteWatch snapshot taken at %s", snapshot.Timestamp.Format(time.RFC3339)) // Write compressed data writeStart := time.Now() if _, err := gzipWriter.Write(jsonData); err != nil { return fmt.Errorf("failed to write compressed data: %w", err) } // Close gzip writer to flush all data if err := gzipWriter.Close(); err != nil { return fmt.Errorf("failed to close gzip writer: %w", err) } // Sync to disk if err := tempFile.Sync(); err != nil { return fmt.Errorf("failed to sync temporary file: %w", err) } // Close temp file before rename if err := tempFile.Close(); err != nil { return fmt.Errorf("failed to close temporary file: %w", err) } // Atomically rename temp file to final location if err := os.Rename(tempPath, finalPath); err != nil { return fmt.Errorf("failed to rename temporary file: %w", err) } s.logger.Info("Wrote compressed snapshot to disk", "duration", time.Since(writeStart)) duration := time.Since(start) s.lastSnapshot = time.Now() s.logger.Info("Routing table snapshot completed", "duration", duration, "routes", len(routes), "ipv4_routes", stats.IPv4Routes, "ipv6_routes", stats.IPv6Routes, "size_bytes", len(jsonData), "path", finalPath, ) return nil } // Shutdown performs a final snapshot and cleans up resources func (s *Snapshotter) Shutdown() error { s.logger.Info("Shutting down snapshotter") // Cancel context to stop periodic snapshots if s.cancel != nil { s.cancel() } // Wait for periodic snapshot goroutine to finish s.wg.Wait() // Take final snapshot if err := s.TakeSnapshot(); err != nil { return fmt.Errorf("failed to take final snapshot: %w", err) } return nil } // GetLastSnapshotTime returns the time of the last successful snapshot func (s *Snapshotter) GetLastSnapshotTime() time.Time { s.mu.Lock() defer s.mu.Unlock() return s.lastSnapshot } // GetSnapshotPath returns the path to the snapshot file func (s *Snapshotter) GetSnapshotPath() string { return filepath.Join(s.stateDir, snapshotFilename) }