diff --git a/Makefile b/Makefile index a83249c..4959e43 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ LDFLAGS = -s -w -X main.version=$(VERSION) # static, no cgo: the same file runs on glibc, musl (Alpine) and anything else with a Linux kernel GOBUILD = CGO_ENABLED=0 GOOS=linux GOARM=6 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -.PHONY: all web build go dev cross release publish deploy push-nodes push-nodes-force test clean +.PHONY: all web build go dev cross release publish gitea-release deploy push-nodes push-nodes-force test clean all: build @@ -52,6 +52,12 @@ test: cd web && npm test --silent # installs here (keeping the config, user and password), then updates every managed machine +# Offer this build to every install, wherever it is: a release on Gitea, which is what +# Settings -> Updates reads. Deliberate, and not part of deploy — deploy pushes to the +# machines this hub owns, publishing offers it to everyone else. +gitea-release: release + ./scripts/gitea-release.sh $(RELEASE_ARGS) + deploy: build cross ./bin/hamlab install --prefix $(PREFIX) @sleep 1; $(PREFIX)/hamlab push-nodes || true diff --git a/internal/api/server.go b/internal/api/server.go index 2d6f955..6de95a4 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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, }) } diff --git a/internal/api/update.go b/internal/api/update.go new file mode 100644 index 0000000..ffac617 --- /dev/null +++ b/internal/api/update.go @@ -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() +} diff --git a/internal/config/config.go b/internal/config/config.go index 8bbbfef..fed9144 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -48,6 +48,19 @@ type User struct { LastSeen time.Time `json:"last_seen,omitempty"` } +// UpdateSource is where this machine looks for a newer Ham Lab, and how often. The field +// is Updates, not Update, because Config already has an Update method. +type UpdateSource struct { + // URL is a Gitea/Forgejo repo API base or a static directory; empty means the built-in + // default, and "off" means never look. + URL string `json:"url,omitempty"` + // Token is sent when the source is behind a login, so a private Gitea can serve its + // own fleet without the instance being opened to everyone. + Token string `json:"token,omitempty"` + // Check is "daily" (the default) or "never". + Check string `json:"check,omitempty"` +} + type Config struct { Listen string `json:"listen"` // PasswordHash is the single login of installs before users existed. On load it becomes @@ -60,6 +73,8 @@ type Config struct { // DemoURL, when set, is linked from the Widgets tab: a page outside Ham Lab that // shows the sensor API being used (examples/external-demo). DemoURL string `json:"demo_url,omitempty"` + // Updates: where a newer build comes from. See UpdateSource. + Updates UpdateSource `json:"update,omitempty"` path string mu sync.Mutex diff --git a/internal/selfupdate/selfupdate.go b/internal/selfupdate/selfupdate.go new file mode 100644 index 0000000..9c55776 --- /dev/null +++ b/internal/selfupdate/selfupdate.go @@ -0,0 +1,264 @@ +// Package selfupdate finds out whether a newer Ham Lab has been published and fetches it. +// +// A source is one URL. Two shapes are understood, told apart by the URL itself: +// +// - a Gitea/Forgejo repository API base — ".../api/v1/repos//" — whose +// newest release names the version and carries the binaries as assets; +// - any other URL, treated as a static directory holding VERSION, SHA256SUMS and +// hamlab-linux-, which is exactly what `make release` writes and what +// install.sh already downloads. +// +// Either way the binary is checked against SHA256SUMS before it is allowed near the +// running one. A token is sent when the source is behind a login, so a private Gitea +// serves its own fleet without the instance being opened to the world. +package selfupdate + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "strconv" + "strings" + "time" +) + +// DefaultCheckEvery is how often a daemon asks, when it is asking at all. +const DefaultCheckEvery = 24 * time.Hour + +// DefaultURL is where a build comes from when the config does not say: the project's own +// releases. An install elsewhere points `update.url` at its own, or at "off". +const DefaultURL = "https://git.bigham.dev/api/v1/repos/mitchell/hamlab" + +// maxBinary is the most we will download; a hamlab binary is ~20 MB. +const maxBinary = 512 << 20 + +// Source is where updates come from. +type Source struct { + URL string // API base or static directory + Token string // optional; for a source behind a login +} + +// Release is one published build. +type Release struct { + Version string `json:"version"` + Notes string `json:"notes,omitempty"` + Published time.Time `json:"published,omitempty"` + // Binary and Sums are where to get this build for the architecture that was asked for. + Binary string `json:"-"` + Sums string `json:"-"` +} + +// AssetName is what a build for an architecture is called, in a release and on disk. +func AssetName(arch string) string { return "hamlab-linux-" + arch } + +func (s Source) api() bool { return strings.Contains(s.URL, "/api/v1/repos/") } + +func (s Source) get(ctx context.Context, url string) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, err + } + if s.Token != "" { + req.Header.Set("Authorization", "token "+s.Token) + } + req.Header.Set("User-Agent", "hamlab-selfupdate") + resp, err := (&http.Client{Timeout: 10 * time.Minute}).Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode != 200 { + resp.Body.Close() + switch resp.StatusCode { + case 401, 403: + return nil, fmt.Errorf("%s: the update source needs a login (HTTP %d); set update.token", url, resp.StatusCode) + case 404: + return nil, fmt.Errorf("%s: nothing published there (HTTP 404)", url) + } + return nil, fmt.Errorf("%s: HTTP %d", url, resp.StatusCode) + } + return resp, nil +} + +func (s Source) getAll(ctx context.Context, url string, limit int64) ([]byte, error) { + resp, err := s.get(ctx, url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + return io.ReadAll(io.LimitReader(resp.Body, limit)) +} + +// Latest is the newest published build, and where to get it for arch. +func (s Source) Latest(ctx context.Context, arch string) (Release, error) { + if s.URL == "" { + return Release{}, errors.New("no update source configured") + } + base := strings.TrimSuffix(s.URL, "/") + if !s.api() { + b, err := s.getAll(ctx, base+"/VERSION", 4096) + if err != nil { + return Release{}, err + } + v := strings.TrimSpace(strings.SplitN(string(b), "\n", 2)[0]) + if v == "" { + return Release{}, errors.New("the source published an empty VERSION") + } + return Release{Version: v, Binary: base + "/" + AssetName(arch), Sums: base + "/SHA256SUMS"}, nil + } + b, err := s.getAll(ctx, base+"/releases/latest", 4<<20) + if err != nil { + return Release{}, err + } + var rel struct { + TagName string `json:"tag_name"` + Body string `json:"body"` + PublishedAt time.Time `json:"published_at"` + Assets []struct { + Name string `json:"name"` + URL string `json:"browser_download_url"` + } `json:"assets"` + } + if err := json.Unmarshal(b, &rel); err != nil { + return Release{}, fmt.Errorf("the source did not answer with a release: %w", err) + } + if rel.TagName == "" { + return Release{}, errors.New("the newest release has no tag") + } + out := Release{Version: rel.TagName, Notes: rel.Body, Published: rel.PublishedAt} + want := AssetName(arch) + for _, a := range rel.Assets { + switch a.Name { + case want: + out.Binary = a.URL + case "SHA256SUMS": + out.Sums = a.URL + } + } + if out.Binary == "" { + return out, fmt.Errorf("release %s has no %s", rel.TagName, want) + } + return out, nil +} + +// Fetch downloads the build for arch to dest and checks it against SHA256SUMS. A release +// without sums is refused: an unverified binary is not worth installing over a working one. +func (s Source) Fetch(ctx context.Context, rel Release, arch, dest string) error { + if rel.Binary == "" { + return errors.New("that release has no binary for this machine") + } + if rel.Sums == "" { + return errors.New("that release has no SHA256SUMS, so the download cannot be verified") + } + sums, err := s.getAll(ctx, rel.Sums, 1<<20) + if err != nil { + return err + } + want := sumFor(string(sums), AssetName(arch)) + if want == "" { + return fmt.Errorf("SHA256SUMS has no entry for %s", AssetName(arch)) + } + resp, err := s.get(ctx, rel.Binary) + if err != nil { + return err + } + defer resp.Body.Close() + f, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755) + if err != nil { + return err + } + h := sha256.New() + n, err := io.Copy(io.MultiWriter(f, h), io.LimitReader(resp.Body, maxBinary)) + cerr := f.Close() + if err == nil { + err = cerr + } + if err != nil { + os.Remove(dest) + return err + } + if n < 1<<20 { + os.Remove(dest) + return fmt.Errorf("downloaded %d bytes: too small to be hamlab", n) + } + if got := hex.EncodeToString(h.Sum(nil)); got != want { + os.Remove(dest) + return fmt.Errorf("checksum mismatch: SHA256SUMS says %s, the download is %s", want[:16]+"…", got[:16]+"…") + } + return nil +} + +// sumFor finds a file's checksum in SHA256SUMS (" ", or "*" for binary mode). +func sumFor(sums, name string) string { + for _, line := range strings.Split(sums, "\n") { + f := strings.Fields(strings.TrimSpace(line)) + if len(f) == 2 && (f[1] == name || f[1] == "*"+name) && len(f[0]) == 64 { + return strings.ToLower(f[0]) + } + } + return "" +} + +// Compare orders two versions: -1 if a is older than b, 0 if the same, 1 if newer. +// It is install.sh's vnum() rule, so the daemon and the installer never disagree about +// which way round two builds go: v0.1.0-69-g9c4f9df → 0001000069, the commits since the +// tag included, the hash ignored. +func Compare(a, b string) int { + na, oka := vnum(a) + nb, okb := vnum(b) + switch { + case !oka && !okb: + return strings.Compare(a, b) + case !oka: + return -1 + case !okb: + return 1 + case na < nb: + return -1 + case na > nb: + return 1 + } + return 0 +} + +func vnum(v string) (int64, bool) { + v = strings.TrimSpace(v) + v = strings.TrimPrefix(v, "v") + if i := strings.LastIndex(v, "-g"); i >= 0 && isHex(v[i+2:]) { + v = v[:i] + } + if v == "" { + return 0, false + } + parts := strings.FieldsFunc(v, func(r rune) bool { return r == '.' || r == '-' }) + nums := make([]int64, 4) + for i := 0; i < 4 && i < len(parts); i++ { + n, err := strconv.ParseInt(parts[i], 10, 64) + if err != nil { + if i == 0 { + return 0, false + } + break + } + nums[i] = n + } + // %d%03d%03d%05d, as the installer does it + return ((nums[0]*1000+nums[1])*1000+nums[2])*100000 + nums[3], true +} + +func isHex(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if !strings.ContainsRune("0123456789abcdefABCDEF", r) { + return false + } + } + return true +} diff --git a/internal/selfupdate/selfupdate_test.go b/internal/selfupdate/selfupdate_test.go new file mode 100644 index 0000000..8ed699e --- /dev/null +++ b/internal/selfupdate/selfupdate_test.go @@ -0,0 +1,145 @@ +package selfupdate + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestCompareMatchesInstaller(t *testing.T) { + older := []([2]string){ + {"v0.1.0-69-g9c4f9df", "v0.1.0-107-gad88a48"}, + {"v0.1.0", "v0.1.0-1-gabc1234"}, + {"v0.1.0-107", "v0.2.0"}, + {"v0.9.9-999", "v1.0.0"}, + {"v0.1.9", "v0.1.10"}, + } + for _, p := range older { + if got := Compare(p[0], p[1]); got != -1 { + t.Errorf("Compare(%q, %q) = %d, want -1", p[0], p[1], got) + } + if got := Compare(p[1], p[0]); got != 1 { + t.Errorf("Compare(%q, %q) = %d, want 1", p[1], p[0], got) + } + } + same := []string{"v0.1.0-107-gad88a48", "v1.2.3", "dev"} + for _, v := range same { + if got := Compare(v, v); got != 0 { + t.Errorf("Compare(%q, %q) = %d, want 0", v, v, got) + } + } + // the hash is ignored: the same build described two ways is the same build + if got := Compare("v0.1.0-107-gad88a48", "v0.1.0-107-gffffff1"); got != 0 { + t.Errorf("hash should not count: %d", got) + } + // "dev" is not a version; anything numbered beats it + if got := Compare("dev", "v0.1.0"); got != -1 { + t.Errorf("Compare(dev, v0.1.0) = %d, want -1", got) + } +} + +// serve puts a release behind both shapes of source and returns the two base URLs. +func serve(t *testing.T, version string, body []byte, token string) (static, api string, srv *httptest.Server) { + t.Helper() + sum := sha256.Sum256(body) + sums := fmt.Sprintf("%s %s\n%s install.sh\n", hex.EncodeToString(sum[:]), AssetName("amd64"), strings.Repeat("0", 64)) + mux := http.NewServeMux() + guard := func(h http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if token != "" && r.Header.Get("Authorization") != "token "+token { + w.WriteHeader(401) + return + } + h(w, r) + } + } + mux.HandleFunc("/dl/VERSION", guard(func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, version) })) + mux.HandleFunc("/dl/SHA256SUMS", guard(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, sums) })) + mux.HandleFunc("/dl/"+AssetName("amd64"), guard(func(w http.ResponseWriter, r *http.Request) { w.Write(body) })) + mux.HandleFunc("/api/v1/repos/m/hamlab/releases/latest", guard(func(w http.ResponseWriter, r *http.Request) { + base := "http://" + r.Host + "/dl/" + _ = json.NewEncoder(w).Encode(map[string]any{ + "tag_name": version, "body": "notes here", + "assets": []map[string]string{ + {"name": AssetName("amd64"), "browser_download_url": base + AssetName("amd64")}, + {"name": "SHA256SUMS", "browser_download_url": base + "SHA256SUMS"}, + }, + }) + })) + srv = httptest.NewServer(mux) + return srv.URL + "/dl", srv.URL + "/api/v1/repos/m/hamlab", srv +} + +func TestLatestAndFetch(t *testing.T) { + body := make([]byte, 2<<20) + copy(body, "ELF fake hamlab") + static, api, srv := serve(t, "v0.9.0-3-gdeadbee", body, "") + defer srv.Close() + + for _, url := range []string{static, api} { + s := Source{URL: url} + rel, err := s.Latest(context.Background(), "amd64") + if err != nil { + t.Fatalf("%s: %v", url, err) + } + if rel.Version != "v0.9.0-3-gdeadbee" { + t.Fatalf("%s: version %q", url, rel.Version) + } + dest := filepath.Join(t.TempDir(), "hamlab.new") + if err := s.Fetch(context.Background(), rel, "amd64", dest); err != nil { + t.Fatalf("%s: fetch: %v", url, err) + } + got, _ := os.ReadFile(dest) + if len(got) != len(body) { + t.Fatalf("%s: got %d bytes, want %d", url, len(got), len(body)) + } + } +} + +func TestFetchRefusesBadChecksum(t *testing.T) { + body := make([]byte, 2<<20) + static, _, srv := serve(t, "v1.0.0", body, "") + defer srv.Close() + s := Source{URL: static} + rel, err := s.Latest(context.Background(), "amd64") + if err != nil { + t.Fatal(err) + } + rel.Sums = static + "/VERSION" // sums that name nothing + dest := filepath.Join(t.TempDir(), "hamlab.new") + if err := s.Fetch(context.Background(), rel, "amd64", dest); err == nil { + t.Fatal("a release whose sums do not name the binary must be refused") + } + if _, err := os.Stat(dest); !os.IsNotExist(err) { + t.Fatal("a refused download must not be left on disk") + } + // and an arch nobody published + if _, err := s.Latest(context.Background(), "riscv64"); err == nil { + t.Log("static sources cannot know an arch is missing until the fetch; that is fine") + } +} + +func TestTokenIsSentAndMissingTokenSaysSo(t *testing.T) { + body := make([]byte, 2<<20) + _, api, srv := serve(t, "v1.0.0", body, "s3cret") + defer srv.Close() + if _, err := (Source{URL: api}).Latest(context.Background(), "amd64"); err == nil || !strings.Contains(err.Error(), "update.token") { + t.Fatalf("a walled source should say a token is needed, got %v", err) + } + rel, err := (Source{URL: api, Token: "s3cret"}).Latest(context.Background(), "amd64") + if err != nil || rel.Version != "v1.0.0" { + t.Fatalf("with the token: %v %+v", err, rel) + } + dest := filepath.Join(t.TempDir(), "hamlab.new") + if err := (Source{URL: api, Token: "s3cret"}).Fetch(context.Background(), rel, "amd64", dest); err != nil { + t.Fatalf("fetch with token: %v", err) + } +} diff --git a/scripts/gitea-release.sh b/scripts/gitea-release.sh new file mode 100755 index 0000000..3100c75 --- /dev/null +++ b/scripts/gitea-release.sh @@ -0,0 +1,69 @@ +#!/bin/sh +# Publish what `make release` built as a release on Gitea, which is where every Ham Lab +# looks for a newer build (Settings → Updates, and `update.url` in config.json). +# +# make release && scripts/gitea-release.sh [--force] +# +# The token needs write:repository and is read from $HAMLAB_PUBLISH_TOKEN, else from +# ~/.config/hamlab-publish-token. It is never printed. +# +# A release is a deliberate act, not part of `make deploy`: deploying pushes this build to +# the machines this hub owns, while publishing offers it to every install everywhere. +set -eu + +REPO=${HAMLAB_REPO:-https://git.bigham.dev/api/v1/repos/mitchell/hamlab} +DIST=${DIST:-dist} +FORCE=0 +[ "${1:-}" != "--force" ] || FORCE=1 + +die() { printf '%s\n' "$*" >&2; exit 1; } + +[ -f "$DIST/VERSION" ] || die "no $DIST/VERSION — run: make release" +VERSION=$(head -n 1 "$DIST/VERSION" | tr -cd 'A-Za-z0-9._+-') +[ -n "$VERSION" ] || die "$DIST/VERSION is empty" + +TOKEN=${HAMLAB_PUBLISH_TOKEN:-} +if [ -z "$TOKEN" ] && [ -r "$HOME/.config/hamlab-publish-token" ]; then + TOKEN=$(cat "$HOME/.config/hamlab-publish-token") +fi +[ -n "$TOKEN" ] || die "no publish token: set HAMLAB_PUBLISH_TOKEN or write ~/.config/hamlab-publish-token (Gitea → Settings → Applications, scope write:repository)" + +api() { # method path [curl args...] + m=$1; p=$2; shift 2 + curl -sS -X "$m" -H "Authorization: token $TOKEN" "$REPO$p" "$@" +} + +SHA=$(git rev-parse HEAD) +echo "publishing $VERSION ($(echo "$SHA" | cut -c1-8)) to $REPO" + +# An existing release for this version is replaced only when asked: re-publishing a version +# with different binaries is how an install ends up with something nobody can reproduce. +existing=$(api GET "/releases/tags/$VERSION" | python3 -c 'import json,sys +try: d = json.load(sys.stdin) +except Exception: d = {} +print(d.get("id") or "")' 2>/dev/null || true) +if [ -n "$existing" ]; then + [ "$FORCE" = 1 ] || die "$VERSION is already published (release $existing). Re-publish with --force." + echo " replacing release $existing" + api DELETE "/releases/$existing" -o /dev/null +fi + +notes=$(git log -1 --pretty=%B | head -20) +payload=$(python3 -c 'import json,sys; print(json.dumps({"tag_name": sys.argv[1], "target": sys.argv[2], "name": sys.argv[1], "body": sys.argv[3], "draft": False, "prerelease": False}))' "$VERSION" "$SHA" "$notes") +id=$(printf '%s' "$payload" | api POST /releases -H 'Content-Type: application/json' -d @- | python3 -c 'import json,sys +d = json.load(sys.stdin) +if not d.get("id"): sys.exit("gitea refused the release: %s" % d.get("message", d)) +print(d["id"])') +[ -n "$id" ] || die "could not create the release" +echo " release $id created" + +n=0 +for f in "$DIST"/hamlab-linux-* "$DIST/SHA256SUMS" "$DIST/install.sh" "$DIST/VERSION"; do + [ -f "$f" ] || continue + name=$(basename "$f") + api POST "/releases/$id/assets?name=$name" -F "attachment=@$f" -o /dev/null + n=$((n + 1)) + echo " + $name" +done +echo "published $VERSION with $n assets" +echo " every Ham Lab pointed at this repo will offer it in Settings → Updates" diff --git a/web/src/Settings.tsx b/web/src/Settings.tsx index c41ca50..7253442 100644 --- a/web/src/Settings.tsx +++ b/web/src/Settings.tsx @@ -1,9 +1,9 @@ import { useCallback, useEffect, useState } from 'react' import { fetchCatalog } from './sensors' -import { api, Me, TokenInfo } from './api' +import { api, Me, TokenInfo, UpdateStatus } from './api' import { savePrefs } from './accents' import { applyTheme, currentThemeId, THEMES } from './themes' -import { Btn, Loading, askYes, toast, useEscape } from './ui' +import { Btn, Loading, Spinner, askYes, toast, useEscape } from './ui' import { fmtMs, fmtWindowSec, getHistorySec, getRefreshMs, HISTORY_OPTIONS, HISTORY_POINTS, REFRESH_OPTIONS, setHistorySec, setRefreshMs, strideMs } from './prefs' const fmtUptime = (s: number) => { const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60); return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m` } @@ -133,6 +133,8 @@ export default function Settings({ me }: { me: Me }) { {making && setMaking(false)} onMade={(t) => { setMaking(false); setFresh(t); loadTokens() }} />} + +

About

@@ -147,6 +149,76 @@ export default function Settings({ me }: { me: Me }) { ) } +// What is published, and installing it. An update replaces the binary and restarts the +// daemon, which drops open terminals — so it only ever happens when it is asked for. +function Updates({ me }: { me: Me }) { + const [st, setSt] = useState(null) + const [busy, setBusy] = useState('') + const [err, setErr] = useState('') + const load = useCallback(async () => { try { setSt(await api.update()) } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } }, []) + useEffect(() => { load() }, [load]) + + const check = async () => { + setBusy('check'); setErr('') + try { setSt(await api.checkUpdate()) } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } + finally { setBusy('') } + } + const install = async (force = false) => { + if (!st) return + if (!await askYes(`Install ${st.latest} on ${me.host}? Ham Lab restarts, which closes open terminals on this machine.`, 'Install')) return + setBusy('install'); setErr('') + try { + await api.installUpdate(st.latest, force) + toast(`${st.latest} installed — reconnecting`) + setTimeout(() => window.location.reload(), 4000) + } catch (e) { + const m = e instanceof Error ? e.message : 'failed' + setBusy('') + if (/^busy:/.test(m) && await askYes(m.replace(/^busy: /, '') + '. Install anyway?', 'Install anyway')) { install(true); return } + setErr(m) + } + } + + return ( + <> +

Updates

+ {st?.auto ? 'checked daily' : st?.enabled ? 'checked when you ask' : 'switched off'} +
+
+
+
Installed{st?.current ?? me.version}
+
Published{st?.checking ? : st?.latest || '—'}
+
Checked{st?.checked ? new Date(st.checked).toLocaleString() : 'not yet'}
+
Source{st?.enabled ? sourceName(st.url) : 'off'}
+
+ {st?.behind && ( +
+ {st.latest} is published; this machine runs {st.current}. + {st.published && Released {new Date(st.published).toLocaleDateString()}.} + {st.notes &&
{st.notes.trim()}
} +
+ )} + {st && !st.behind && st.latest &&
This is the newest published build.
} + {(err || st?.error) &&
{err || st?.error}
} +
+ Check for updates + {st?.behind && install()}>Install {st.latest}} + Installing restarts Ham Lab on this machine. Terminals here close; the shells do not survive it. +
+
+ + ) +} + +// A source shown as something readable: the repo it is, or the host it is on. +function sourceName(url: string): string { + try { + const u = new URL(url) + const m = u.pathname.match(/\/api\/v1\/repos\/(.+)$/) + return m ? `${u.host}/${m[1]}` : u.host + u.pathname + } catch { return url } +} + // One dialog for a read-only token: what will use it, and (optionally) which sensor // families it may see — picked from this machine's own catalog, or typed as patterns. function TokenDialog({ onClose, onMade }: { onClose: () => void; onMade: (t: { name: string; token: string }) => void }) { diff --git a/web/src/Shell.tsx b/web/src/Shell.tsx index b0f3935..17ae190 100644 --- a/web/src/Shell.tsx +++ b/web/src/Shell.tsx @@ -258,7 +258,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
- !HUB_ONLY.includes(g.id as View)) : GROUPS} view={view} badges={{ alerts: firing }} + !HUB_ONLY.includes(g.id as View)) : GROUPS} view={view} badges={{ alerts: firing, settings: hubMe.update_available ? 1 : 0 }} header={ { setView('machines'); setDrawer(false) }} />} onView={async (id) => { if (id !== view && !(await okToLeave())) return; setView(id as View); setDrawer(false) }} />
setDrawer(false)} /> diff --git a/web/src/api.ts b/web/src/api.ts index 4c15c90..ba7ac12 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -54,7 +54,8 @@ async function req(method: string, path: string, body?: unknown, local = fals return r.json() } -export interface Me { demo_url?: string; addr?: string; host: string; user: string; account?: string; version: string; load: string; uptime: number; reboot_needed?: boolean; fingerprint?: string; arch?: string } +export interface UpdateStatus { current: string; latest: string; arch: string; behind: boolean; checking: boolean; installing: boolean; enabled: boolean; auto: boolean; url: string; notes?: string; published?: string; checked?: string; error?: string } +export interface Me { demo_url?: string; addr?: string; host: string; user: string; account?: string; update_available?: string; version: string; load: string; uptime: number; reboot_needed?: boolean; fingerprint?: string; arch?: string } export interface PushResult { name: string; ok: boolean; version?: string; error?: string } // What a machine is called on screen: the hostname it reports, which follows a rename within // a poll. The name it was added under stays its identity — the URL, its accent colour, and @@ -225,6 +226,9 @@ export const api = { updateMachine: (name: string, force = false) => req('POST', `/api/machines/${encodeURIComponent(name)}/update${force ? '?force=1' : ''}`, undefined, true), updateAllMachines: (force = false) => req('POST', `/api/machines-update-all${force ? '?force=1' : ''}`, undefined, true), changePassword: (current: string, password: string) => req('POST', '/api/password', { current, password }, true), + update: () => req('GET', '/api/update'), + checkUpdate: () => req('POST', '/api/update/check'), + installUpdate: (version: string, force = false) => req<{ version: string; previous: string }>('POST', `/api/update/install${force ? '?force=1' : ''}`, { version }), tokens: () => req('GET', '/api/tokens', undefined, true), createReadToken: (name: string, sensors: string[] = []) => req<{ token: string; name: string }>('POST', '/api/tokens/read', { name, sensors }, true), docker: () => req('GET', '/api/docker'),