- hamlab install / uninstall (internal/setup): systemd or OpenRC, elevates through sudo or doas, runs as the invoking user, sets or generates the password, names only journal groups that exist, waits for /healthz, reports an active firewalld/ufw. --purge and the data chown never touch a directory that isn't Ham Lab's alone. - install.sh: curl-able bootstrap (arch detection, SHA256SUMS, offline beside the binaries, noexec /tmp fallback). make release builds dist/ for amd64, arm64, arm (ARMv6), 386, riscv64. Every build is CGO_ENABLED=0; the native one used to link glibc. - internal/priv replaces every literal `sudo -n`: uid 0, sudo or doas. - Updates acts on dnf, zypper, pacman and apk as well as apt; dnf shows installed versions. - Services falls back to the plain systemctl table before systemd 246 and says so on hosts without systemd. Disks falls back to /sys/block without lsblk. - Listener binds with IP_FREEBIND, so a LAN address that isn't up at boot is no failure. - Self-update: the new build must pass `check` against the live config, is put in place by atomic rename, and the daemon re-execs itself (same under any supervisor). - State and config writes are fsynced; a corrupt state.json is set aside, not fatal. - Terminal shutdown no longer empties the saved list, so names come back after a restart by design rather than by winning a race. - Config default is ~/.config/hamlab; an existing ~/apps/hamlab is still found. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
811 lines
22 KiB
Go
811 lines
22 KiB
Go
// 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"
|
|
|
|
"hamlab/internal/priv"
|
|
)
|
|
|
|
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
|
|
lastCursor string // journal cursor of the newest entry folded into lastRuns
|
|
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
|
|
}
|
|
|
|
// runLines streams a command's stdout to fn line by line, so multi-megabyte journal
|
|
// dumps never sit in memory whole.
|
|
func runLines(ctx context.Context, fn func(line []byte), args ...string) error {
|
|
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
|
defer cancel()
|
|
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
|
|
var errb bytes.Buffer
|
|
cmd.Stderr = &errb
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := cmd.Start(); err != nil {
|
|
return err
|
|
}
|
|
sc := bufio.NewScanner(stdout)
|
|
sc.Buffer(make([]byte, 0, 64<<10), 4<<20)
|
|
for sc.Scan() {
|
|
fn(sc.Bytes())
|
|
}
|
|
if err := cmd.Wait(); err != nil {
|
|
if msg := strings.TrimSpace(errb.String()); msg != "" {
|
|
return errors.New(msg)
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) sudoArgs(args ...string) []string {
|
|
if m.sudo {
|
|
return priv.Wrap(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, "", priv.Wrap("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, "", priv.Wrap("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, "", priv.Wrap("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.
|
|
// The first call scans 30 days; later calls only read entries after the last cursor, so
|
|
// the cost per refresh is proportional to new cron activity, not to history.
|
|
func (m *Manager) lastRunsFromJournal(ctx context.Context) map[string]int64 {
|
|
m.mu.Lock()
|
|
if time.Since(m.lastAt) < 2*time.Second && m.lastRuns != nil {
|
|
r := m.lastRuns
|
|
m.mu.Unlock()
|
|
return r
|
|
}
|
|
cursor := m.lastCursor
|
|
res := map[string]int64{}
|
|
for k, v := range m.lastRuns {
|
|
res[k] = v
|
|
}
|
|
m.mu.Unlock()
|
|
|
|
args := []string{"journalctl", "-t", "CRON", "-t", "cron", "-t", "crond", "-o", "json", "--no-pager", "-q"}
|
|
if cursor != "" {
|
|
args = append(args, "--after-cursor="+cursor)
|
|
} else {
|
|
args = append(args, "--since", "-30d", "-n", "20000")
|
|
}
|
|
if m.journalSudo {
|
|
args = priv.Wrap(args...)
|
|
}
|
|
seen := m.logging
|
|
_ = runLines(ctx, func(line []byte) {
|
|
var raw struct {
|
|
Msg any `json:"MESSAGE"`
|
|
TS string `json:"__REALTIME_TIMESTAMP"`
|
|
Cursor string `json:"__CURSOR"`
|
|
}
|
|
if json.Unmarshal(line, &raw) != nil {
|
|
return
|
|
}
|
|
seen = true
|
|
if raw.Cursor != "" {
|
|
cursor = raw.Cursor
|
|
}
|
|
msg, _ := raw.Msg.(string)
|
|
mm := cronLogRe.FindStringSubmatch(msg)
|
|
if mm == nil {
|
|
return
|
|
}
|
|
us, _ := strconv.ParseInt(raw.TS, 10, 64)
|
|
k := mm[1] + "\x00" + mm[2]
|
|
if us/1000 > res[k] {
|
|
res[k] = us / 1000
|
|
}
|
|
}, args...)
|
|
m.mu.Lock()
|
|
m.lastRuns, m.lastAt, m.lastCursor, m.logging = res, time.Now(), cursor, 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 = priv.Wrap(args...)
|
|
}
|
|
var res []map[string]any
|
|
needle := "(" + j.User + ") CMD (" + j.Command + ")"
|
|
err := runLines(ctx, func(line []byte) {
|
|
if !bytes.Contains(line, []byte(j.Command[:min(len(j.Command), 40)])) { // cheap pre-filter before JSON
|
|
return
|
|
}
|
|
var raw struct {
|
|
Msg any `json:"MESSAGE"`
|
|
TS string `json:"__REALTIME_TIMESTAMP"`
|
|
PID string `json:"_PID"`
|
|
}
|
|
if json.Unmarshal(line, &raw) != nil {
|
|
return
|
|
}
|
|
msg, _ := raw.Msg.(string)
|
|
if !strings.Contains(msg, needle) {
|
|
return
|
|
}
|
|
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})
|
|
}, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
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 ----
|
|
|
|
// timers: one `systemctl show` over every loaded timer instead of three list commands.
|
|
func (m *Manager) timers(ctx context.Context) []Timer {
|
|
out, err := run(ctx, "", "systemctl", "show", "*.timer", "--no-pager",
|
|
"-p", "Id,Description,ActiveState,SubState,UnitFileState,NextElapseUSecRealtime,LastTriggerUSec,Triggers")
|
|
if err != nil {
|
|
return []Timer{}
|
|
}
|
|
unix := func(v string) int64 { // "Sat 2026-09-19 05:47:13 PDT", "n/a" or ""
|
|
for _, layout := range []string{"Mon 2006-01-02 15:04:05 MST", "2006-01-02 15:04:05 MST", "Mon 2006-01-02 15:04:05"} {
|
|
if t, err := time.ParseInLocation(layout, v, time.Local); err == nil {
|
|
return t.UnixMilli()
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
res := []Timer{}
|
|
for _, block := range strings.Split(out, "\n\n") {
|
|
t := Timer{}
|
|
for _, line := range strings.Split(block, "\n") {
|
|
k, v, ok := strings.Cut(line, "=")
|
|
if !ok {
|
|
continue
|
|
}
|
|
switch k {
|
|
case "Id":
|
|
t.Unit = v
|
|
case "Description":
|
|
t.Description = v
|
|
case "ActiveState":
|
|
t.Active = v
|
|
case "SubState":
|
|
t.Sub = v
|
|
case "UnitFileState":
|
|
t.Enabled = v
|
|
case "NextElapseUSecRealtime":
|
|
t.Next = unix(v)
|
|
case "LastTriggerUSec":
|
|
t.Last = unix(v)
|
|
case "Triggers":
|
|
t.Activates = strings.Fields(v + " ")[0:1][0]
|
|
}
|
|
}
|
|
if t.Unit != "" {
|
|
res = append(res, t)
|
|
}
|
|
}
|
|
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, priv.Wrap("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, priv.Wrap("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 = priv.AsUser(j.User, 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()
|
|
}
|