- cmd/serve.go: starts scheduler, optionally starts Telegram bot with --telegram flag - cmd/schedule.go: add/list/remove/run subcommands for scheduled scan job CRUD - pkg/scheduler/: gocron v2 based scheduler with DB-backed jobs and scan execution - pkg/storage/scheduled_jobs.go: scheduled_jobs table CRUD with tests - Remove serve and schedule stubs from cmd/stubs.go Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
171 lines
4.4 KiB
Go
171 lines
4.4 KiB
Go
// 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
|
|
}
|