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>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
91b7f4f009
commit
488d929733
@@ -13,6 +13,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"sort"
|
||||
@@ -21,6 +22,8 @@ import (
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"hamlab/internal/priv"
|
||||
)
|
||||
|
||||
type Unit struct {
|
||||
@@ -65,11 +68,20 @@ type Manager struct {
|
||||
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 = exec.CommandContext(ctx, "sudo", "-n", "true").Run() == nil
|
||||
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
|
||||
@@ -106,6 +118,9 @@ func run(ctx context.Context, args ...string) (string, error) {
|
||||
// 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
|
||||
@@ -126,15 +141,18 @@ func (m *Manager) List(ctx context.Context) ([]Unit, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var units []struct{ Unit, Load, Active, Sub, Description string }
|
||||
var units []listedUnit
|
||||
if err := json.Unmarshal([]byte(out), &units); err != nil {
|
||||
return nil, fmt.Errorf("parse list-units: %w", err)
|
||||
// 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 []struct {
|
||||
UnitFile string `json:"unit_file"`
|
||||
State string `json:"state"`
|
||||
var uf []listedFile
|
||||
if json.Unmarshal([]byte(files), &uf) != nil {
|
||||
uf = parseFileTable(files)
|
||||
}
|
||||
_ = json.Unmarshal([]byte(files), &uf)
|
||||
enabled := map[string]string{}
|
||||
for _, f := range uf {
|
||||
enabled[f.UnitFile] = f.State
|
||||
@@ -165,6 +183,37 @@ func (m *Manager) List(ctx context.Context) ([]Unit, error) {
|
||||
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{}
|
||||
@@ -243,7 +292,7 @@ func parseEntry(line []byte) (Entry, bool) {
|
||||
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...)
|
||||
args = priv.Wrap(args...)
|
||||
}
|
||||
return args
|
||||
}
|
||||
@@ -278,7 +327,8 @@ func (m *Manager) Tail(ctx context.Context, unit string, n int, fn func(Entry))
|
||||
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()
|
||||
k := priv.Wrap("kill", "-9", "--", pg)
|
||||
return exec.Command(k[0], k[1:]...).Run()
|
||||
}
|
||||
return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
}
|
||||
@@ -310,7 +360,7 @@ func (m *Manager) Action(ctx context.Context, action, unit string) error {
|
||||
}
|
||||
args := []string{"systemctl", action, unit, "--no-pager"}
|
||||
if m.sudo {
|
||||
args = append([]string{"sudo", "-n"}, args...)
|
||||
args = priv.Wrap(args...)
|
||||
}
|
||||
_, err := run(ctx, args...)
|
||||
m.invalidate()
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package services
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestTables(t *testing.T) {
|
||||
u := parseUnitTable(" UNIT LOAD ACTIVE SUB DESCRIPTION\n● bad.service loaded failed failed Bad thing here\n ssh.service loaded active running OpenBSD Secure Shell server\n\nLOAD = x\n12 loaded units listed.\n")
|
||||
if len(u) != 2 || u[0].Unit != "bad.service" || u[0].Active != "failed" || u[1].Description != "OpenBSD Secure Shell server" {
|
||||
t.Fatalf("%+v", u)
|
||||
}
|
||||
f := parseFileTable("UNIT FILE STATE VENDOR PRESET\nssh.service enabled enabled\nfoo@.service static -\n\n2 unit files listed.\n")
|
||||
if len(f) != 2 || f[0].State != "enabled" {
|
||||
t.Fatalf("%+v", f)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user