An updater: what is published on Gitea, and installing it from Settings

A new selfupdate package asks one URL what the newest build is — a Gitea/Forgejo repo
API base, or any directory holding VERSION, SHA256SUMS and hamlab-linux-<arch>, which is
what make release already writes and what install.sh already downloads. The binary is
checked against SHA256SUMS before it goes near the running one, and a token is sent when
the source is behind a login, so a private Gitea serves its own fleet without opening the
instance to everyone. Version ordering is install.sh's vnum rule, so the daemon and the
installer never disagree about which build is newer.

Settings gains an Updates card: installed, published, when it last looked, the source,
release notes, Check for updates, and Install. The rail badges Settings when a newer
build is out. Nothing installs unasked — it restarts the daemon and drops terminals.

scripts/gitea-release.sh (make gitea-release) publishes dist/ as a Gitea release.
Deliberate, and not part of deploy: deploy pushes to the machines this hub owns,
publishing offers the build to every install anywhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-23 14:31:32 -07:00
co-authored by Claude Opus 5
parent 98a8de8091
commit 68bf3bb218
10 changed files with 876 additions and 36 deletions
+79 -31
View File
@@ -68,6 +68,7 @@ type Server struct {
updates *updates.Manager
storage *storage.Manager
space *space.Manager
update updateState
fsLocal files.FS
fsSudo files.FS // nil without passwordless sudo
nodes *nodes.Manager
@@ -145,6 +146,7 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
}
go s.keepFleetInStep()
}
go s.watchForUpdates(context.Background())
m := http.NewServeMux()
m.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(204) })
@@ -157,6 +159,9 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
m.HandleFunc("DELETE /api/nodes/{name}", s.auth(s.removeNode))
m.HandleFunc("/api/nodes/{name}/{rest...}", s.auth(s.proxyNode))
m.HandleFunc("POST /api/self-update", s.auth(s.selfUpdate))
m.HandleFunc("GET /api/update", s.auth(s.updateStatus))
m.HandleFunc("POST /api/update/check", s.auth(s.updateCheck))
m.HandleFunc("POST /api/update/install", s.auth(s.updateInstall))
m.HandleFunc("POST /api/machines/{name}/update", s.auth(s.updateMachine))
m.HandleFunc("POST /api/machines-update-all", s.auth(s.updateAllMachines))
m.HandleFunc("POST /api/logout", s.auth(s.logout))
@@ -659,26 +664,10 @@ func (s *Server) removeNode(w http.ResponseWriter, r *http.Request) {
// selfUpdate receives a new binary from a hub, checks it runs, installs it over the
// current executable (keeping a .prev copy) and restarts the service.
func (s *Server) selfUpdate(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("force") != "1" {
if t, ok := s.tasks.RunningOf("updates"); ok {
jsonErr(w, 409, "busy: a package task is running ("+t.Title+"); retry with force to interrupt it")
return
}
attached := 0
for _, t := range s.mgr.List() {
attached += t.Clients
}
if attached > 0 {
jsonErr(w, 409, fmt.Sprintf("busy: %d terminal client(s) attached; retry with force to drop them", attached))
return
}
}
exe, err := os.Executable()
if err != nil {
jsonErr(w, 500, err.Error())
if err := s.updateAllowed(r.URL.Query().Get("force") == "1"); err != nil {
jsonErr(w, 409, err.Error())
return
}
exe, _ = filepath.EvalSymlinks(exe)
tmp := filepath.Join(s.cfg.DataDir(), "hamlab.new")
f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755)
if err != nil {
@@ -692,21 +681,73 @@ func (s *Server) selfUpdate(w http.ResponseWriter, r *http.Request) {
jsonErr(w, 400, "upload failed or too small to be hamlab")
return
}
// It must at least run and identify itself before it replaces us.
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
newVersion, err := s.installBinary(r.Context(), tmp)
if err != nil {
jsonErr(w, codeOf(err, 500), 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})
s.restartAfterUpdate()
}
// updateAllowed refuses to replace the binary under a package task or an attached terminal,
// since the restart drops both. force says to do it anyway.
func (s *Server) updateAllowed(force bool) error {
if force {
return nil
}
if t, ok := s.tasks.RunningOf("updates"); ok {
return errors.New("busy: a package task is running (" + t.Title + "); retry with force to interrupt it")
}
attached := 0
for _, t := range s.mgr.List() {
attached += t.Clients
}
if attached > 0 {
return fmt.Errorf("busy: %d terminal client(s) attached; retry with force to drop them", attached)
}
return nil
}
// httpErr carries the status a failure deserves, so one install path serves both the
// upload handler and the updater.
type httpErr struct {
code int
msg string
}
func (e httpErr) Error() string { return e.msg }
func codeOf(err error, dflt int) int {
var h httpErr
if errors.As(err, &h) {
return h.code
}
return dflt
}
// installBinary checks that the file at tmp is a working hamlab for this machine and puts
// it in place of the running one, keeping the old one as hamlab.prev. It returns the new
// version. The caller restarts.
func (s *Server) installBinary(ctx context.Context, tmp string) (string, error) {
defer os.Remove(tmp)
exe, err := os.Executable()
if err != nil {
return "", err
}
exe, _ = filepath.EvalSymlinks(exe)
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
// It must at least run and identify itself before it replaces us.
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 for this machine ("+runtime.GOARCH+")")
return
return "", httpErr{400, "that file is not a working hamlab binary for this machine (" + runtime.GOARCH + ")"}
}
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
return "", httpErr{400, "the new build can't start here: " + strings.TrimSpace(string(msg))}
}
prev := filepath.Join(s.cfg.DataDir(), "hamlab.prev")
_ = copyFile(exe, prev)
@@ -720,15 +761,20 @@ func (s *Server) selfUpdate(w http.ResponseWriter, r *http.Request) {
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
return "", errors.New("install failed: " + strings.TrimSpace(string(msg)+" "+err.Error()))
}
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})
return newVersion, nil
}
// restartAfterUpdate becomes the binary now on disk; if that fails the old one goes back
// and we exit, so the supervisor starts something that works.
func (s *Server) restartAfterUpdate() {
go func() {
time.Sleep(800 * time.Millisecond)
exe, _ := os.Executable()
exe, _ = filepath.EvalSymlinks(exe)
prev := filepath.Join(s.cfg.DataDir(), "hamlab.prev")
if s.Restart != nil {
log.Printf("self-update: exec failed (%v); putting %s back", s.Restart(), s.version)
full := priv.Wrap(prev, "replace", exe)
@@ -966,6 +1012,7 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) {
}
}
_, rebootErr := os.Stat("/run/reboot-required")
updateVersion, _ := s.behind()
var demoURL, addr string
s.cfg.View(func(c *config.Config) {
demoURL = c.DemoURL
@@ -977,6 +1024,7 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) {
"host": s.hostName(), "user": s.user, "account": actorOf(r).User, "version": s.version,
"load": load, "uptime": uptime, "service_started": s.started,
"reboot_needed": rebootErr == nil, "fingerprint": s.fingerprint, "arch": runtime.GOARCH, "demo_url": demoURL, "addr": addr,
"update_available": updateVersion,
})
}
+217
View File
@@ -0,0 +1,217 @@
package api
// Updating Ham Lab itself: is there a newer build published, and install it on request.
// The check is a plain HTTP GET against whatever `update.url` names — this project's Gitea
// releases by default — so a fleet, a stranger's install and an air-gapped copy pointed at
// a directory on a NAS all work the same way. Nothing is ever installed unasked: an update
// restarts the daemon and drops open terminals, which is not a thing to do to somebody.
import (
"context"
"encoding/json"
"io"
"log"
"net/http"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
"hamlab/internal/config"
"hamlab/internal/selfupdate"
)
type updateState struct {
mu sync.Mutex
checking bool
installing bool
checkedAt time.Time
latest selfupdate.Release
err string
}
// source is the update source as configured, and whether looking is switched off.
func (s *Server) source() (selfupdate.Source, bool) {
var u config.UpdateSource
s.cfg.View(func(c *config.Config) { u = c.Updates })
url := strings.TrimSpace(u.URL)
if url == "" {
url = selfupdate.DefaultURL
}
if strings.EqualFold(url, "off") || strings.EqualFold(url, "none") {
return selfupdate.Source{}, false
}
return selfupdate.Source{URL: url, Token: u.Token}, true
}
func (s *Server) checksEnabled() bool {
var u config.UpdateSource
s.cfg.View(func(c *config.Config) { u = c.Updates })
if strings.EqualFold(u.Check, "never") {
return false
}
_, on := s.source()
return on
}
// Behind reports whether a newer build than this one has been seen, and which.
func (s *Server) behind() (string, bool) {
s.update.mu.Lock()
defer s.update.mu.Unlock()
v := s.update.latest.Version
if v == "" || selfupdate.Compare(s.version, v) >= 0 {
return "", false
}
return v, true
}
// checkNow asks the source what the newest build is. One at a time.
func (s *Server) checkNow(ctx context.Context) {
src, on := s.source()
if !on {
return
}
s.update.mu.Lock()
if s.update.checking {
s.update.mu.Unlock()
return
}
s.update.checking = true
s.update.mu.Unlock()
ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
rel, err := src.Latest(ctx, runtime.GOARCH)
s.update.mu.Lock()
s.update.checking = false
s.update.checkedAt = time.Now()
if err != nil {
s.update.err = err.Error()
} else {
s.update.err, s.update.latest = "", rel
}
s.update.mu.Unlock()
if err != nil {
log.Printf("update check: %v", err)
return
}
if selfupdate.Compare(s.version, rel.Version) < 0 {
log.Printf("update check: %s is published; this is %s", rel.Version, s.version)
}
}
// watchForUpdates asks once shortly after boot and then daily.
func (s *Server) watchForUpdates(ctx context.Context) {
t := time.NewTimer(90 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
}
if s.checksEnabled() {
s.checkNow(ctx)
}
t.Reset(selfupdate.DefaultCheckEvery)
}
}
func (s *Server) updateStatus(w http.ResponseWriter, _ *http.Request) {
src, on := s.source()
s.update.mu.Lock()
defer s.update.mu.Unlock()
latest := s.update.latest.Version
out := map[string]any{
"current": s.version, "latest": latest, "arch": runtime.GOARCH,
"behind": latest != "" && selfupdate.Compare(s.version, latest) < 0,
"checking": s.update.checking,
"installing": s.update.installing,
"enabled": on, "auto": s.checksEnabled(), "url": src.URL,
"notes": s.update.latest.Notes, "published": s.update.latest.Published,
}
if !s.update.checkedAt.IsZero() {
out["checked"] = s.update.checkedAt
}
if s.update.err != "" {
out["error"] = s.update.err
}
w.Header().Set("Cache-Control", "no-store")
jsonOK(w, 200, out)
}
func (s *Server) updateCheck(w http.ResponseWriter, r *http.Request) {
if _, on := s.source(); !on {
jsonErr(w, 400, "update checks are switched off (update.url is \"off\")")
return
}
s.checkNow(r.Context())
s.updateStatus(w, r)
}
// updateInstall fetches the published build for this machine and puts it in place.
func (s *Server) updateInstall(w http.ResponseWriter, r *http.Request) {
src, on := s.source()
if !on {
jsonErr(w, 400, "update checks are switched off (update.url is \"off\")")
return
}
force := r.URL.Query().Get("force") == "1"
if err := s.updateAllowed(force); err != nil {
jsonErr(w, 409, err.Error())
return
}
var body struct {
Version string `json:"version"` // optional: refuse if what is published is not this
}
_ = json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body)
s.update.mu.Lock()
if s.update.installing {
s.update.mu.Unlock()
jsonErr(w, 409, "an update is already being installed")
return
}
rel := s.update.latest
s.update.installing = true
s.update.mu.Unlock()
done := func() { s.update.mu.Lock(); s.update.installing = false; s.update.mu.Unlock() }
ctx := context.WithoutCancel(r.Context()) // a closed browser tab must not abort an install
if rel.Version == "" || body.Version != "" && body.Version != rel.Version {
var err error
if rel, err = src.Latest(ctx, runtime.GOARCH); err != nil {
done()
jsonErr(w, 502, err.Error())
return
}
s.update.mu.Lock()
s.update.latest, s.update.checkedAt, s.update.err = rel, time.Now(), ""
s.update.mu.Unlock()
}
if body.Version != "" && body.Version != rel.Version {
done()
jsonErr(w, 409, "what is published is now "+rel.Version+", not "+body.Version+"; look again")
return
}
if !force && selfupdate.Compare(s.version, rel.Version) >= 0 {
done()
jsonErr(w, 409, "this machine is already running "+s.version+"; nothing newer is published")
return
}
tmp := filepath.Join(s.cfg.DataDir(), "hamlab.new")
if err := src.Fetch(ctx, rel, runtime.GOARCH, tmp); err != nil {
done()
jsonErr(w, 502, err.Error())
return
}
newVersion, err := s.installBinary(ctx, tmp)
if err != nil {
done()
jsonErr(w, codeOf(err, 500), err.Error())
return
}
log.Printf("update: installed %s (was %s), asked for by %s; restarting", newVersion, s.version, clientIP(r))
jsonOK(w, 200, map[string]string{"version": newVersion, "previous": s.version})
s.restartAfterUpdate()
}