// Package services wraps systemctl and journalctl for the Services tab: list units, // show one, read its unit file, read and tail its journal, and start/stop/etc. // // Reads run as the daemon's user (systemd exposes state to everyone; the journal needs // the systemd-journal or adm group, or sudo). Actions go through passwordless sudo when // it's available, otherwise plain systemctl so polkit gets its say. package services import ( "bufio" "bytes" "context" "encoding/json" "errors" "fmt" "os" "os/exec" "regexp" "sort" "strconv" "strings" "sync" "syscall" "time" "hamlab/internal/priv" ) type Unit struct { Name string `json:"name"` Description string `json:"description"` Load string `json:"load"` Active string `json:"active"` Sub string `json:"sub"` Enabled string `json:"enabled"` // enabled | disabled | static | masked | … | "" (unknown) Failed bool `json:"failed"` } type Detail struct { Unit Props map[string]string `json:"props"` } type Entry struct { T int64 `json:"t"` // unix ms Priority int `json:"p"` // syslog priority 0-7 Ident string `json:"ident"` PID string `json:"pid,omitempty"` Msg string `json:"msg"` } var unitRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9@._:\\-]{0,255}\.service$`) func Valid(unit string) bool { return unitRe.MatchString(unit) } var Actions = map[string]bool{"start": true, "stop": true, "restart": true, "reload": true, "enable": true, "disable": true, "reset-failed": true} // Properties shown on the detail page, in display order. var showProps = []string{"Id", "Description", "LoadState", "ActiveState", "SubState", "UnitFileState", "FragmentPath", "MainPID", "ExecMainStartTimestamp", "ActiveEnterTimestamp", "InactiveEnterTimestamp", "NRestarts", "Result", "MemoryCurrent", "MemoryPeak", "CPUUsageNSec", "TasksCurrent", "ExecStart", "User", "Restart"} type Manager struct { sudo bool // actions via sudo -n journalSudo bool // journal needs sudo (user isn't in systemd-journal/adm) mu sync.Mutex cache []Unit cacheAt time.Time } // ErrNoSystemd is what the Services tab says on OpenRC, runit and friends. var ErrNoSystemd = errors.New("this host doesn't run systemd, which the Services tab needs") // HasSystemd reports whether systemd is the running init (the test sd_booted() uses). func HasSystemd() bool { _, err := os.Stat("/run/systemd/system") return err == nil } func New() *Manager { m := &Manager{} ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() m.sudo = priv.Available() // Prefer reading the journal as ourselves: a plain child can be killed cleanly when a // tail ends, whereas a root journalctl under sudo can only be reached with sudo kill. var errb bytes.Buffer probe := exec.CommandContext(ctx, "journalctl", "--system", "-n", "1", "-q", "-o", "cat") probe.Stderr = &errb if err := probe.Run(); err != nil || strings.Contains(strings.ToLower(errb.String()), "permission") { m.journalSudo = m.sudo } return m } // 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() cmd := exec.CommandContext(ctx, args[0], args[1:]...) 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 } // List merges `list-units --all` (runtime state) with `list-unit-files` (enablement). // Cached for a few seconds because the UI polls it. func (m *Manager) List(ctx context.Context) ([]Unit, error) { if !HasSystemd() { return nil, ErrNoSystemd } m.mu.Lock() if time.Since(m.cacheAt) < 200*time.Millisecond && m.cache != nil { // coalesce simultaneous clients only c := m.cache m.mu.Unlock() return c, nil } m.mu.Unlock() var out, files string var err error done := make(chan struct{}) go func() { files, _ = run(ctx, "systemctl", "list-unit-files", "--type=service", "--output=json", "--no-pager") close(done) }() out, err = run(ctx, "systemctl", "list-units", "--type=service", "--all", "--output=json", "--no-pager", "--plain") <-done if err != nil { return nil, err } var units []listedUnit if err := json.Unmarshal([]byte(out), &units); err != nil { // systemd before 246 (RHEL 8, Ubuntu 20.04) ignores --output=json here and prints // the table; the columns have been the same since the beginning. if units = parseUnitTable(out); len(units) == 0 { return nil, fmt.Errorf("parse list-units: %w", err) } } var uf []listedFile if json.Unmarshal([]byte(files), &uf) != nil { uf = parseFileTable(files) } enabled := map[string]string{} for _, f := range uf { enabled[f.UnitFile] = f.State } seen := map[string]bool{} list := make([]Unit, 0, len(units)+len(uf)) for _, u := range units { if strings.Contains(u.Unit, "@.") { // uninstantiated template continue } seen[u.Unit] = true list = append(list, Unit{Name: u.Unit, Description: u.Description, Load: u.Load, Active: u.Active, Sub: u.Sub, Enabled: enabled[u.Unit], Failed: u.Active == "failed"}) } // Unit files that exist but were never loaded this boot (disabled and never started). for _, f := range uf { if seen[f.UnitFile] || strings.Contains(f.UnitFile, "@.") || f.State == "masked" || f.State == "alias" { continue } list = append(list, Unit{Name: f.UnitFile, Load: "not-loaded", Active: "inactive", Sub: "dead", Enabled: f.State}) } sort.Slice(list, func(i, j int) bool { return list[i].Name < list[j].Name }) m.mu.Lock() m.cache, m.cacheAt = list, time.Now() m.mu.Unlock() return list, nil } type listedUnit struct{ Unit, Load, Active, Sub, Description string } type listedFile struct { UnitFile string `json:"unit_file"` State string `json:"state"` } // parseUnitTable reads the plain `systemctl list-units` table: UNIT LOAD ACTIVE SUB DESCRIPTION… func parseUnitTable(text string) []listedUnit { var units []listedUnit for _, line := range strings.Split(text, "\n") { f := strings.Fields(strings.TrimLeft(line, "●*× ")) if len(f) < 4 || !strings.HasSuffix(f[0], ".service") { continue } units = append(units, listedUnit{Unit: f[0], Load: f[1], Active: f[2], Sub: f[3], Description: strings.Join(f[4:], " ")}) } return units } // parseFileTable reads the plain `systemctl list-unit-files` table: UNIT FILE, STATE[, PRESET] func parseFileTable(text string) []listedFile { var uf []listedFile for _, line := range strings.Split(text, "\n") { if f := strings.Fields(line); len(f) >= 2 && strings.HasSuffix(f[0], ".service") { uf = append(uf, listedFile{UnitFile: f[0], State: f[1]}) } } return uf } func (m *Manager) invalidate() { m.mu.Lock() m.cacheAt = time.Time{} m.mu.Unlock() } func (m *Manager) Show(ctx context.Context, unit string) (Detail, error) { if !Valid(unit) { return Detail{}, errors.New("invalid unit name") } out, err := run(ctx, "systemctl", "show", unit, "-p", strings.Join(showProps, ","), "--no-pager") if err != nil { return Detail{}, err } props := map[string]string{} for _, line := range strings.Split(out, "\n") { if k, v, ok := strings.Cut(line, "="); ok && v != "" && v != "[not set]" && v != "infinity" { props[k] = v } } if props["LoadState"] == "not-found" { return Detail{}, errors.New("no such unit") } d := Detail{Props: props} d.Name, d.Description = unit, props["Description"] d.Load, d.Active, d.Sub, d.Enabled = props["LoadState"], props["ActiveState"], props["SubState"], props["UnitFileState"] d.Failed = d.Active == "failed" return d, nil } func (m *Manager) Cat(ctx context.Context, unit string) (string, error) { if !Valid(unit) { return "", errors.New("invalid unit name") } return run(ctx, "systemctl", "cat", unit, "--no-pager") } func parseEntry(line []byte) (Entry, bool) { var raw map[string]any if json.Unmarshal(line, &raw) != nil { return Entry{}, false } e := Entry{Priority: 6} switch v := raw["MESSAGE"].(type) { case string: e.Msg = v case []any: // non-UTF-8 payload comes as a byte array b := make([]byte, 0, len(v)) for _, x := range v { if f, ok := x.(float64); ok { b = append(b, byte(f)) } } e.Msg = string(b) default: e.Msg = "" } if p, ok := raw["PRIORITY"].(string); ok { e.Priority, _ = strconv.Atoi(p) } if ts, ok := raw["__REALTIME_TIMESTAMP"].(string); ok { us, _ := strconv.ParseInt(ts, 10, 64) e.T = us / 1000 } if id, ok := raw["SYSLOG_IDENTIFIER"].(string); ok { e.Ident = id } else if c, ok := raw["_COMM"].(string); ok { e.Ident = c } if pid, ok := raw["_PID"].(string); ok { e.PID = pid } return e, true } func (m *Manager) journalArgs(unit string, n int) []string { args := []string{"journalctl", "-u", unit, "-n", strconv.Itoa(n), "-o", "json", "--no-pager", "--all"} if m.journalSudo { args = priv.Wrap(args...) } return args } // Journal returns the last n entries. func (m *Manager) Journal(ctx context.Context, unit string, n int) ([]Entry, error) { if !Valid(unit) { return nil, errors.New("invalid unit name") } out, err := run(ctx, m.journalArgs(unit, n)...) if err != nil { return nil, err } entries := []Entry{} for _, line := range bytes.Split([]byte(out), []byte("\n")) { if e, ok := parseEntry(line); ok { entries = append(entries, e) } } return entries, nil } // Tail streams entries (last n, then follow) to fn until ctx is cancelled. func (m *Manager) Tail(ctx context.Context, unit string, n int, fn func(Entry)) error { if !Valid(unit) { return errors.New("invalid unit name") } args := append(m.journalArgs(unit, n), "-f") cmd := exec.CommandContext(ctx, args[0], args[1:]...) // Own process group so the kill reaches journalctl itself, not just a sudo wrapper. cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} cmd.Cancel = func() error { pg := "-" + strconv.Itoa(cmd.Process.Pid) if m.journalSudo { // children are root's; only sudo can signal them k := priv.Wrap("kill", "-9", "--", pg) return exec.Command(k[0], k[1:]...).Run() } return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) } 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() { if e, ok := parseEntry(sc.Bytes()); ok { fn(e) } } _ = cmd.Wait() return nil } // Action runs start/stop/restart/reload/enable/disable/reset-failed. func (m *Manager) Action(ctx context.Context, action, unit string) error { if !Valid(unit) { return errors.New("invalid unit name") } if !Actions[action] { return errors.New("unknown action") } args := []string{"systemctl", action, unit, "--no-pager"} if m.sudo { args = priv.Wrap(args...) } _, err := run(ctx, args...) m.invalidate() return err }