- 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>
369 lines
11 KiB
Go
369 lines
11 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"
|
||
"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
|
||
}
|