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() }