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:
co-authored by
Claude Opus 5
parent
98a8de8091
commit
68bf3bb218
@@ -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
|
# 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)"
|
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
|
all: build
|
||||||
|
|
||||||
@@ -52,6 +52,12 @@ test:
|
|||||||
cd web && npm test --silent
|
cd web && npm test --silent
|
||||||
|
|
||||||
# installs here (keeping the config, user and password), then updates every managed machine
|
# 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
|
deploy: build cross
|
||||||
./bin/hamlab install --prefix $(PREFIX)
|
./bin/hamlab install --prefix $(PREFIX)
|
||||||
@sleep 1; $(PREFIX)/hamlab push-nodes || true
|
@sleep 1; $(PREFIX)/hamlab push-nodes || true
|
||||||
|
|||||||
+79
-31
@@ -68,6 +68,7 @@ type Server struct {
|
|||||||
updates *updates.Manager
|
updates *updates.Manager
|
||||||
storage *storage.Manager
|
storage *storage.Manager
|
||||||
space *space.Manager
|
space *space.Manager
|
||||||
|
update updateState
|
||||||
fsLocal files.FS
|
fsLocal files.FS
|
||||||
fsSudo files.FS // nil without passwordless sudo
|
fsSudo files.FS // nil without passwordless sudo
|
||||||
nodes *nodes.Manager
|
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.keepFleetInStep()
|
||||||
}
|
}
|
||||||
|
go s.watchForUpdates(context.Background())
|
||||||
|
|
||||||
m := http.NewServeMux()
|
m := http.NewServeMux()
|
||||||
m.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(204) })
|
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("DELETE /api/nodes/{name}", s.auth(s.removeNode))
|
||||||
m.HandleFunc("/api/nodes/{name}/{rest...}", s.auth(s.proxyNode))
|
m.HandleFunc("/api/nodes/{name}/{rest...}", s.auth(s.proxyNode))
|
||||||
m.HandleFunc("POST /api/self-update", s.auth(s.selfUpdate))
|
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/{name}/update", s.auth(s.updateMachine))
|
||||||
m.HandleFunc("POST /api/machines-update-all", s.auth(s.updateAllMachines))
|
m.HandleFunc("POST /api/machines-update-all", s.auth(s.updateAllMachines))
|
||||||
m.HandleFunc("POST /api/logout", s.auth(s.logout))
|
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
|
// 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.
|
// current executable (keeping a .prev copy) and restarts the service.
|
||||||
func (s *Server) selfUpdate(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) selfUpdate(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Query().Get("force") != "1" {
|
if err := s.updateAllowed(r.URL.Query().Get("force") == "1"); err != nil {
|
||||||
if t, ok := s.tasks.RunningOf("updates"); ok {
|
jsonErr(w, 409, err.Error())
|
||||||
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())
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
exe, _ = filepath.EvalSymlinks(exe)
|
|
||||||
tmp := filepath.Join(s.cfg.DataDir(), "hamlab.new")
|
tmp := filepath.Join(s.cfg.DataDir(), "hamlab.new")
|
||||||
f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755)
|
f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755)
|
||||||
if err != nil {
|
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")
|
jsonErr(w, 400, "upload failed or too small to be hamlab")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// It must at least run and identify itself before it replaces us.
|
newVersion, err := s.installBinary(r.Context(), tmp)
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
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()
|
defer cancel()
|
||||||
|
// It must at least run and identify itself before it replaces us.
|
||||||
out, err := exec.CommandContext(ctx, tmp, "version").Output()
|
out, err := exec.CommandContext(ctx, tmp, "version").Output()
|
||||||
if err != nil || !strings.HasPrefix(string(out), "hamlab ") {
|
if err != nil || !strings.HasPrefix(string(out), "hamlab ") {
|
||||||
os.Remove(tmp)
|
return "", httpErr{400, "that file is not a working hamlab binary for this machine (" + runtime.GOARCH + ")"}
|
||||||
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 "))
|
newVersion := strings.TrimSpace(strings.TrimPrefix(string(out), "hamlab "))
|
||||||
// Builds that have `check` must also load this machine's config and state.
|
// 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:")) {
|
if msg, err := exec.CommandContext(ctx, tmp, "-config", s.cfg.Path(), "check").CombinedOutput(); err != nil && !bytes.Contains(msg, []byte("usage:")) {
|
||||||
os.Remove(tmp)
|
return "", httpErr{400, "the new build can't start here: " + strings.TrimSpace(string(msg))}
|
||||||
jsonErr(w, 400, "the new build can't start here: "+strings.TrimSpace(string(msg)))
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
prev := filepath.Join(s.cfg.DataDir(), "hamlab.prev")
|
prev := filepath.Join(s.cfg.DataDir(), "hamlab.prev")
|
||||||
_ = copyFile(exe, 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()
|
msg, err = exec.CommandContext(ctx, full[0], full[1:]...).CombinedOutput()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
os.Remove(tmp)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonErr(w, 500, "install failed: "+strings.TrimSpace(string(msg)+" "+err.Error()))
|
return "", errors.New("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))
|
return newVersion, nil
|
||||||
jsonOK(w, 200, map[string]string{"version": newVersion, "previous": s.version})
|
}
|
||||||
|
|
||||||
|
// 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() {
|
go func() {
|
||||||
time.Sleep(800 * time.Millisecond)
|
time.Sleep(800 * time.Millisecond)
|
||||||
|
exe, _ := os.Executable()
|
||||||
|
exe, _ = filepath.EvalSymlinks(exe)
|
||||||
|
prev := filepath.Join(s.cfg.DataDir(), "hamlab.prev")
|
||||||
if s.Restart != nil {
|
if s.Restart != nil {
|
||||||
log.Printf("self-update: exec failed (%v); putting %s back", s.Restart(), s.version)
|
log.Printf("self-update: exec failed (%v); putting %s back", s.Restart(), s.version)
|
||||||
full := priv.Wrap(prev, "replace", exe)
|
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")
|
_, rebootErr := os.Stat("/run/reboot-required")
|
||||||
|
updateVersion, _ := s.behind()
|
||||||
var demoURL, addr string
|
var demoURL, addr string
|
||||||
s.cfg.View(func(c *config.Config) {
|
s.cfg.View(func(c *config.Config) {
|
||||||
demoURL = c.DemoURL
|
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,
|
"host": s.hostName(), "user": s.user, "account": actorOf(r).User, "version": s.version,
|
||||||
"load": load, "uptime": uptime, "service_started": s.started,
|
"load": load, "uptime": uptime, "service_started": s.started,
|
||||||
"reboot_needed": rebootErr == nil, "fingerprint": s.fingerprint, "arch": runtime.GOARCH, "demo_url": demoURL, "addr": addr,
|
"reboot_needed": rebootErr == nil, "fingerprint": s.fingerprint, "arch": runtime.GOARCH, "demo_url": demoURL, "addr": addr,
|
||||||
|
"update_available": updateVersion,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -48,6 +48,19 @@ type User struct {
|
|||||||
LastSeen time.Time `json:"last_seen,omitempty"`
|
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 {
|
type Config struct {
|
||||||
Listen string `json:"listen"`
|
Listen string `json:"listen"`
|
||||||
// PasswordHash is the single login of installs before users existed. On load it becomes
|
// 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
|
// DemoURL, when set, is linked from the Widgets tab: a page outside Ham Lab that
|
||||||
// shows the sensor API being used (examples/external-demo).
|
// shows the sensor API being used (examples/external-demo).
|
||||||
DemoURL string `json:"demo_url,omitempty"`
|
DemoURL string `json:"demo_url,omitempty"`
|
||||||
|
// Updates: where a newer build comes from. See UpdateSource.
|
||||||
|
Updates UpdateSource `json:"update,omitempty"`
|
||||||
|
|
||||||
path string
|
path string
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
|
|||||||
@@ -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/<owner>/<repo>" — 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-<arch>, 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 ("<hex> <name>", or "*<name>" 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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+69
@@ -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"
|
||||||
+74
-2
@@ -1,9 +1,9 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { fetchCatalog } from './sensors'
|
import { fetchCatalog } from './sensors'
|
||||||
import { api, Me, TokenInfo } from './api'
|
import { api, Me, TokenInfo, UpdateStatus } from './api'
|
||||||
import { savePrefs } from './accents'
|
import { savePrefs } from './accents'
|
||||||
import { applyTheme, currentThemeId, THEMES } from './themes'
|
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'
|
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` }
|
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 && <TokenDialog onClose={() => setMaking(false)} onMade={(t) => { setMaking(false); setFresh(t); loadTokens() }} />}
|
{making && <TokenDialog onClose={() => setMaking(false)} onMade={(t) => { setMaking(false); setFresh(t); loadTokens() }} />}
|
||||||
|
|
||||||
|
<Updates me={me} />
|
||||||
|
|
||||||
<div className="dash-bar"><h2 className="section">About</h2></div>
|
<div className="dash-bar"><h2 className="section">About</h2></div>
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<div className="props">
|
<div className="props">
|
||||||
@@ -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<UpdateStatus | null>(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 (
|
||||||
|
<>
|
||||||
|
<div className="dash-bar"><h2 className="section">Updates</h2><span className="spacer" />
|
||||||
|
<span className="muted small">{st?.auto ? 'checked daily' : st?.enabled ? 'checked when you ask' : 'switched off'}</span>
|
||||||
|
</div>
|
||||||
|
<section className="card">
|
||||||
|
<div className="props">
|
||||||
|
<div><span className="k">Installed</span><span className="mono">{st?.current ?? me.version}</span></div>
|
||||||
|
<div><span className="k">Published</span><span className="mono">{st?.checking ? <Spinner /> : st?.latest || '—'}</span></div>
|
||||||
|
<div><span className="k">Checked</span><span>{st?.checked ? new Date(st.checked).toLocaleString() : 'not yet'}</span></div>
|
||||||
|
<div><span className="k">Source</span><span className="mono small" title={st?.url}>{st?.enabled ? sourceName(st.url) : 'off'}</span></div>
|
||||||
|
</div>
|
||||||
|
{st?.behind && (
|
||||||
|
<div className="notice attn" style={{ marginTop: 10 }}>
|
||||||
|
<b>{st.latest}</b> is published; this machine runs <b>{st.current}</b>.
|
||||||
|
{st.published && <span className="muted"> Released {new Date(st.published).toLocaleDateString()}.</span>}
|
||||||
|
{st.notes && <div className="mono small" style={{ whiteSpace: 'pre-wrap', marginTop: 6, maxHeight: 140, overflow: 'auto' }}>{st.notes.trim()}</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{st && !st.behind && st.latest && <div className="muted small" style={{ marginTop: 8 }}>This is the newest published build.</div>}
|
||||||
|
{(err || st?.error) && <div className="error" style={{ marginTop: 8 }}>{err || st?.error}</div>}
|
||||||
|
<div className="svc-actions" style={{ marginTop: 10 }}>
|
||||||
|
<Btn className="btn" busy={busy === 'check'} disabled={!st?.enabled || !!busy} onClick={check}>Check for updates</Btn>
|
||||||
|
{st?.behind && <Btn className="btn on" busy={busy === 'install'} disabled={!!busy} onClick={() => install()}>Install {st.latest}</Btn>}
|
||||||
|
<span className="muted small">Installing restarts Ham Lab on this machine. Terminals here close; the shells do not survive it.</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
// 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.
|
// 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 }) {
|
function TokenDialog({ onClose, onMade }: { onClose: () => void; onMade: (t: { name: string; token: string }) => void }) {
|
||||||
|
|||||||
+1
-1
@@ -258,7 +258,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="body">
|
<div className="body">
|
||||||
<Rail groups={machine ? GROUPS.filter((g) => !HUB_ONLY.includes(g.id as View)) : GROUPS} view={view} badges={{ alerts: firing }}
|
<Rail groups={machine ? GROUPS.filter((g) => !HUB_ONLY.includes(g.id as View)) : GROUPS} view={view} badges={{ alerts: firing, settings: hubMe.update_available ? 1 : 0 }}
|
||||||
header={<MachineSwitcher hub={hubMe.host} hubAddr={hubMe.addr} current={machine} machines={machines} onChange={switchMachine} onManage={() => { setView('machines'); setDrawer(false) }} />}
|
header={<MachineSwitcher hub={hubMe.host} hubAddr={hubMe.addr} current={machine} machines={machines} onChange={switchMachine} onManage={() => { setView('machines'); setDrawer(false) }} />}
|
||||||
onView={async (id) => { if (id !== view && !(await okToLeave())) return; setView(id as View); setDrawer(false) }} />
|
onView={async (id) => { if (id !== view && !(await okToLeave())) return; setView(id as View); setDrawer(false) }} />
|
||||||
<div className="backdrop" onClick={() => setDrawer(false)} />
|
<div className="backdrop" onClick={() => setDrawer(false)} />
|
||||||
|
|||||||
+5
-1
@@ -54,7 +54,8 @@ async function req<T>(method: string, path: string, body?: unknown, local = fals
|
|||||||
return r.json()
|
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 }
|
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
|
// 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
|
// 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<PushResult>('POST', `/api/machines/${encodeURIComponent(name)}/update${force ? '?force=1' : ''}`, undefined, true),
|
updateMachine: (name: string, force = false) => req<PushResult>('POST', `/api/machines/${encodeURIComponent(name)}/update${force ? '?force=1' : ''}`, undefined, true),
|
||||||
updateAllMachines: (force = false) => req<PushResult[]>('POST', `/api/machines-update-all${force ? '?force=1' : ''}`, undefined, true),
|
updateAllMachines: (force = false) => req<PushResult[]>('POST', `/api/machines-update-all${force ? '?force=1' : ''}`, undefined, true),
|
||||||
changePassword: (current: string, password: string) => req<void>('POST', '/api/password', { current, password }, true),
|
changePassword: (current: string, password: string) => req<void>('POST', '/api/password', { current, password }, true),
|
||||||
|
update: () => req<UpdateStatus>('GET', '/api/update'),
|
||||||
|
checkUpdate: () => req<UpdateStatus>('POST', '/api/update/check'),
|
||||||
|
installUpdate: (version: string, force = false) => req<{ version: string; previous: string }>('POST', `/api/update/install${force ? '?force=1' : ''}`, { version }),
|
||||||
tokens: () => req<TokenInfo[]>('GET', '/api/tokens', undefined, true),
|
tokens: () => req<TokenInfo[]>('GET', '/api/tokens', undefined, true),
|
||||||
createReadToken: (name: string, sensors: string[] = []) => req<{ token: string; name: string }>('POST', '/api/tokens/read', { name, sensors }, true),
|
createReadToken: (name: string, sensors: string[] = []) => req<{ token: string; name: string }>('POST', '/api/tokens/read', { name, sensors }, true),
|
||||||
docker: () => req<DockerInfo>('GET', '/api/docker'),
|
docker: () => req<DockerInfo>('GET', '/api/docker'),
|
||||||
|
|||||||
Reference in New Issue
Block a user