Files
hamlab/internal/services/services.go
T
Mitchell-JohnsonandClaude Fable 5.1 488d929733 Portable install: the binary installs itself; static builds on five architectures; root via sudo, doas or uid 0; every package manager; older systemd; no lsblk
- 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>
2026-09-18 23:00:20 -07:00

369 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}