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
+30 -8
View File
@@ -2,6 +2,7 @@
package api
import (
"bytes"
"context"
"encoding/json"
"errors"
@@ -32,7 +33,9 @@ import (
"hamlab/internal/files"
"hamlab/internal/metrics"
"hamlab/internal/nodes"
"hamlab/internal/priv"
"hamlab/internal/services"
"hamlab/internal/setup"
"hamlab/internal/storage"
"hamlab/internal/store"
"hamlab/internal/tasks"
@@ -43,6 +46,9 @@ import (
const cookieName = "hamlab_session"
type Server struct {
// Restart replaces the running process with the binary on disk; it only returns on failure.
Restart func() error
cfg *config.Config
sessions *auth.Sessions
limiter auth.Limiter
@@ -374,27 +380,43 @@ func (s *Server) selfUpdate(w http.ResponseWriter, r *http.Request) {
out, err := exec.CommandContext(ctx, tmp, "version").Output()
if err != nil || !strings.HasPrefix(string(out), "hamlab ") {
os.Remove(tmp)
jsonErr(w, 400, "uploaded file is not a working hamlab binary")
jsonErr(w, 400, "uploaded file is not a working hamlab binary for this machine ("+runtime.GOARCH+")")
return
}
newVersion := strings.TrimSpace(strings.TrimPrefix(string(out), "hamlab "))
// Builds that have `check` must also load this machine's config and state.
if msg, err := exec.CommandContext(ctx, tmp, "-config", s.cfg.Path(), "check").CombinedOutput(); err != nil && !bytes.Contains(msg, []byte("usage:")) {
os.Remove(tmp)
jsonErr(w, 400, "the new build can't start here: "+strings.TrimSpace(string(msg)))
return
}
prev := filepath.Join(s.cfg.DataDir(), "hamlab.prev")
_ = copyFile(exe, prev)
install := exec.CommandContext(ctx, "sudo", "-n", "install", "-m", "0755", tmp, exe)
if errb, err := install.CombinedOutput(); err != nil {
if err2 := os.Rename(tmp, exe); err2 != nil { // no sudo: try in place
jsonErr(w, 500, "install failed: "+strings.TrimSpace(string(errb)))
return
// Atomic replace, by the new binary itself: directly when the directory is ours
// (root, or a user-owned prefix), else as root.
var msg []byte
if err = setup.CopyAtomic(tmp, exe); err != nil && priv.Available() {
full := priv.Wrap(tmp, "replace", exe)
if msg, err = exec.CommandContext(ctx, full[0], full[1:]...).CombinedOutput(); err != nil { // a build from before `replace`
full = priv.Wrap("install", "-m", "0755", tmp, exe)
msg, err = exec.CommandContext(ctx, full[0], full[1:]...).CombinedOutput()
}
}
os.Remove(tmp)
if err != nil {
jsonErr(w, 500, "install failed: "+strings.TrimSpace(string(msg)+" "+err.Error()))
return
}
log.Printf("self-update: installed %s (was %s) from %s; restarting", newVersion, s.version, clientIP(r))
jsonOK(w, 200, map[string]string{"version": newVersion, "previous": s.version})
go func() {
time.Sleep(800 * time.Millisecond)
if exec.Command("sudo", "-n", "systemctl", "restart", "hamlab").Run() != nil {
os.Exit(0) // Restart=always brings the new binary up
if s.Restart != nil {
log.Printf("self-update: exec failed (%v); putting %s back", s.Restart(), s.version)
full := priv.Wrap(prev, "replace", exe)
_ = exec.Command(full[0], full[1:]...).Run()
}
os.Exit(1) // the supervisor starts whatever is on disk
}()
}