Files
hamlab/internal/services/services.go
T

312 lines
9.4 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 }
// 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) {
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()
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
}