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