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:
Mitchell-Johnson
2026-09-18 23:00:20 -07:00
co-authored by Claude Fable 5.1
parent 91b7f4f009
commit 488d929733
26 changed files with 1617 additions and 178 deletions
+174 -34
View File
@@ -2,8 +2,9 @@
// was last refreshed, whether a reboot is pending, and the refresh/upgrade actions,
// which run as background tasks so their output can be followed live.
//
// apt is fully supported. dnf and pacman get a read-only list so the tab still says
// something useful on those hosts; upgrades there are a later addition.
// Supported: apt (Debian, Ubuntu), dnf (Fedora, RHEL family), zypper (openSUSE, SLES),
// pacman (Arch) and apk (Alpine). All of them list, refresh, upgrade and install; pacman
// upgrades everything or nothing, because Arch doesn't support partial upgrades.
package updates
import (
@@ -18,6 +19,7 @@ import (
"strings"
"time"
"hamlab/internal/priv"
"hamlab/internal/tasks"
)
@@ -31,8 +33,9 @@ type Package struct {
}
type Status struct {
Manager string `json:"manager"` // apt | dnf | pacman | ""
Manager string `json:"manager"` // apt | dnf | zypper | pacman | apk | ""
CanUpgrade bool `json:"can_upgrade"`
Selective bool `json:"selective"` // individual packages can be upgraded
Packages []Package `json:"packages"`
SecurityCount int `json:"security_count"`
LastRefresh int64 `json:"last_refresh,omitempty"` // unix ms
@@ -52,7 +55,7 @@ type Manager struct {
func New(sudo bool, tm *tasks.Manager) *Manager {
m := &Manager{sudo: sudo, tasks: tm}
for _, pm := range []string{"apt-get", "dnf", "pacman"} {
for _, pm := range []string{"apt-get", "dnf", "zypper", "pacman", "apk"} {
if _, err := exec.LookPath(pm); err == nil {
m.pm = strings.TrimSuffix(pm, "-get")
break
@@ -63,7 +66,7 @@ func New(sudo bool, tm *tasks.Manager) *Manager {
func (m *Manager) sudoArgs(args ...string) []string {
if m.sudo {
return append([]string{"sudo", "-n"}, args...)
return priv.Wrap(args...)
}
return args
}
@@ -145,9 +148,10 @@ func (m *Manager) Status(ctx context.Context) Status {
if t, ok := m.tasks.RunningOf("updates"); ok {
s.RunningTask = t.ID
}
s.CanUpgrade = m.sudo && m.pm != ""
s.Selective = m.pm != "pacman"
switch m.pm {
case "apt":
s.CanUpgrade = m.sudo
s.Packages = aptUpgradable(ctx)
s.LastRefresh = mtimeMs("/var/lib/apt/periodic/update-success-stamp")
if s.LastRefresh == 0 {
@@ -162,9 +166,18 @@ func (m *Manager) Status(ctx context.Context) Status {
s.Packages = dnfUpgradable(ctx)
s.LastRefresh = newestMtime("/var/cache/libdnf5/*/repodata", "/var/cache/dnf/*/repodata", "/var/cache/libdnf5", "/var/cache/dnf")
s.LastUpgrade = dnfLastUpgrade(ctx)
case "zypper":
s.Packages = zypperUpgradable(ctx)
s.LastRefresh = newestMtime("/var/cache/zypp/raw/*", "/var/cache/zypp/raw")
s.LastUpgrade = mtimeMs("/var/log/zypp/history")
case "pacman":
s.Packages = pacmanUpgradable(ctx)
s.LastRefresh = mtimeMs("/var/lib/pacman/sync")
s.LastRefresh = newestMtime("/var/lib/pacman/sync/*.db", "/var/lib/pacman/sync")
s.LastUpgrade = pacmanLastUpgrade()
case "apk":
s.Packages = apkUpgradable(ctx)
s.LastRefresh = newestMtime("/var/cache/apk/APKINDEX.*", "/var/lib/apk/APKINDEX.*")
s.LastUpgrade = mtimeMs("/lib/apk/db/installed")
}
for _, p := range s.Packages {
if p.Security {
@@ -289,7 +302,7 @@ func aptAutoUpgrades(ctx context.Context) bool {
return strings.Contains(text, `"1"`)
}
// ---- dnf / pacman (read-only) ----
// ---- dnf / zypper / pacman / apk ----
func dnfUpgradable(ctx context.Context) []Package {
text, _ := out(ctx, "dnf", "-q", "check-update") // exit 100 when updates exist; output still parses
@@ -305,6 +318,19 @@ func dnfUpgradable(ctx context.Context) []Package {
if pkgs == nil {
return []Package{}
}
// check-update only names the candidate; one rpm query fills in what's installed.
args := append([]string{"rpm", "-q", "--qf", `%{NAME} %|EPOCH?{%{EPOCH}:}:{}|%{VERSION}-%{RELEASE}\n`}, names(pkgs)...)
if text, _ := out(ctx, args...); text != "" { // exits non-zero when any name isn't installed (new dependencies)
cur := map[string]string{}
for _, line := range strings.Split(text, "\n") {
if f := strings.Fields(line); len(f) == 2 {
cur[f[0]] = f[1] // several installed (kernels): the last listed is the newest
}
}
for i := range pkgs {
pkgs[i].Current = cur[pkgs[i].Name]
}
}
return pkgs
}
@@ -323,44 +349,148 @@ func pacmanUpgradable(ctx context.Context) []Package {
return pkgs
}
// ---- actions (apt only) ----
// zypperUpgradable parses the table `zypper list-updates` prints:
// S | Repository | Name | Current Version | Available Version | Arch
func zypperUpgradable(ctx context.Context) []Package {
text, _ := out(ctx, "zypper", "--non-interactive", "--quiet", "list-updates")
pkgs := []Package{}
for _, line := range strings.Split(text, "\n") {
f := strings.Split(line, "|")
if len(f) < 5 || strings.TrimSpace(f[0]) != "v" {
continue
}
repo := strings.TrimSpace(f[1])
pkgs = append(pkgs, Package{Name: strings.TrimSpace(f[2]), Current: strings.TrimSpace(f[3]), Candidate: strings.TrimSpace(f[4]),
Suite: repo, Security: strings.Contains(strings.ToLower(repo), "security")})
}
return pkgs
}
var pkgNameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9+.-]{1,127}$`)
var apkVerRe = regexp.MustCompile(`^(.+)-([^-]+-r\d+)\s+<\s+(\S+)`)
func (m *Manager) start(title string, args ...string) (*tasks.Task, error) {
if m.pm != "apt" {
return nil, errors.New("upgrades are only supported for apt so far")
// apkUpgradable parses `apk version -l '<'`: "musl-1.2.4-r2 < 1.2.5-r0".
func apkUpgradable(ctx context.Context) []Package {
text, _ := out(ctx, "apk", "version", "-l", "<")
pkgs := []Package{}
for _, line := range strings.Split(text, "\n") {
if m := apkVerRe.FindStringSubmatch(strings.TrimSpace(line)); m != nil {
pkgs = append(pkgs, Package{Name: m[1], Current: m[2], Candidate: m[3]})
}
}
return pkgs
}
// pacmanLastUpgrade: the timestamp of the last "upgraded" line in pacman.log.
func pacmanLastUpgrade() int64 {
f, err := os.Open("/var/log/pacman.log")
if err != nil {
return 0
}
defer f.Close()
last := ""
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 64<<10), 1<<20)
for sc.Scan() {
if l := sc.Text(); strings.Contains(l, "[ALPM] upgraded ") && strings.HasPrefix(l, "[") {
if end := strings.IndexByte(l, ']'); end > 1 {
last = l[1:end]
}
}
}
for _, layout := range []string{"2006-01-02T15:04:05-0700", "2006-01-02 15:04"} {
if t, err := time.ParseInLocation(layout, last, time.Local); err == nil {
return t.UnixMilli()
}
}
return 0
}
// ---- actions ----
// Never starts with '-', so a name can't be read as an option by any of the managers.
var pkgNameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9+._-]{0,127}$`)
func checkNames(pkgs []string) error {
for _, p := range pkgs {
if !pkgNameRe.MatchString(p) {
return errors.New("bad package name: " + p)
}
}
return nil
}
func (m *Manager) start(args ...string) (*tasks.Task, error) {
if m.pm == "" {
return nil, errors.New("no supported package manager on this host")
}
if !m.sudo {
return nil, errors.New("needs passwordless sudo")
return nil, errors.New("needs root: run Ham Lab as root, or give its user passwordless sudo or doas")
}
if t, ok := m.tasks.RunningOf("updates"); ok {
return nil, errors.New("another package task is running: " + t.Title)
}
full := append([]string{"sudo", "-n", "env", "DEBIAN_FRONTEND=noninteractive", "APT_LISTCHANGES_FRONTEND=none", "LC_ALL=C"}, args...)
full := priv.Wrap(append([]string{"env", "DEBIAN_FRONTEND=noninteractive", "APT_LISTCHANGES_FRONTEND=none", "LC_ALL=C"}, args...)...)
cmd := exec.Command(full[0], full[1:]...)
return m.tasks.Start("updates", title, cmd)
return m.tasks.Start("updates", strings.Join(args, " "), cmd)
}
func (m *Manager) Refresh() (*tasks.Task, error) {
return m.start("apt-get update", "apt-get", "update")
switch m.pm {
case "apt":
return m.start("apt-get", "update")
case "dnf":
return m.start("dnf", "-y", "makecache", "--refresh")
case "zypper":
return m.start("zypper", "--non-interactive", "refresh")
case "pacman":
return m.start("pacman", "-Sy", "--noconfirm")
case "apk":
return m.start("apk", "update")
}
return m.start()
}
// Upgrade runs a full dist-upgrade, or only the named packages. Config-file prompts are
// answered "keep the current one" so the run can never block on a question.
// rolling reports a distro whose supported upgrade path is a full distribution upgrade.
func rolling() bool {
b, _ := os.ReadFile("/etc/os-release")
return strings.Contains(string(b), "tumbleweed") || strings.Contains(string(b), "microos")
}
// Upgrade upgrades everything, or only the named packages. Nothing here can block on a
// question: apt keeps the current config file on a conflict, the rest run non-interactive.
func (m *Manager) Upgrade(pkgs []string) (*tasks.Task, error) {
base := []string{"apt-get", "-y", "-o", "Dpkg::Options::=--force-confdef", "-o", "Dpkg::Options::=--force-confold"}
if len(pkgs) == 0 {
return m.start("apt-get dist-upgrade", append(base, "dist-upgrade")...)
if err := checkNames(pkgs); err != nil {
return nil, err
}
for _, p := range pkgs {
if !pkgNameRe.MatchString(p) {
return nil, errors.New("bad package name: " + p)
all := len(pkgs) == 0
var args []string
switch m.pm {
case "apt":
args = []string{"apt-get", "-y", "-o", "Dpkg::Options::=--force-confdef", "-o", "Dpkg::Options::=--force-confold"}
if all {
args = append(args, "dist-upgrade")
} else {
args = append(args, "install", "--only-upgrade")
}
case "dnf":
args = []string{"dnf", "-y", "upgrade"}
case "zypper":
args = []string{"zypper", "--non-interactive", "update"}
if all && rolling() {
args = []string{"zypper", "--non-interactive", "dist-upgrade"}
}
case "pacman":
if !all {
return nil, errors.New("pacman upgrades everything or nothing: Arch doesn't support partial upgrades")
}
args = []string{"pacman", "-Syu", "--noconfirm"}
case "apk":
args = []string{"apk", "upgrade"}
if all {
args = []string{"apk", "upgrade", "--update-cache"}
}
}
args := append(base, "install", "--only-upgrade")
args = append(args, pkgs...)
return m.start("apt-get upgrade "+strings.Join(pkgs, " "), args...)
return m.start(append(args, pkgs...)...)
}
// Install installs packages (used by other tabs for optional tooling like smartmontools).
@@ -368,13 +498,23 @@ func (m *Manager) Install(pkgs []string) (*tasks.Task, error) {
if len(pkgs) == 0 {
return nil, errors.New("no packages")
}
for _, p := range pkgs {
if !pkgNameRe.MatchString(p) {
return nil, errors.New("bad package name: " + p)
}
if err := checkNames(pkgs); err != nil {
return nil, err
}
args := append([]string{"apt-get", "-y", "--no-install-recommends", "install"}, pkgs...)
return m.start("apt-get install "+strings.Join(pkgs, " "), args...)
var args []string
switch m.pm {
case "apt":
args = []string{"apt-get", "-y", "--no-install-recommends", "install"}
case "dnf":
args = []string{"dnf", "-y", "install"}
case "zypper":
args = []string{"zypper", "--non-interactive", "install", "--no-recommends"}
case "pacman":
args = []string{"pacman", "-S", "--noconfirm", "--needed"}
case "apk":
args = []string{"apk", "add"}
}
return m.start(append(args, pkgs...)...)
}
func (m *Manager) Changelog(ctx context.Context, pkg string) (string, error) {