// Package updater checks a Gitea repository for a newer release and, on // request, downloads and verifies the new binary. // // Two rules shape the whole package: it never installs on its own, and it never // blocks startup. The check runs in a goroutine behind a short timeout and // fails silently into the log, because a laptop on a customer site frequently // has no route to the Gitea host and that must not be a visible error. package updater import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "net/url" "os" "path" "path/filepath" "regexp" "strings" "time" ) // checkTimeout bounds the startup release check. const checkTimeout = 8 * time.Second // maxAssetBytes caps a download. The binary is a few MB; anything near this is // a wrong URL or a captive portal serving a login page. const maxAssetBytes = 128 << 20 // Release is the subset of Gitea's release JSON that matters here. type Release struct { TagName string `json:"tag_name"` Name string `json:"name"` Body string `json:"body"` Assets []Asset `json:"assets"` HTMLURL string `json:"html_url"` } // Asset is one file attached to a release. type Asset struct { Name string `json:"name"` Size int64 `json:"size"` BrowserDownloadURL string `json:"browser_download_url"` } // Checker polls one repository. type Checker struct { // Repo is the repository as "https://gitea.example.com/owner/repo". Repo string // Current is the compiled-in version, from -X main.version. Current string HTTP *http.Client } // Update describes an available newer release. type Update struct { Version string Release Release Asset Asset // SHA256 is the expected checksum, empty if the release published none. SHA256 string } // apiURL turns a repo browse URL into its releases/latest API endpoint. func apiURL(repo string) (string, error) { repo = strings.TrimSuffix(strings.TrimSpace(repo), "/") if repo == "" { return "", fmt.Errorf("no update repository configured") } u, err := url.Parse(repo) if err != nil { return "", fmt.Errorf("update repo %q is not a URL: %w", repo, err) } if u.Scheme != "https" && u.Scheme != "http" { return "", fmt.Errorf("update repo %q must be an http(s) URL", repo) } parts := strings.Split(strings.Trim(u.Path, "/"), "/") if len(parts) != 2 || parts[0] == "" || parts[1] == "" { return "", fmt.Errorf("update repo %q should look like https://host/owner/repo", repo) } u.Path = path.Join("/api/v1/repos", parts[0], parts[1], "releases/latest") return u.String(), nil } func (c *Checker) client() *http.Client { if c.HTTP != nil { return c.HTTP } return &http.Client{Timeout: checkTimeout} } // Check asks for the latest release and returns an Update if it is newer than // the running build. A nil Update with a nil error means "up to date". func (c *Checker) Check(ctx context.Context) (*Update, error) { endpoint, err := apiURL(c.Repo) if err != nil { return nil, err } ctx, cancel := context.WithTimeout(ctx, checkTimeout) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } req.Header.Set("Accept", "application/json") resp, err := c.client().Do(req) if err != nil { return nil, fmt.Errorf("contacting %s: %w", endpoint, err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("%s returned %s", endpoint, resp.Status) } var rel Release if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&rel); err != nil { return nil, fmt.Errorf("parsing the release response: %w", err) } if !IsNewer(rel.TagName, c.Current) { return nil, nil } asset, ok := pickExe(rel.Assets) if !ok { return nil, fmt.Errorf("release %s has no .exe asset", rel.TagName) } return &Update{ Version: strings.TrimPrefix(rel.TagName, "v"), Release: rel, Asset: asset, SHA256: findChecksum(rel, asset.Name), }, nil } func pickExe(assets []Asset) (Asset, bool) { for _, a := range assets { if strings.HasSuffix(strings.ToLower(a.Name), ".exe") { return a, true } } return Asset{}, false } var sha256Re = regexp.MustCompile(`\b([a-fA-F0-9]{64})\b`) // findChecksum digs the expected SHA256 out of the release body. The body is // scanned for a line naming the asset; a bare 64-hex string anywhere is // accepted as a fallback for a release that publishes only the one checksum. // // A SHA256SUMS sibling asset is handled by the caller, which can fetch it — it // is not available from the release JSON alone. func findChecksum(rel Release, assetName string) string { for _, line := range strings.Split(rel.Body, "\n") { if strings.Contains(line, assetName) { if m := sha256Re.FindStringSubmatch(line); m != nil { return strings.ToLower(m[1]) } } } if m := sha256Re.FindStringSubmatch(rel.Body); m != nil { return strings.ToLower(m[1]) } return "" } // SumsAsset finds a SHA256SUMS-style asset in the release, if there is one. func (u *Update) SumsAsset() (Asset, bool) { for _, a := range u.Release.Assets { n := strings.ToUpper(a.Name) if strings.Contains(n, "SHA256") { return a, true } } return Asset{}, false } // Download fetches the update's .exe into dir and verifies its SHA256 before // returning the path. A release with no published checksum is refused: an // unverified binary that we are about to swap in and run is not worth the // convenience. func (c *Checker) Download(ctx context.Context, u *Update, dir string) (string, error) { want := u.SHA256 if want == "" { // Try the sibling SHA256SUMS asset before giving up. if sums, ok := u.SumsAsset(); ok { body, err := c.fetch(ctx, sums.BrowserDownloadURL, 1<<20) if err != nil { return "", fmt.Errorf("fetching %s: %w", sums.Name, err) } want = checksumFor(string(body), u.Asset.Name) } } if want == "" { return "", fmt.Errorf("release %s publishes no SHA256 for %s; refusing to install an unverified binary", u.Release.TagName, u.Asset.Name) } body, err := c.fetch(ctx, u.Asset.BrowserDownloadURL, maxAssetBytes) if err != nil { return "", fmt.Errorf("downloading %s: %w", u.Asset.Name, err) } sum := sha256.Sum256(body) got := hex.EncodeToString(sum[:]) if !strings.EqualFold(got, want) { return "", fmt.Errorf("checksum mismatch for %s:\n expected %s\n got %s", u.Asset.Name, want, got) } if err := os.MkdirAll(dir, 0o755); err != nil { return "", err } dest := filepath.Join(dir, u.Asset.Name) if err := os.WriteFile(dest, body, 0o755); err != nil { return "", fmt.Errorf("writing %s: %w", dest, err) } return dest, nil } func (c *Checker) fetch(ctx context.Context, rawURL string, limit int64) ([]byte, error) { ctx, cancel := context.WithTimeout(ctx, 5*time.Minute) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) if err != nil { return nil, err } resp, err := c.client().Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("server returned %s", resp.Status) } return io.ReadAll(io.LimitReader(resp.Body, limit)) } // checksumFor pulls one file's hash out of a `sha256sum` style listing. func checksumFor(sums, name string) string { for _, line := range strings.Split(sums, "\n") { fields := strings.Fields(line) if len(fields) < 2 { continue } // The name may carry sha256sum's binary-mode "*" prefix. if strings.TrimPrefix(fields[len(fields)-1], "*") == name { if sha256Re.MatchString(fields[0]) { return strings.ToLower(fields[0]) } } } return "" }