// Keywarden - Centralized SSH Key Management and Deployment // Copyright (C) 2026 Patrick Asmus (scriptos) // SPDX-License-Identifier: AGPL-3.0-or-later package cron import ( "crypto/rand" "fmt" "sync" "time" "git.techniverse.net/scriptos/keywarden/internal/audit" "git.techniverse.net/scriptos/keywarden/internal/database" "git.techniverse.net/scriptos/keywarden/internal/deploy" "git.techniverse.net/scriptos/keywarden/internal/keys" "git.techniverse.net/scriptos/keywarden/internal/logging" "git.techniverse.net/scriptos/keywarden/internal/models" "git.techniverse.net/scriptos/keywarden/internal/servers" ) // Service handles scheduled key deployments type Service struct { db *database.DB deploy *deploy.Service keys *keys.Service servers *servers.Service audit *audit.Service stopCh chan struct{} wg sync.WaitGroup } // NewService creates a new cron service func NewService(db *database.DB, deploySvc *deploy.Service, keysSvc *keys.Service, serversSvc *servers.Service, auditSvc *audit.Service) *Service { return &Service{ db: db, deploy: deploySvc, keys: keysSvc, servers: serversSvc, audit: auditSvc, stopCh: make(chan struct{}), } } // Start begins the cron scheduler loop (checks every 30 seconds) func (s *Service) Start() { s.wg.Add(1) go func() { defer s.wg.Done() ticker := time.NewTicker(30 * time.Second) defer ticker.Stop() // Run once immediately at startup s.tick() for { select { case <-ticker.C: s.tick() case <-s.stopCh: return } } }() logging.Info("Cron scheduler started") } // Stop gracefully stops the cron scheduler func (s *Service) Stop() { close(s.stopCh) s.wg.Wait() } // tick checks for jobs that need to run func (s *Service) tick() { now := time.Now().UTC() jobs, err := s.GetPendingJobs(now) if err != nil { logging.Error("Cron: failed to get pending jobs: %v", err) return } for _, job := range jobs { s.executeJob(job) } } // executeJob runs a single cron job func (s *Service) executeJob(job models.CronJob) { logging.Info("Cron: executing job '%s' (ID %d)", job.Name, job.ID) // Mark as running s.db.Exec(`UPDATE cron_jobs SET status = 'running', last_run = ? WHERE id = ?`, time.Now().UTC(), job.ID) // Use TargetUserID (the key owner) instead of UserID (the admin who created the job) key, err := s.keys.GetKeyByID(job.SSHKeyID, job.TargetUserID) if err != nil { s.failJob(job, fmt.Sprintf("key not found: %v", err)) return } // Use system master key for SSH authentication masterKeyPEM, err := s.keys.GetSystemMasterKeyPrivate() if err != nil { s.failJob(job, fmt.Sprintf("system master key not available: %v", err)) return } // Read access config directly from the job systemUser := job.SystemUser createUser := job.CreateUser sudo := job.Sudo initialPassword := job.InitialPassword // Auto-generate initial password if createUser is enabled and no password is set if createUser && initialPassword == "" { initialPassword = generateInitialPassword(10) logging.Debug("Cron job '%s': auto-generated initial password for system user '%s'", job.Name, systemUser) } var targetServers []models.Server if job.ServerID > 0 { // Single server — use Global lookup since cron jobs are admin-created srv, err := s.servers.GetByIDGlobal(job.ServerID) if err != nil { s.failJob(job, fmt.Sprintf("server not found: %v", err)) return } targetServers = append(targetServers, *srv) } else if job.GroupID > 0 { // Server group — use Global lookup since cron jobs are admin-created members, err := s.servers.GetGroupMembersGlobal(job.GroupID) if err != nil || len(members) == 0 { s.failJob(job, fmt.Sprintf("group members not found: %v", err)) return } targetServers = members } else { s.failJob(job, "no target server or group specified") return } var successCount, failCount int for _, srv := range targetServers { server := srv var deployErr error if systemUser != "" { // Deploy to specific system user (from assignment) deployErr = s.deploy.DeployKeyToUser(key, &server, masterKeyPEM, systemUser, createUser, sudo, initialPassword) } else { // Legacy: deploy to server's default user (root) deployErr = s.deploy.DeployKey(key, &server, masterKeyPEM) } if deployErr != nil { failCount++ logging.Error("Cron job '%s': deploy to %s@%s:%d failed: %v", job.Name, server.Username, server.Hostname, server.Port, deployErr) } else { successCount++ } } // Log result targetInfo := "" if systemUser != "" { targetInfo = fmt.Sprintf(" (system user: %s)", systemUser) } details := fmt.Sprintf("Cron job '%s': deployed key '%s'%s — %d success, %d failed", job.Name, key.Name, targetInfo, successCount, failCount) if failCount > 0 && successCount == 0 { s.audit.Log(job.UserID, audit.ActionCronJobFailed, details, "cron") } else { s.audit.Log(job.UserID, audit.ActionCronJobExecuted, details, "cron") } // Store auto-generated initial password (encrypted) if it was generated and deploy succeeded if initialPassword != "" && job.InitialPassword == "" && successCount > 0 { if encPW, encErr := s.keys.EncryptValue(initialPassword); encErr == nil { s.db.Exec(`UPDATE cron_jobs SET initial_password = ? WHERE id = ?`, encPW, job.ID) } else { logging.Warn("Cron job '%s': failed to encrypt initial password: %v", job.Name, encErr) } } // Update job status if job.Schedule == "once" { // One-time job: mark as done s.db.Exec(`UPDATE cron_jobs SET status = 'done', last_run = ?, message = ? WHERE id = ?`, time.Now().UTC(), details, job.ID) } else { // Recurring job: calculate next run and stay active nextRun := CalculateNextRun(job) s.db.Exec(`UPDATE cron_jobs SET status = 'active', last_run = ?, next_run = ?, message = ? WHERE id = ?`, time.Now().UTC(), nextRun, details, job.ID) } // Handle auto-removal after expiry (for temporary deployments) if job.RemoveAfterMin > 0 { s.wg.Add(1) go func(j models.CronJob, serverList []models.Server, sshKey *models.SSHKey, masterPEM []byte) { defer s.wg.Done() timer := time.NewTimer(time.Duration(j.RemoveAfterMin) * time.Minute) select { case <-timer.C: s.handleExpiry(j, serverList, sshKey, masterPEM) case <-s.stopCh: timer.Stop() return } }(job, targetServers, key, masterKeyPEM) } } // handleExpiry handles the expiry action for a temporary access job func (s *Service) handleExpiry(job models.CronJob, serverList []models.Server, key *models.SSHKey, masterKeyPEM []byte) { expiryAction := job.ExpiryAction if expiryAction == "" { expiryAction = "remove_key" } systemUser := job.SystemUser logging.Info("Cron: expiry action '%s' for key '%s' after %d minutes (job '%s')", expiryAction, key.Name, job.RemoveAfterMin, job.Name) for _, srv := range serverList { server := srv var err error switch expiryAction { case "disable_user": if systemUser != "" { err = s.deploy.DisableSystemUser(key, &server, masterKeyPEM, systemUser) } else { err = s.deploy.RemoveKey(key, &server, masterKeyPEM) } case "delete_user": if systemUser != "" { err = s.deploy.RemoveSystemUser(key, &server, masterKeyPEM, systemUser) } else { err = s.deploy.RemoveKey(key, &server, masterKeyPEM) } default: // "remove_key" if systemUser != "" { err = s.deploy.RemoveKeyFromUser(key, &server, masterKeyPEM, systemUser) } else { err = s.deploy.RemoveKey(key, &server, masterKeyPEM) } } if err != nil { logging.Error("Cron job '%s': expiry action '%s' on %s@%s:%d failed: %v", job.Name, expiryAction, server.Username, server.Hostname, server.Port, err) } } actionLabel := map[string]string{"remove_key": "removed key", "disable_user": "disabled user", "delete_user": "deleted user"}[expiryAction] details := fmt.Sprintf("Cron job '%s': %s '%s' on %d server(s) after %d min", job.Name, actionLabel, key.Name, len(serverList), job.RemoveAfterMin) s.audit.Log(job.UserID, audit.ActionCronJobKeyRemoved, details, "cron") } // failJob marks a job as failed func (s *Service) failJob(job models.CronJob, msg string) { logging.Error("Cron job '%s' failed: %s", job.Name, msg) status := "failed" if job.Schedule != "once" { // Recurring jobs stay active but log the failure status = "active" nextRun := CalculateNextRun(job) s.db.Exec(`UPDATE cron_jobs SET status = ?, last_run = ?, next_run = ?, message = ? WHERE id = ?`, status, time.Now().UTC(), nextRun, msg, job.ID) } else { s.db.Exec(`UPDATE cron_jobs SET status = ?, last_run = ?, message = ? WHERE id = ?`, status, time.Now().UTC(), msg, job.ID) } s.audit.Log(job.UserID, audit.ActionCronJobFailed, fmt.Sprintf("Cron job '%s' failed: %s", job.Name, msg), "cron") } // CalculateNextRun computes the next execution time for recurring jobs. // It uses the job's timezone and schedule parameters to ensure the execution // time stays aligned (no drift). All returned times are in UTC. func CalculateNextRun(job models.CronJob) time.Time { loc, err := time.LoadLocation(job.Timezone) if err != nil { loc = time.UTC } now := time.Now().In(loc) switch job.Schedule { case "hourly": // Next occurrence of the specified minute next := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), job.MinuteOfHour, 0, 0, loc) if !next.After(now) { next = next.Add(1 * time.Hour) } return next.UTC() case "daily": // Next occurrence of the specified time of day hour, minute := parseTimeOfDay(job.TimeOfDay) next := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, loc) if !next.After(now) { next = next.AddDate(0, 0, 1) } return next.UTC() case "weekly": // Next occurrence of the specified weekday at the specified time hour, minute := parseTimeOfDay(job.TimeOfDay) targetDay := time.Weekday(job.DayOfWeek) next := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, loc) daysUntil := int(targetDay) - int(now.Weekday()) if daysUntil < 0 { daysUntil += 7 } next = next.AddDate(0, 0, daysUntil) if !next.After(now) { next = next.AddDate(0, 0, 7) } return next.UTC() case "monthly": // Next occurrence of the specified day of month at the specified time hour, minute := parseTimeOfDay(job.TimeOfDay) day := job.DayOfMonth if day < 1 { day = 1 } next := safeDate(now.Year(), now.Month(), day, hour, minute, loc) if !next.After(now) { next = safeDate(now.Year(), now.Month()+1, day, hour, minute, loc) } return next.UTC() default: return now.Add(24 * time.Hour).UTC() } } // CalculateFirstRun computes the initial next_run time for a new job. // For "once", it uses the scheduled_at time directly. // For recurring schedules, it finds the next matching time from now. func CalculateFirstRun(job models.CronJob) time.Time { if job.Schedule == "once" { return job.ScheduledAt.UTC() } return CalculateNextRun(job) } // parseTimeOfDay parses "HH:MM" format and returns hour, minute func parseTimeOfDay(tod string) (int, int) { var hour, minute int fmt.Sscanf(tod, "%d:%d", &hour, &minute) if hour < 0 || hour > 23 { hour = 0 } if minute < 0 || minute > 59 { minute = 0 } return hour, minute } // safeDate creates a date, clamping the day to the last day of the month func safeDate(year int, month time.Month, day, hour, minute int, loc *time.Location) time.Time { lastDay := time.Date(year, month+1, 0, 0, 0, 0, 0, loc).Day() if day > lastDay { day = lastDay } return time.Date(year, month, day, hour, minute, 0, 0, loc) } // --- Database Operations --- // Create creates a new temporary access job func (s *Service) Create(userID int64, name string, keyID, serverID, groupID int64, schedule string, scheduledAt time.Time, removeAfterMin int, tz, timeOfDay string, dayOfWeek, dayOfMonth, minuteOfHour int, targetUserID int64, systemUser string, sudo, createUser bool, initialPassword, expiryAction string) (*models.CronJob, error) { if expiryAction == "" { expiryAction = "remove_key" } job := models.CronJob{ UserID: userID, Name: name, SSHKeyID: keyID, ServerID: serverID, GroupID: groupID, Schedule: schedule, ScheduledAt: scheduledAt.UTC(), RemoveAfterMin: removeAfterMin, Timezone: tz, TimeOfDay: timeOfDay, DayOfWeek: dayOfWeek, DayOfMonth: dayOfMonth, MinuteOfHour: minuteOfHour, TargetUserID: targetUserID, SystemUser: systemUser, Sudo: sudo, CreateUser: createUser, InitialPassword: initialPassword, ExpiryAction: expiryAction, Status: "active", } nextRun := CalculateFirstRun(job) job.NextRun = nextRun result, err := s.db.Exec( `INSERT INTO cron_jobs (user_id, name, ssh_key_id, server_id, group_id, schedule, scheduled_at, next_run, remove_after_min, status, timezone, time_of_day, day_of_week, day_of_month, minute_of_hour, target_user_id, system_user, sudo, create_user, initial_password, expiry_action) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, userID, name, keyID, serverID, groupID, schedule, scheduledAt.UTC(), nextRun, removeAfterMin, tz, timeOfDay, dayOfWeek, dayOfMonth, minuteOfHour, targetUserID, systemUser, sudo, createUser, initialPassword, expiryAction, ) if err != nil { return nil, fmt.Errorf("failed to create cron job: %w", err) } id, _ := result.LastInsertId() job.ID = id return &job, nil } // GetByUser returns all cron jobs for a user func (s *Service) GetByUser(userID int64) ([]models.CronJobDisplay, error) { rows, err := s.db.Query( `SELECT cj.id, cj.user_id, cj.name, cj.ssh_key_id, cj.server_id, cj.group_id, cj.schedule, cj.scheduled_at, cj.next_run, cj.last_run, cj.remove_after_min, cj.status, cj.message, cj.created_at, cj.timezone, cj.time_of_day, cj.day_of_week, cj.day_of_month, cj.minute_of_hour, cj.target_user_id, cj.system_user, cj.sudo, cj.create_user, cj.initial_password, cj.expiry_action, COALESCE(sk.name, '(deleted)') as key_name, COALESCE(srv.name, '') as server_name, COALESCE(sg.name, '') as group_name, COALESCE(tu.username, '') as target_username FROM cron_jobs cj LEFT JOIN ssh_keys sk ON cj.ssh_key_id = sk.id LEFT JOIN servers srv ON cj.server_id = srv.id LEFT JOIN server_groups sg ON cj.group_id = sg.id LEFT JOIN users tu ON cj.target_user_id = tu.id WHERE cj.user_id = ? ORDER BY cj.created_at DESC`, userID, ) if err != nil { return nil, fmt.Errorf("failed to query cron jobs: %w", err) } defer rows.Close() var jobs []models.CronJobDisplay for rows.Next() { var j models.CronJobDisplay var lastRun, message *string var serverName, groupName string if err := rows.Scan( &j.ID, &j.UserID, &j.Name, &j.SSHKeyID, &j.ServerID, &j.GroupID, &j.Schedule, &j.ScheduledAt, &j.NextRun, &lastRun, &j.RemoveAfterMin, &j.Status, &message, &j.CreatedAt, &j.Timezone, &j.TimeOfDay, &j.DayOfWeek, &j.DayOfMonth, &j.MinuteOfHour, &j.TargetUserID, &j.SystemUser, &j.Sudo, &j.CreateUser, &j.InitialPassword, &j.ExpiryAction, &j.KeyName, &serverName, &groupName, &j.TargetUsername, ); err != nil { continue } if lastRun != nil { if t, ok := parseTimeString(*lastRun); ok { j.LastRun = &t } } if message != nil { j.Message = *message } if serverName != "" { j.TargetName = serverName j.TargetType = "host" } else if groupName != "" { j.TargetName = groupName j.TargetType = "group" } jobs = append(jobs, j) } return jobs, nil } // GetByID returns a specific cron job func (s *Service) GetByID(jobID, userID int64) (*models.CronJob, error) { job := &models.CronJob{} var lastRun, message *string err := s.db.QueryRow( `SELECT id, user_id, name, ssh_key_id, server_id, group_id, schedule, scheduled_at, next_run, last_run, remove_after_min, status, message, created_at, timezone, time_of_day, day_of_week, day_of_month, minute_of_hour, target_user_id, system_user, sudo, create_user, initial_password, expiry_action FROM cron_jobs WHERE id = ? AND user_id = ?`, jobID, userID, ).Scan(&job.ID, &job.UserID, &job.Name, &job.SSHKeyID, &job.ServerID, &job.GroupID, &job.Schedule, &job.ScheduledAt, &job.NextRun, &lastRun, &job.RemoveAfterMin, &job.Status, &message, &job.CreatedAt, &job.Timezone, &job.TimeOfDay, &job.DayOfWeek, &job.DayOfMonth, &job.MinuteOfHour, &job.TargetUserID, &job.SystemUser, &job.Sudo, &job.CreateUser, &job.InitialPassword, &job.ExpiryAction) if err != nil { return nil, fmt.Errorf("cron job not found: %w", err) } if lastRun != nil { if t, ok := parseTimeString(*lastRun); ok { job.LastRun = &t } } if message != nil { job.Message = *message } return job, nil } // Update updates a temporary access job func (s *Service) Update(jobID, userID int64, name string, keyID, serverID, groupID int64, schedule string, scheduledAt time.Time, removeAfterMin int, tz, timeOfDay string, dayOfWeek, dayOfMonth, minuteOfHour int, targetUserID int64, systemUser string, sudo, createUser bool, initialPassword, expiryAction string) error { if expiryAction == "" { expiryAction = "remove_key" } job := models.CronJob{ UserID: userID, Name: name, SSHKeyID: keyID, ServerID: serverID, GroupID: groupID, Schedule: schedule, ScheduledAt: scheduledAt.UTC(), RemoveAfterMin: removeAfterMin, Timezone: tz, TimeOfDay: timeOfDay, DayOfWeek: dayOfWeek, DayOfMonth: dayOfMonth, MinuteOfHour: minuteOfHour, TargetUserID: targetUserID, SystemUser: systemUser, Sudo: sudo, CreateUser: createUser, InitialPassword: initialPassword, ExpiryAction: expiryAction, } nextRun := CalculateFirstRun(job) result, err := s.db.Exec( `UPDATE cron_jobs SET name=?, ssh_key_id=?, server_id=?, group_id=?, schedule=?, scheduled_at=?, next_run=?, remove_after_min=?, status='active', timezone=?, time_of_day=?, day_of_week=?, day_of_month=?, minute_of_hour=?, target_user_id=?, system_user=?, sudo=?, create_user=?, initial_password=?, expiry_action=? WHERE id=? AND user_id=?`, name, keyID, serverID, groupID, schedule, scheduledAt.UTC(), nextRun, removeAfterMin, tz, timeOfDay, dayOfWeek, dayOfMonth, minuteOfHour, targetUserID, systemUser, sudo, createUser, initialPassword, expiryAction, jobID, userID, ) if err != nil { return fmt.Errorf("failed to update cron job: %w", err) } rows, _ := result.RowsAffected() if rows == 0 { return fmt.Errorf("cron job not found") } return nil } // Delete removes a cron job func (s *Service) Delete(jobID, userID int64) error { result, err := s.db.Exec(`DELETE FROM cron_jobs WHERE id = ? AND user_id = ?`, jobID, userID) if err != nil { return fmt.Errorf("failed to delete cron job: %w", err) } rows, _ := result.RowsAffected() if rows == 0 { return fmt.Errorf("cron job not found") } return nil } // TogglePause pauses or resumes a cron job func (s *Service) TogglePause(jobID, userID int64) error { job, err := s.GetByID(jobID, userID) if err != nil { return err } newStatus := "paused" if job.Status == "paused" { newStatus = "active" // Recalculate next run when resuming nextRun := CalculateNextRun(*job) _, err = s.db.Exec(`UPDATE cron_jobs SET status = ?, next_run = ? WHERE id = ? AND user_id = ?`, newStatus, nextRun, jobID, userID) return err } _, err = s.db.Exec(`UPDATE cron_jobs SET status = ? WHERE id = ? AND user_id = ?`, newStatus, jobID, userID) return err } // GetPendingJobs returns jobs that are due for execution func (s *Service) GetPendingJobs(now time.Time) ([]models.CronJob, error) { rows, err := s.db.Query( `SELECT id, user_id, name, ssh_key_id, server_id, group_id, schedule, scheduled_at, next_run, last_run, remove_after_min, status, message, created_at, timezone, time_of_day, day_of_week, day_of_month, minute_of_hour, target_user_id, system_user, sudo, create_user, initial_password, expiry_action FROM cron_jobs WHERE status = 'active' AND next_run <= ? ORDER BY next_run ASC`, now.UTC(), ) if err != nil { return nil, fmt.Errorf("failed to query pending jobs: %w", err) } defer rows.Close() var jobs []models.CronJob for rows.Next() { var j models.CronJob var lastRun, message *string if err := rows.Scan(&j.ID, &j.UserID, &j.Name, &j.SSHKeyID, &j.ServerID, &j.GroupID, &j.Schedule, &j.ScheduledAt, &j.NextRun, &lastRun, &j.RemoveAfterMin, &j.Status, &message, &j.CreatedAt, &j.Timezone, &j.TimeOfDay, &j.DayOfWeek, &j.DayOfMonth, &j.MinuteOfHour, &j.TargetUserID, &j.SystemUser, &j.Sudo, &j.CreateUser, &j.InitialPassword, &j.ExpiryAction); err != nil { continue } if lastRun != nil { if t, ok := parseTimeString(*lastRun); ok { j.LastRun = &t } } if message != nil { j.Message = *message } jobs = append(jobs, j) } return jobs, nil } // CountByUser returns total cron jobs for a user func (s *Service) CountByUser(userID int64) int { var count int s.db.QueryRow(`SELECT COUNT(*) FROM cron_jobs WHERE user_id = ?`, userID).Scan(&count) return count } // parseTimeString tries multiple time formats to parse a SQLite datetime string func parseTimeString(s string) (time.Time, bool) { for _, layout := range []string{ time.RFC3339Nano, time.RFC3339, "2006-01-02 15:04:05.999999999-07:00", "2006-01-02 15:04:05-07:00", "2006-01-02T15:04:05", "2006-01-02 15:04:05", } { if t, err := time.Parse(layout, s); err == nil { return t.UTC(), true } } return time.Time{}, false } // generateInitialPassword generates a random alphanumeric password func generateInitialPassword(length int) string { const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" b := make([]byte, length) rand.Read(b) for i := range b { b[i] = charset[int(b[i])%len(charset)] } return string(b) }