Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
309 lines
9.2 KiB
Go
309 lines
9.2 KiB
Go
// 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/exec"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
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
|
|
}
|
|
|
|
func New() *Manager {
|
|
m := &Manager{}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
m.sudo = exec.CommandContext(ctx, "sudo", "-n", "true").Run() == nil
|
|
// 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 }
|
|
|
|
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) {
|
|
m.mu.Lock()
|
|
if time.Since(m.cacheAt) < 4*time.Second && m.cache != nil {
|
|
c := m.cache
|
|
m.mu.Unlock()
|
|
return c, nil
|
|
}
|
|
m.mu.Unlock()
|
|
|
|
out, err := run(ctx, "systemctl", "list-units", "--type=service", "--all", "--output=json", "--no-pager", "--plain")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var units []struct{ Unit, Load, Active, Sub, Description string }
|
|
if err := json.Unmarshal([]byte(out), &units); err != nil {
|
|
return nil, fmt.Errorf("parse list-units: %w", err)
|
|
}
|
|
files, _ := run(ctx, "systemctl", "list-unit-files", "--type=service", "--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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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 = append([]string{"sudo", "-n"}, 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
|
|
return exec.Command("sudo", "-n", "kill", "-9", "--", pg).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 = append([]string{"sudo", "-n"}, args...)
|
|
}
|
|
_, err := run(ctx, args...)
|
|
m.invalidate()
|
|
return err
|
|
}
|