// Package cron reads every scheduled job on the host into one list: /etc/crontab, // /etc/cron.d/*, per-user crontabs, the run-parts directories they drive, and systemd // timers. It computes next runs from the cron expressions, pulls last runs from the // journal (cron logs "(user) CMD (command)" via syslog), and can toggle, edit and run // jobs. Reads/writes that need root go through passwordless sudo when available. package cron import ( "bufio" "bytes" "context" "crypto/sha1" "encoding/hex" "encoding/json" "errors" "fmt" "os" "os/exec" "os/user" "path/filepath" "regexp" "sort" "strconv" "strings" "sync" "time" ) type Job struct { ID string `json:"id"` Source string `json:"source"` // "/etc/crontab", "/etc/cron.d/x", "crontab:user", "/etc/cron.daily" Line int `json:"line"` // 1-based line in Source (0 for run-parts scripts) User string `json:"user"` Schedule string `json:"schedule"` Command string `json:"command"` Enabled bool `json:"enabled"` Next int64 `json:"next,omitempty"` // unix ms Last int64 `json:"last,omitempty"` OutFile string `json:"out_file,omitempty"` // parsed from a ">> file" redirect Note string `json:"note,omitempty"` Editable bool `json:"editable"` raw string } type Timer struct { Unit string `json:"unit"` Activates string `json:"activates"` Description string `json:"description"` Active string `json:"active"` Sub string `json:"sub"` Enabled string `json:"enabled"` Next int64 `json:"next,omitempty"` Last int64 `json:"last,omitempty"` } type Overview struct { Jobs []Job `json:"jobs"` Timers []Timer `json:"timers"` Logging bool `json:"logging"` // cron lines seen in the journal Anacron bool `json:"anacron"` Sudo bool `json:"sudo"` } type Run struct { ID string `json:"id"` JobID string `json:"job_id"` Command string `json:"command"` User string `json:"user"` Started time.Time `json:"started"` Finished time.Time `json:"finished,omitempty"` Running bool `json:"running"` ExitCode int `json:"exit_code"` Output string `json:"output"` mu sync.Mutex buf bytes.Buffer } type Manager struct { sudo bool journalSudo bool self string mu sync.Mutex lastRuns map[string]int64 // user\x00command -> unix ms lastAt time.Time logging bool runs map[string]*Run } func New(sudo, journalSudo bool) *Manager { m := &Manager{sudo: sudo, journalSudo: journalSudo, runs: map[string]*Run{}} if u, err := user.Current(); err == nil { m.self = u.Username } return m } // ---- helpers ---- func run(ctx context.Context, stdin string, args ...string) (string, error) { ctx, cancel := context.WithTimeout(ctx, 20*time.Second) defer cancel() cmd := exec.CommandContext(ctx, args[0], args[1:]...) if stdin != "" { cmd.Stdin = strings.NewReader(stdin) } var out, errb bytes.Buffer cmd.Stdout, cmd.Stderr = &out, &errb if err := cmd.Run(); err != nil { msg := strings.TrimSpace(errb.String()) if msg == "" { msg = err.Error() } return out.String(), errors.New(msg) } return out.String(), nil } func (m *Manager) sudoArgs(args ...string) []string { if m.sudo { return append([]string{"sudo", "-n"}, args...) } return args } func jobID(source, raw string) string { h := sha1.Sum([]byte(source + "\n" + raw)) return hex.EncodeToString(h[:6]) } var userRe = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,31}$`) var cronDName = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) // cron ignores other names in /etc/cron.d // ---- parsing ---- // parseLine splits one crontab line. withUser is true for system crontabs (6th field is // the user). Returns ok=false for comments, blanks, env assignments and garbage. func parseLine(line string, withUser bool) (schedule, usr, command string, ok bool) { t := strings.TrimSpace(line) if t == "" || strings.HasPrefix(t, "#") { return } if i := strings.Index(t, "="); i > 0 && !strings.ContainsAny(t[:i], " \t*") { // NAME=value return } var fields []string if strings.HasPrefix(t, "@") { f := strings.Fields(t) if len(f) < 2 { return } fields = []string{f[0]} t = strings.TrimSpace(t[len(f[0]):]) } else { f := strings.Fields(t) if len(f) < 6 { return } fields = f[:5] for _, x := range fields { t = strings.TrimSpace(t[len(x):]) } } schedule = strings.Join(fields, " ") if _, err := parseSchedule(schedule); err != nil { return } if withUser { f := strings.Fields(t) if len(f) < 2 || !userRe.MatchString(f[0]) { return } usr = f[0] t = strings.TrimSpace(t[len(f[0]):]) } if t == "" { return } return schedule, usr, t, true } var redirectRe = regexp.MustCompile(`(?:^|\s)(?:>>|>|2>&1\s*>>|&>>|&>)\s*("?)([^\s"&|;]+)`) func outFile(cmd string) string { for _, m := range redirectRe.FindAllStringSubmatch(cmd, -1) { if p := m[2]; strings.HasPrefix(p, "/") && p != "/dev/null" { return p } } return "" } // parseSource turns crontab text into jobs. Commented-out lines that still parse as a // job are reported disabled, so a "# 0 5 * * * ..." example shows up as such. func (m *Manager) parseSource(source, text string, withUser, editable bool, defaultUser string, now time.Time, anacron bool) []Job { var jobs []Job lines := strings.Split(text, "\n") isComment := func(i int) bool { return i >= 0 && i < len(lines) && strings.HasPrefix(strings.TrimSpace(lines[i]), "#") } for i, line := range lines { enabled := true probe := line if t := strings.TrimSpace(line); strings.HasPrefix(t, "#") { // A commented-out job sits on its own; a job-shaped line inside a block of // comments is documentation (Debian's stock header has two such examples). if isComment(i-1) && isComment(i+1) { continue } enabled = false probe = strings.TrimLeft(t, "# ") } schedule, usr, command, ok := parseLine(probe, withUser) if !ok { continue } if usr == "" { usr = defaultUser } if !enabled { if _, err := user.Lookup(usr); err != nil { continue // "user-name" placeholder and the like } } j := Job{ID: jobID(source, line), Source: source, Line: i + 1, User: usr, Schedule: schedule, Command: command, Enabled: enabled, OutFile: outFile(command), Editable: editable, raw: line} if enabled { if next, ok := nextRun(schedule, now); ok { j.Next = next.UnixMilli() } } if strings.Contains(command, "run-parts") { if dir := runPartsDir(command); dir != "" { if anacron && strings.Contains(command, "anacron") { j.Note = "skipped: anacron is installed and runs " + dir + " instead" } jobs = append(jobs, j) jobs = append(jobs, m.runPartsJobs(dir, j)...) continue } } jobs = append(jobs, j) } return jobs } var runPartsRe = regexp.MustCompile(`run-parts\s+(?:--\S+\s+)*(/\S+)`) func runPartsDir(cmd string) string { if m := runPartsRe.FindStringSubmatch(cmd); m != nil { return m[1] } return "" } // runPartsJobs lists the executable scripts a run-parts line will run, as read-only jobs. func (m *Manager) runPartsJobs(dir string, parent Job) []Job { ents, err := os.ReadDir(dir) if err != nil { return nil } var out []Job for _, e := range ents { if e.IsDir() || !cronDName.MatchString(e.Name()) { continue } info, err := e.Info() if err != nil || info.Mode()&0o111 == 0 { continue } p := filepath.Join(dir, e.Name()) out = append(out, Job{ID: jobID(dir, e.Name()), Source: dir, User: parent.User, Schedule: parent.Schedule, Command: p, Enabled: parent.Enabled, Next: parent.Next, Note: "run by " + parent.Source + " via run-parts", raw: e.Name()}) } return out } // ---- sources ---- type source struct { name string // job Source withUser bool user string // default user for user crontabs editable bool } func (m *Manager) readSource(ctx context.Context, s source) (string, error) { if strings.HasPrefix(s.name, "crontab:") { u := s.user if u == m.self { out, err := run(ctx, "", "crontab", "-l") if err != nil && strings.Contains(err.Error(), "no crontab") { return "", nil } return out, err } if !m.sudo { return "", errors.New("needs sudo") } out, err := run(ctx, "", "sudo", "-n", "crontab", "-u", u, "-l") if err != nil && strings.Contains(err.Error(), "no crontab") { return "", nil } return out, err } b, err := os.ReadFile(s.name) if err != nil && m.sudo { out, err2 := run(ctx, "", "sudo", "-n", "cat", s.name) return out, err2 } return string(b), err } func (m *Manager) sources(ctx context.Context) []source { var out []source if _, err := os.Stat("/etc/crontab"); err == nil { out = append(out, source{name: "/etc/crontab", withUser: true, editable: m.sudo}) } if ents, err := os.ReadDir("/etc/cron.d"); err == nil { for _, e := range ents { if !e.IsDir() && cronDName.MatchString(e.Name()) { out = append(out, source{name: "/etc/cron.d/" + e.Name(), withUser: true, editable: m.sudo}) } } } // per-user crontabs: Debian keeps them in /var/spool/cron/crontabs, cronie in /var/spool/cron users := map[string]bool{} if m.sudo { for _, dir := range []string{"/var/spool/cron/crontabs", "/var/spool/cron"} { if out, err := run(ctx, "", "sudo", "-n", "ls", "-1", dir); err == nil { for _, n := range strings.Split(out, "\n") { if n = strings.TrimSpace(n); userRe.MatchString(n) { users[n] = true } } break } } } if m.self != "" { users[m.self] = true } names := make([]string, 0, len(users)) for u := range users { names = append(names, u) } sort.Strings(names) for _, u := range names { out = append(out, source{name: "crontab:" + u, user: u, editable: u == m.self || m.sudo}) } return out } func (m *Manager) findSource(ctx context.Context, name string) (source, bool) { for _, s := range m.sources(ctx) { if s.name == name { return s, true } } return source{}, false } // ---- overview ---- func (m *Manager) Overview(ctx context.Context) Overview { now := time.Now() _, anacronErr := os.Stat("/usr/sbin/anacron") anacron := anacronErr == nil ov := Overview{Jobs: []Job{}, Timers: []Timer{}, Anacron: anacron, Sudo: m.sudo} for _, s := range m.sources(ctx) { text, err := m.readSource(ctx, s) if err != nil { continue } ov.Jobs = append(ov.Jobs, m.parseSource(s.name, text, s.withUser, s.editable, s.user, now, anacron)...) } last := m.lastRunsFromJournal(ctx) ov.Logging = m.logging for i := range ov.Jobs { j := &ov.Jobs[i] if t, ok := last[j.User+"\x00"+j.Command]; ok { j.Last = t } } ov.Timers = m.timers(ctx) return ov } var cronLogRe = regexp.MustCompile(`^\((\S+)\) CMD \((.*)\)$`) // lastRunsFromJournal maps user+command to the most recent "(user) CMD (command)" line. func (m *Manager) lastRunsFromJournal(ctx context.Context) map[string]int64 { m.mu.Lock() if time.Since(m.lastAt) < 5*time.Second && m.lastRuns != nil { r := m.lastRuns m.mu.Unlock() return r } m.mu.Unlock() args := []string{"journalctl", "-t", "CRON", "-t", "cron", "-t", "crond", "-o", "json", "--no-pager", "-q", "--since", "-30d", "-n", "20000"} if m.journalSudo { args = append([]string{"sudo", "-n"}, args...) } out, _ := run(ctx, "", args...) res := map[string]int64{} seen := false sc := bufio.NewScanner(strings.NewReader(out)) sc.Buffer(make([]byte, 0, 64<<10), 4<<20) for sc.Scan() { var raw struct { Msg any `json:"MESSAGE"` TS string `json:"__REALTIME_TIMESTAMP"` } if json.Unmarshal(sc.Bytes(), &raw) != nil { continue } seen = true msg, _ := raw.Msg.(string) mm := cronLogRe.FindStringSubmatch(msg) if mm == nil { continue } us, _ := strconv.ParseInt(raw.TS, 10, 64) k := mm[1] + "\x00" + mm[2] if us/1000 > res[k] { res[k] = us / 1000 } } m.mu.Lock() m.lastRuns, m.lastAt, m.logging = res, time.Now(), seen m.mu.Unlock() return res } // JobLogs returns the journal lines for one job's command (most recent last). func (m *Manager) JobLogs(ctx context.Context, j Job, n int) ([]map[string]any, error) { args := []string{"journalctl", "-t", "CRON", "-t", "cron", "-t", "crond", "-o", "json", "--no-pager", "-q", "-n", "20000"} if m.journalSudo { args = append([]string{"sudo", "-n"}, args...) } out, err := run(ctx, "", args...) if err != nil { return nil, err } var res []map[string]any sc := bufio.NewScanner(strings.NewReader(out)) sc.Buffer(make([]byte, 0, 64<<10), 4<<20) for sc.Scan() { var raw struct { Msg any `json:"MESSAGE"` TS string `json:"__REALTIME_TIMESTAMP"` PID string `json:"_PID"` } if json.Unmarshal(sc.Bytes(), &raw) != nil { continue } msg, _ := raw.Msg.(string) if !strings.Contains(msg, "("+j.User+") CMD ("+j.Command+")") { continue } us, _ := strconv.ParseInt(raw.TS, 10, 64) res = append(res, map[string]any{"t": us / 1000, "p": 6, "ident": "CRON", "pid": raw.PID, "msg": msg}) } if len(res) > n { res = res[len(res)-n:] } if res == nil { res = []map[string]any{} } return res, nil } // OutputTail returns the last n lines of the job's redirect target. func (m *Manager) OutputTail(ctx context.Context, j Job, n int) (string, error) { if j.OutFile == "" { return "", errors.New("job has no output file") } out, err := run(ctx, "", m.sudoArgs("tail", "-n", strconv.Itoa(n), j.OutFile)...) return out, err } // ---- timers ---- func (m *Manager) timers(ctx context.Context) []Timer { out, err := run(ctx, "", "systemctl", "list-timers", "--all", "--output=json", "--no-pager") if err != nil { return []Timer{} } var lt []struct { Next int64 `json:"next"` Last int64 `json:"last"` Unit string `json:"unit"` Activates string `json:"activates"` } _ = json.Unmarshal([]byte(out), <) units, _ := run(ctx, "", "systemctl", "list-units", "--type=timer", "--all", "--output=json", "--no-pager", "--plain") var lu []struct{ Unit, Active, Sub, Description string } _ = json.Unmarshal([]byte(units), &lu) meta := map[string]struct{ Active, Sub, Description string }{} for _, u := range lu { meta[u.Unit] = struct{ Active, Sub, Description string }{u.Active, u.Sub, u.Description} } files, _ := run(ctx, "", "systemctl", "list-unit-files", "--type=timer", "--output=json", "--no-pager") var uf []struct { UnitFile string `json:"unit_file"` State string `json:"state"` } _ = json.Unmarshal([]byte(files), &uf) enabled := map[string]string{} for _, f := range uf { enabled[f.UnitFile] = f.State } res := make([]Timer, 0, len(lt)) for _, t := range lt { x := Timer{Unit: t.Unit, Activates: t.Activates, Enabled: enabled[t.Unit]} if mt, ok := meta[t.Unit]; ok { x.Active, x.Sub, x.Description = mt.Active, mt.Sub, mt.Description } if t.Next > 0 { x.Next = t.Next / 1000 } if t.Last > 0 { x.Last = t.Last / 1000 } res = append(res, x) } sort.Slice(res, func(i, j int) bool { return res[i].Unit < res[j].Unit }) return res } var timerRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9@._:\\-]{0,255}\.timer$`) func (m *Manager) TimerAction(ctx context.Context, unit, action string) error { if !timerRe.MatchString(unit) { return errors.New("invalid timer name") } switch action { case "start", "stop", "enable", "disable": _, err := run(ctx, "", m.sudoArgs("systemctl", action, unit)...) return err case "run": // fire the service the timer activates, right now out, err := run(ctx, "", "systemctl", "show", unit, "-p", "Triggers", "--value") if err != nil { return err } svc := strings.TrimSpace(strings.Split(out, " ")[0]) if svc == "" { return errors.New("timer triggers nothing") } _, err = run(ctx, "", m.sudoArgs("systemctl", "start", svc)...) return err } return errors.New("unknown action") } // ---- job lookup and editing ---- func (m *Manager) FindJob(ctx context.Context, id string) (Job, bool) { for _, j := range m.Overview(ctx).Jobs { if j.ID == id { return j, true } } return Job{}, false } // Source returns a crontab's raw text for the editor. func (m *Manager) Source(ctx context.Context, name string) (text string, editable bool, err error) { s, ok := m.findSource(ctx, name) if !ok { return "", false, errors.New("unknown source") } text, err = m.readSource(ctx, s) return text, s.editable, err } // validate rejects any line that is neither blank, comment, env assignment, nor a job. func validate(text string, withUser bool) error { for i, line := range strings.Split(text, "\n") { t := strings.TrimSpace(line) if t == "" || strings.HasPrefix(t, "#") { continue } if k, _, ok := strings.Cut(t, "="); ok && !strings.ContainsAny(strings.TrimSpace(k), " \t*") { continue } if _, _, _, ok := parseLine(t, withUser); !ok { return fmt.Errorf("line %d: not a valid cron entry", i+1) } } return nil } // WriteSource replaces a crontab. User crontabs go through crontab(1) so cron reloads // them; system files are written in place with their permissions preserved. func (m *Manager) WriteSource(ctx context.Context, name, text string) error { s, ok := m.findSource(ctx, name) if !ok { return errors.New("unknown source") } if !s.editable { return errors.New("source is read-only here (no sudo)") } if !strings.HasSuffix(text, "\n") { text += "\n" } if err := validate(text, s.withUser); err != nil { return err } if strings.HasPrefix(name, "crontab:") { if s.user == m.self { _, err := run(ctx, text, "crontab", "-") return err } _, err := run(ctx, text, "sudo", "-n", "crontab", "-u", s.user, "-") return err } if name != "/etc/crontab" && !strings.HasPrefix(name, "/etc/cron.d/") { return errors.New("refusing to write outside /etc/crontab and /etc/cron.d") } _, err := run(ctx, text, "sudo", "-n", "tee", name) m.mu.Lock() m.lastAt = time.Time{} m.mu.Unlock() return err } // Toggle comments or uncomments a job's line. The line must still read as the job did // when it was listed, so a concurrent edit can't be clobbered. func (m *Manager) Toggle(ctx context.Context, j Job, enable bool) error { if j.Line == 0 || !j.Editable { return errors.New("this job can't be toggled here") } s, ok := m.findSource(ctx, j.Source) if !ok { return errors.New("unknown source") } text, err := m.readSource(ctx, s) if err != nil { return err } lines := strings.Split(text, "\n") if j.Line > len(lines) || lines[j.Line-1] != j.raw { return errors.New("crontab changed underneath; reload and try again") } if enable { lines[j.Line-1] = strings.TrimLeft(strings.TrimSpace(j.raw), "# ") } else { lines[j.Line-1] = "#" + j.raw } return m.WriteSource(ctx, j.Source, strings.Join(lines, "\n")) } // ---- run now ---- // StartRun executes the job's command as its user, the way cron would (sh -c, cwd = home, // '%' turned into newlines/stdin), in the background. Output is kept in memory. func (m *Manager) StartRun(ctx context.Context, j Job) (*Run, error) { if j.User != m.self && !m.sudo { return nil, errors.New("needs sudo to run as " + j.User) } cmdline, stdin := cronPercent(j.Command) args := []string{"sh", "-c", cmdline} if j.User != m.self { args = append([]string{"sudo", "-n", "-u", j.User, "-H"}, args...) } r := &Run{ID: jobID("run", strconv.FormatInt(time.Now().UnixNano(), 10)), JobID: j.ID, Command: j.Command, User: j.User, Started: time.Now(), Running: true} runCtx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) cmd := exec.CommandContext(runCtx, args[0], args[1:]...) if stdin != "" { cmd.Stdin = strings.NewReader(stdin) } w := &capWriter{r: r} cmd.Stdout, cmd.Stderr = w, w if err := cmd.Start(); err != nil { cancel() return nil, err } m.mu.Lock() m.runs[r.ID] = r for id, old := range m.runs { // keep the map small if !old.Running && time.Since(old.Finished) > time.Hour { delete(m.runs, id) } } m.mu.Unlock() go func() { defer cancel() err := cmd.Wait() r.mu.Lock() r.Running, r.Finished = false, time.Now() if err != nil { if ee, ok := err.(*exec.ExitError); ok { r.ExitCode = ee.ExitCode() } else { r.ExitCode = -1 } } r.mu.Unlock() m.mu.Lock() m.lastAt = time.Time{} m.mu.Unlock() }() return r, nil } func (m *Manager) GetRun(id string) (*Run, bool) { m.mu.Lock() r, ok := m.runs[id] m.mu.Unlock() return r, ok } // Snapshot returns a copy safe to encode. func (r *Run) Snapshot() Run { r.mu.Lock() defer r.mu.Unlock() return Run{ID: r.ID, JobID: r.JobID, Command: r.Command, User: r.User, Started: r.Started, Finished: r.Finished, Running: r.Running, ExitCode: r.ExitCode, Output: r.buf.String()} } type capWriter struct{ r *Run } func (w *capWriter) Write(p []byte) (int, error) { w.r.mu.Lock() defer w.r.mu.Unlock() w.r.buf.Write(p) if w.r.buf.Len() > 256<<10 { // keep the tail b := w.r.buf.Bytes() w.r.buf = *bytes.NewBuffer(append([]byte("…\n"), b[len(b)-200<<10:]...)) } return len(p), nil } // cronPercent applies crontab(5) rules: an unescaped % ends the command; later ones are // newlines fed to stdin. "\%" is a literal percent. func cronPercent(cmd string) (string, string) { var out, in strings.Builder cur := &out esc := false for _, c := range cmd { switch { case esc: if c != '%' { cur.WriteRune('\\') } cur.WriteRune(c) esc = false case c == '\\': esc = true case c == '%': if cur == &out { cur = &in } else { in.WriteRune('\n') } default: cur.WriteRune(c) } } return out.String(), in.String() }