Files
hamlab/internal/selfupdate/selfupdate.go
Mitchell-JohnsonandClaude Opus 5 68bf3bb218 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>
2026-09-23 14:31:32 -07:00

265 lines
7.7 KiB
Go

// 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
}