// Package scheduler implements cron-based recurring scan scheduling for KeyHunter. // It uses gocron v2 for job management and delegates scan execution to the engine. package scheduler import ( "context" "fmt" "log" "sync" "time" "github.com/go-co-op/gocron/v2" "github.com/salvacybersec/keyhunter/pkg/engine" "github.com/salvacybersec/keyhunter/pkg/storage" ) // Scheduler manages recurring scan jobs backed by the database. type Scheduler struct { cron gocron.Scheduler engine *engine.Engine db *storage.DB encKey []byte mu sync.Mutex jobs map[int64]gocron.Job // DB job ID -> gocron job // OnFindings is called when a scheduled scan produces findings. // The caller can wire this to Telegram notifications. OnFindings func(jobName string, findings []engine.Finding) } // Deps bundles the dependencies for creating a Scheduler. type Deps struct { Engine *engine.Engine DB *storage.DB EncKey []byte } // New creates a new Scheduler. Call Start() to begin processing jobs. func New(deps Deps) (*Scheduler, error) { cron, err := gocron.NewScheduler() if err != nil { return nil, fmt.Errorf("creating gocron scheduler: %w", err) } return &Scheduler{ cron: cron, engine: deps.Engine, db: deps.DB, encKey: deps.EncKey, jobs: make(map[int64]gocron.Job), }, nil } // LoadAndStart loads all enabled jobs from the database, registers them // with gocron, and starts the scheduler. func (s *Scheduler) LoadAndStart() error { jobs, err := s.db.ListEnabledScheduledJobs() if err != nil { return fmt.Errorf("loading scheduled jobs: %w", err) } for _, j := range jobs { if err := s.registerJob(j); err != nil { log.Printf("scheduler: failed to register job %d (%s): %v", j.ID, j.Name, err) } } s.cron.Start() return nil } // Stop shuts down the scheduler gracefully. func (s *Scheduler) Stop() error { return s.cron.Shutdown() } // AddJob registers a new job from a storage.ScheduledJob and adds it to the // running scheduler. func (s *Scheduler) AddJob(job storage.ScheduledJob) error { return s.registerJob(job) } // RemoveJob removes a job from the running scheduler by its DB ID. func (s *Scheduler) RemoveJob(id int64) { s.mu.Lock() defer s.mu.Unlock() if j, ok := s.jobs[id]; ok { s.cron.RemoveJob(j.ID()) delete(s.jobs, id) } } // RunNow executes a job immediately (outside of its cron schedule). func (s *Scheduler) RunNow(job storage.ScheduledJob) ([]engine.Finding, error) { return s.executeScan(job) } // registerJob adds a single scheduled job to gocron. func (s *Scheduler) registerJob(job storage.ScheduledJob) error { jobCopy := job // capture for closure cronJob, err := s.cron.NewJob( gocron.CronJob(job.CronExpr, false), gocron.NewTask(func() { findings, err := s.executeScan(jobCopy) if err != nil { log.Printf("scheduler: job %d (%s) failed: %v", jobCopy.ID, jobCopy.Name, err) return } // Update last run time in DB. if err := s.db.UpdateJobLastRun(jobCopy.ID, time.Now()); err != nil { log.Printf("scheduler: failed to update last_run for job %d: %v", jobCopy.ID, err) } if len(findings) > 0 && jobCopy.Notify && s.OnFindings != nil { s.OnFindings(jobCopy.Name, findings) } }), ) if err != nil { return fmt.Errorf("registering cron job %q (%s): %w", job.Name, job.CronExpr, err) } s.mu.Lock() s.jobs[job.ID] = cronJob s.mu.Unlock() return nil } // executeScan runs a scan against the job's configured path and persists findings. func (s *Scheduler) executeScan(job storage.ScheduledJob) ([]engine.Finding, error) { src, err := selectSchedulerSource(job.ScanPath) if err != nil { return nil, fmt.Errorf("selecting source for %q: %w", job.ScanPath, err) } cfg := engine.ScanConfig{ Workers: 0, // auto Verify: false, Unmask: false, } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) defer cancel() ch, err := s.engine.Scan(ctx, src, cfg) if err != nil { return nil, fmt.Errorf("starting scan: %w", err) } var findings []engine.Finding for f := range ch { findings = append(findings, f) } // Persist findings. for _, f := range findings { sf := storage.Finding{ ProviderName: f.ProviderName, KeyValue: f.KeyValue, KeyMasked: f.KeyMasked, Confidence: f.Confidence, SourcePath: f.Source, SourceType: f.SourceType, LineNumber: f.LineNumber, } if _, err := s.db.SaveFinding(sf, s.encKey); err != nil { log.Printf("scheduler: failed to save finding: %v", err) } } return findings, nil }