diff --git a/PLAN.md b/PLAN.md index 848aec0..e825dc9 100644 --- a/PLAN.md +++ b/PLAN.md @@ -123,8 +123,19 @@ something long in one, switch away and back, refresh — it's all still there. `tmux new -A -s hamlab-`; needs `mouse on` and status-bar handling in xterm. - **Services tab** — `systemctl list-units`, per-unit page: status, last 200 journal lines (live-tail via WS), start/stop/restart/enable buttons, unit file viewer. -- **Cron tab** — parse `/etc/crontab`, `/etc/cron.d/*`, user crontabs, and systemd - timers into one table with next-run times; edit via `crontab -` round-trip. +- **Cron tab** — **shipped 2026-09-17** (`internal/cron`, `Cron.tsx`): one list built + from `/etc/crontab`, `/etc/cron.d/*`, every user crontab (via sudo), the scripts in + the `run-parts` directories those drive, plus systemd timers. Next run computed by an + in-house cron-expression evaluator (tested: ranges, steps, names, dom-OR-dow rule, + `@` aliases); last run from the journal's `(user) CMD (command)` lines (cron logs via + syslog to journald; reading needs sudo/adm). Commented-out jobs show as disabled; + job-shaped lines inside comment blocks (Debian's header examples) are ignored. Per + job: Run now (background, as the job's user, `sh -c` with crontab `%` rules, output + captured), Enable/Disable (comments the line in place, refuses if the file changed + underneath), Edit source (textarea; validated line-by-line; user crontabs installed + with `crontab -`, system files written via `sudo tee`), Cron log, and Output file + when the command redirects to one. Timers: Run now (starts the activated service), + start/stop/enable/disable, live logs of the service via the Services tab's tail. - **ZFS tab** — pools, datasets (`used/avail/refer/usedbysnapshots`), snapshot list, scrub status, and the `freeing` counter. `grid` is the target, which implies multi-host. diff --git a/cmd/hamlab/main.go b/cmd/hamlab/main.go index 8521769..3becd5f 100644 --- a/cmd/hamlab/main.go +++ b/cmd/hamlab/main.go @@ -21,6 +21,7 @@ import ( "hamlab/internal/api" "hamlab/internal/auth" "hamlab/internal/config" + "hamlab/internal/cron" "hamlab/internal/metrics" "hamlab/internal/services" "hamlab/internal/store" @@ -142,7 +143,9 @@ func serve(cfg *config.Config) error { col := metrics.NewCollector() go col.Run(context.Background()) - srv := api.New(cfg, sessions, mgr, col, st, services.New(), web.FS(), version) + svc := services.New() + cr := cron.New(svc.Sudo(), svc.JournalSudo()) + srv := api.New(cfg, sessions, mgr, col, st, svc, cr, web.FS(), version) hs := &http.Server{ Addr: cfg.Listen, Handler: srv.Handler(), diff --git a/internal/api/server.go b/internal/api/server.go index ccb8f89..3d35b07 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -19,6 +19,7 @@ import ( "hamlab/internal/auth" "hamlab/internal/config" + "hamlab/internal/cron" "hamlab/internal/metrics" "hamlab/internal/services" "hamlab/internal/store" @@ -35,6 +36,7 @@ type Server struct { metrics *metrics.Collector store *store.Store svc *services.Manager + cron *cron.Manager version string started time.Time user string @@ -43,9 +45,9 @@ type Server struct { mux *http.ServeMux } -func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *metrics.Collector, st *store.Store, svc *services.Manager, ui fs.FS, version string) *Server { +func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *metrics.Collector, st *store.Store, svc *services.Manager, cr *cron.Manager, ui fs.FS, version string) *Server { host, _ := os.Hostname() - s := &Server{cfg: cfg, sessions: sessions, mgr: mgr, metrics: col, store: st, svc: svc, ui: ui, version: version, + s := &Server{cfg: cfg, sessions: sessions, mgr: mgr, metrics: col, store: st, svc: svc, cron: cr, ui: ui, version: version, started: time.Now(), host: host, user: os.Getenv("USER")} if u, err := user.Current(); err == nil && u.Username != "" { s.user = u.Username @@ -76,6 +78,14 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me m.HandleFunc("GET /api/services/{unit}/logs", s.auth(s.serviceLogs)) m.HandleFunc("GET /api/services/{unit}/logs/tail", s.auth(s.serviceTail)) m.HandleFunc("POST /api/services/{unit}/{action}", s.auth(s.serviceAction)) + m.HandleFunc("GET /api/cron", s.auth(s.cronOverview)) + m.HandleFunc("GET /api/cron/source", s.auth(s.cronSource)) + m.HandleFunc("PUT /api/cron/source", s.auth(s.cronWriteSource)) + m.HandleFunc("GET /api/cron/jobs/{id}/logs", s.auth(s.cronJobLogs)) + m.HandleFunc("GET /api/cron/jobs/{id}/output", s.auth(s.cronJobOutput)) + m.HandleFunc("POST /api/cron/jobs/{id}/{action}", s.auth(s.cronJobAction)) + m.HandleFunc("GET /api/cron/runs/{id}", s.auth(s.cronRun)) + m.HandleFunc("POST /api/cron/timers/{unit}/{action}", s.auth(s.cronTimerAction)) m.HandleFunc("GET /api/prefs/dashboard", s.auth(s.getDashPrefs)) m.HandleFunc("PUT /api/prefs/dashboard", s.auth(s.putDashPrefs)) m.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) { jsonErr(w, 404, "not found") }) @@ -311,6 +321,120 @@ func (s *Server) serviceAction(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) } +// ---- cron (crontabs + systemd timers) ---- + +func (s *Server) cronOverview(w http.ResponseWriter, r *http.Request) { + jsonOK(w, 200, s.cron.Overview(r.Context())) +} + +func (s *Server) cronSource(w http.ResponseWriter, r *http.Request) { + text, editable, err := s.cron.Source(r.Context(), r.URL.Query().Get("name")) + if err != nil { + jsonErr(w, 404, err.Error()) + return + } + jsonOK(w, 200, map[string]any{"text": text, "editable": editable}) +} + +func (s *Server) cronWriteSource(w http.ResponseWriter, r *http.Request) { + var body struct { + Text string `json:"text"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil { + jsonErr(w, 400, "bad request") + return + } + name := r.URL.Query().Get("name") + if err := s.cron.WriteSource(r.Context(), name, body.Text); err != nil { + jsonErr(w, 400, err.Error()) + return + } + log.Printf("cron: wrote %s (from %s)", name, clientIP(r)) + w.WriteHeader(204) +} + +func (s *Server) cronJob(w http.ResponseWriter, r *http.Request) (cron.Job, bool) { + j, ok := s.cron.FindJob(r.Context(), r.PathValue("id")) + if !ok { + jsonErr(w, 404, "no such job (crontab may have changed; reload)") + } + return j, ok +} + +func (s *Server) cronJobLogs(w http.ResponseWriter, r *http.Request) { + j, ok := s.cronJob(w, r) + if !ok { + return + } + entries, err := s.cron.JobLogs(r.Context(), j, logCount(r)) + if err != nil { + jsonErr(w, 500, err.Error()) + return + } + jsonOK(w, 200, entries) +} + +func (s *Server) cronJobOutput(w http.ResponseWriter, r *http.Request) { + j, ok := s.cronJob(w, r) + if !ok { + return + } + out, err := s.cron.OutputTail(r.Context(), j, logCount(r)) + if err != nil { + jsonErr(w, 400, err.Error()) + return + } + jsonOK(w, 200, map[string]string{"file": j.OutFile, "text": out}) +} + +func (s *Server) cronJobAction(w http.ResponseWriter, r *http.Request) { + j, ok := s.cronJob(w, r) + if !ok { + return + } + action := r.PathValue("action") + var err error + switch action { + case "enable", "disable": + err = s.cron.Toggle(r.Context(), j, action == "enable") + case "run": + var run *cron.Run + if run, err = s.cron.StartRun(r.Context(), j); err == nil { + log.Printf("cron: run now %q as %s (from %s)", j.Command, j.User, clientIP(r)) + jsonOK(w, 202, run.Snapshot()) + return + } + default: + jsonErr(w, 400, "unknown action") + return + } + if err != nil { + jsonErr(w, 400, err.Error()) + return + } + log.Printf("cron: %s %q in %s (from %s)", action, j.Command, j.Source, clientIP(r)) + w.WriteHeader(204) +} + +func (s *Server) cronRun(w http.ResponseWriter, r *http.Request) { + run, ok := s.cron.GetRun(r.PathValue("id")) + if !ok { + jsonErr(w, 404, "no such run") + return + } + jsonOK(w, 200, run.Snapshot()) +} + +func (s *Server) cronTimerAction(w http.ResponseWriter, r *http.Request) { + unit, action := r.PathValue("unit"), r.PathValue("action") + if err := s.cron.TimerAction(r.Context(), unit, action); err != nil { + jsonErr(w, 400, err.Error()) + return + } + log.Printf("timer %s %s ok (from %s)", action, unit, clientIP(r)) + w.WriteHeader(204) +} + // ---- dashboard layout prefs (opaque JSON object, owned by the UI) ---- func (s *Server) getDashPrefs(w http.ResponseWriter, _ *http.Request) { diff --git a/internal/cron/cron.go b/internal/cron/cron.go new file mode 100644 index 0000000..2189925 --- /dev/null +++ b/internal/cron/cron.go @@ -0,0 +1,761 @@ +// 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) < 60*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() +} diff --git a/internal/cron/schedule.go b/internal/cron/schedule.go new file mode 100644 index 0000000..4c52d5b --- /dev/null +++ b/internal/cron/schedule.go @@ -0,0 +1,163 @@ +package cron + +import ( + "errors" + "fmt" + "strconv" + "strings" + "time" +) + +// schedule is a parsed five-field cron expression as sets. +type schedule struct { + min, hour, dom, mon, dow [64]bool + domStar, dowStar bool // "*" in dom/dow changes the match rule + reboot bool +} + +var monthNames = map[string]int{"jan": 1, "feb": 2, "mar": 3, "apr": 4, "may": 5, "jun": 6, "jul": 7, "aug": 8, "sep": 9, "oct": 10, "nov": 11, "dec": 12} +var dayNames = map[string]int{"sun": 0, "mon": 1, "tue": 2, "wed": 3, "thu": 4, "fri": 5, "sat": 6} + +var aliases = map[string]string{ + "@yearly": "0 0 1 1 *", "@annually": "0 0 1 1 *", "@monthly": "0 0 1 * *", "@weekly": "0 0 * * 0", + "@daily": "0 0 * * *", "@midnight": "0 0 * * *", "@hourly": "0 * * * *", +} + +func parseSchedule(expr string) (*schedule, error) { + expr = strings.TrimSpace(expr) + if expr == "@reboot" { + return &schedule{reboot: true}, nil + } + if a, ok := aliases[strings.ToLower(expr)]; ok { + expr = a + } + f := strings.Fields(expr) + if len(f) != 5 { + return nil, errors.New("expected 5 fields") + } + s := &schedule{} + var err error + if s.min, err = parseField(f[0], 0, 59, nil); err != nil { + return nil, fmt.Errorf("minute: %w", err) + } + if s.hour, err = parseField(f[1], 0, 23, nil); err != nil { + return nil, fmt.Errorf("hour: %w", err) + } + if s.dom, err = parseField(f[2], 1, 31, nil); err != nil { + return nil, fmt.Errorf("day of month: %w", err) + } + if s.mon, err = parseField(f[3], 1, 12, monthNames); err != nil { + return nil, fmt.Errorf("month: %w", err) + } + if s.dow, err = parseField(f[4], 0, 7, dayNames); err != nil { + return nil, fmt.Errorf("day of week: %w", err) + } + if s.dow[7] { // 7 == Sunday + s.dow[0] = true + } + s.domStar = f[2] == "*" + s.dowStar = f[4] == "*" + return s, nil +} + +func parseField(field string, lo, hi int, names map[string]int) ([64]bool, error) { + var set [64]bool + atom := func(s string) (int, error) { + if names != nil { + if v, ok := names[strings.ToLower(s)]; ok { + return v, nil + } + } + v, err := strconv.Atoi(s) + if err != nil { + return 0, fmt.Errorf("bad value %q", s) + } + return v, nil + } + for _, part := range strings.Split(field, ",") { + step := 1 + if i := strings.Index(part, "/"); i >= 0 { + v, err := strconv.Atoi(part[i+1:]) + if err != nil || v <= 0 { + return set, fmt.Errorf("bad step in %q", part) + } + step, part = v, part[:i] + } + a, b := lo, hi + switch { + case part == "*": + case strings.Contains(part, "-"): + x, y, _ := strings.Cut(part, "-") + var err error + if a, err = atom(x); err != nil { + return set, err + } + if b, err = atom(y); err != nil { + return set, err + } + default: + v, err := atom(part) + if err != nil { + return set, err + } + a = v + if step == 1 { + b = v + } + } + if a < lo || b > hi || a > b { + return set, fmt.Errorf("out of range: %q", part) + } + for v := a; v <= b; v += step { + set[v] = true + } + } + return set, nil +} + +func (s *schedule) dayMatches(t time.Time) bool { + if !s.mon[int(t.Month())] { + return false + } + dom, dow := s.dom[t.Day()], s.dow[int(t.Weekday())] + switch { + case s.domStar && s.dowStar: + return true + case s.domStar: + return dow + case s.dowStar: + return dom + default: // both restricted: cron runs when either matches + return dom || dow + } +} + +// nextRun finds the first time strictly after `from` that the schedule fires. +func nextRun(expr string, from time.Time) (time.Time, bool) { + s, err := parseSchedule(expr) + if err != nil || s.reboot { + return time.Time{}, false + } + t := from.Truncate(time.Minute).Add(time.Minute) + day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) + for i := 0; i < 366*2; i++ { + if s.dayMatches(day) { + for h := 0; h < 24; h++ { + if !s.hour[h] { + continue + } + for mi := 0; mi < 60; mi++ { + if !s.min[mi] { + continue + } + c := time.Date(day.Year(), day.Month(), day.Day(), h, mi, 0, 0, day.Location()) + if !c.Before(t) { + return c, true + } + } + } + } + day = day.AddDate(0, 0, 1) + } + return time.Time{}, false +} diff --git a/internal/cron/schedule_test.go b/internal/cron/schedule_test.go new file mode 100644 index 0000000..5760903 --- /dev/null +++ b/internal/cron/schedule_test.go @@ -0,0 +1,68 @@ +package cron + +import ( + "testing" + "time" +) + +func TestNextRun(t *testing.T) { + loc := time.UTC + from := time.Date(2026, 9, 17, 15, 30, 0, 0, loc) // Thursday + cases := []struct { + expr string + want time.Time + }{ + {"0 1 * * *", time.Date(2026, 9, 18, 1, 0, 0, 0, loc)}, + {"17 * * * *", time.Date(2026, 9, 17, 16, 17, 0, 0, loc)}, + {"*/15 * * * *", time.Date(2026, 9, 17, 15, 45, 0, 0, loc)}, + {"47 6 * * 7", time.Date(2026, 9, 20, 6, 47, 0, 0, loc)}, // Sunday as 7 + {"52 6 1 * *", time.Date(2026, 10, 1, 6, 52, 0, 0, loc)}, // first of month + {"24 0 1-7 * *", time.Date(2026, 10, 1, 0, 24, 0, 0, loc)}, // range + {"30 3 * * 0", time.Date(2026, 9, 20, 3, 30, 0, 0, loc)}, // Sunday as 0 + {"0 9 15 * mon", time.Date(2026, 9, 21, 9, 0, 0, 0, loc)}, // dom OR dow + {"@daily", time.Date(2026, 9, 18, 0, 0, 0, 0, loc)}, + {"@weekly", time.Date(2026, 9, 20, 0, 0, 0, 0, loc)}, + {"0 0 29 feb *", time.Date(2028, 2, 29, 0, 0, 0, 0, loc)}, + } + for _, c := range cases { + got, ok := nextRun(c.expr, from) + if !ok || !got.Equal(c.want) { + t.Errorf("%q: got %v ok=%v, want %v", c.expr, got, ok, c.want) + } + } + if _, ok := nextRun("@reboot", from); ok { + t.Error("@reboot should have no next run") + } + for _, bad := range []string{"* * * *", "60 * * * *", "a b c d e", "*/0 * * * *"} { + if _, err := parseSchedule(bad); err == nil { + t.Errorf("%q should fail", bad) + } + } +} + +func TestParseLine(t *testing.T) { + sch, u, cmd, ok := parseLine("25 6\t* * *\troot\ttest -x /usr/sbin/anacron || { cd / && run-parts --report /etc/cron.daily; }", true) + if !ok || sch != "25 6 * * *" || u != "root" || !strings_HasPrefix(cmd, "test -x") { + t.Errorf("system line: %q %q %q %v", sch, u, cmd, ok) + } + if _, _, _, ok := parseLine("PATH=/usr/bin:/bin", true); ok { + t.Error("env line parsed as job") + } + if _, _, _, ok := parseLine("# m h dom mon dow command", false); ok { + t.Error("comment parsed as job") + } + if _, u, cmd, ok := parseLine("@reboot /usr/local/bin/thing --flag", false); !ok || u != "" || cmd != "/usr/local/bin/thing --flag" { + t.Errorf("@reboot: %q %q %v", u, cmd, ok) + } + if got := outFile("foo >> /var/log/foo.log 2>&1"); got != "/var/log/foo.log" { + t.Errorf("outFile: %q", got) + } + if c, in := cronPercent("echo hi%line1%line2"); c != "echo hi" || in != "line1\nline2" { + t.Errorf("percent: %q %q", c, in) + } + if c, _ := cronPercent(`date +\%w`); c != "date +%w" { + t.Errorf("escaped percent: %q", c) + } +} + +func strings_HasPrefix(s, p string) bool { return len(s) >= len(p) && s[:len(p)] == p } diff --git a/internal/services/services.go b/internal/services/services.go index 11b651a..c466c18 100644 --- a/internal/services/services.go +++ b/internal/services/services.go @@ -84,6 +84,9 @@ func New() *Manager { // Sudo reports whether actions will run through passwordless sudo. func (m *Manager) Sudo() bool { return m.sudo } +// JournalSudo reports whether reading the system journal needs sudo for this user. +func (m *Manager) JournalSudo() bool { return m.journalSudo } + func run(ctx context.Context, args ...string) (string, error) { ctx, cancel := context.WithTimeout(ctx, 20*time.Second) defer cancel() diff --git a/web/src/Cron.tsx b/web/src/Cron.tsx new file mode 100644 index 0000000..17dfb4d --- /dev/null +++ b/web/src/Cron.tsx @@ -0,0 +1,235 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { api, CronJob, CronOverview, CronRun, CronTimer, LogEntry } from './api' +import { Logs } from './Services' + +const fmtTime = (ms: number) => { const d = new Date(ms); return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: '2-digit' }) + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) } +function rel(ms: number): string { + const s = Math.round((ms - Date.now()) / 1000), a = Math.abs(s) + const d = Math.floor(a / 86400), h = Math.floor((a % 86400) / 3600), m = Math.floor((a % 3600) / 60) + const txt = d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : m > 0 ? `${m}m` : `${a}s` + return s >= 0 ? `in ${txt}` : `${txt} ago` +} + +// Plain-English reading of the common five-field shapes; anything else shows the raw expression. +function describe(expr: string): string { + const alias: Record = { '@reboot': 'at boot', '@hourly': 'every hour', '@daily': 'every day at 00:00', '@midnight': 'every day at 00:00', '@weekly': 'every Sunday at 00:00', '@monthly': 'on the 1st at 00:00', '@yearly': 'every 1 Jan', '@annually': 'every 1 Jan' } + if (alias[expr]) return alias[expr] + const f = expr.split(/\s+/) + if (f.length !== 5) return expr + const [mi, h, dom, mon, dow] = f + const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] + const at = /^\d+$/.test(mi) && /^\d+$/.test(h) ? `at ${h.padStart(2, '0')}:${mi.padStart(2, '0')}` : /^\d+$/.test(mi) && h === '*' ? `every hour at :${mi.padStart(2, '0')}` : mi.startsWith('*/') && h === '*' ? `every ${mi.slice(2)} min` : null + if (!at) return expr + const dowTxt = dow === '*' ? '' : /^\d$/.test(dow) ? `every ${days[+dow]}` : `on days ${dow}` + const domTxt = dom === '*' ? '' : /^\d+$/.test(dom) ? `on the ${dom}${['st', 'nd', 'rd'][+dom - 1] ?? 'th'}` : `on days ${dom}` + const monTxt = mon === '*' ? '' : `in month ${mon}` + const when = [dowTxt, domTxt, monTxt].filter(Boolean).join(', ') + return (when ? `${when} ` : dow === '*' && dom === '*' && !at.startsWith('every') ? 'every day ' : '') + at +} + +const srcLabel = (s: string) => (s.startsWith('crontab:') ? `${s.slice(8)}'s crontab` : s) + +// ---- source editor ---- +function SourceEditor({ name, onClose, onSaved }: { name: string; onClose: () => void; onSaved: () => void }) { + const [text, setText] = useState(null) + const [editable, setEditable] = useState(false) + const [err, setErr] = useState('') + const [busy, setBusy] = useState(false) + useEffect(() => { api.cronSource(name).then((r) => { setText(r.text); setEditable(r.editable) }).catch((e) => setErr(String(e))) }, [name]) + async function save() { + if (text === null) return + setBusy(true); setErr('') + try { await api.cronWriteSource(name, text); onSaved(); onClose() } + catch (e) { setErr(e instanceof Error ? e.message : 'save failed') } + finally { setBusy(false) } + } + return ( +
+ +

{srcLabel(name)}

+ {!editable &&
Read-only: writing this file needs passwordless sudo on the host.
} + {text === null ?
…
: