// Package update implements gpu-turnstile's self-updater: it polls the // Gitea releases API, downloads the Windows binary of newer releases, and // verifies its Ed25519 signature (produced by CI with OpenSSL) before // swapping it in next to the running executable. package update import ( "context" "crypto/ed25519" "crypto/sha256" "crypto/x509" "encoding/hex" "encoding/json" "encoding/pem" "fmt" "io" "log/slog" "net/http" "net/url" "os" "strconv" "strings" "time" ) // maxAssetSize bounds release asset downloads. const maxAssetSize = 512 << 20 // Updater checks one Gitea repository for releases. type Updater struct { Repo string // e.g. https://git.rambossek.at/PUBLIC/gpu-turnstile Asset string // e.g. gpu-turnstile.exe Version string // current binary version, e.g. v0.1.2 ("dev" cannot be compared) // Desired is the APP_VER policy: "dev" disables updates, "stable" (or // empty) tracks the latest release, anything else is an exact vX.Y.Z // release tag to pin — staging it even when that means a downgrade. Desired string Log *slog.Logger Client *http.Client } type release struct { TagName string `json:"tag_name"` Assets []struct { Name string `json:"name"` BrowserDownloadURL string `json:"browser_download_url"` } `json:"assets"` } func (u *Updater) logger() *slog.Logger { if u.Log != nil { return u.Log } return slog.Default() } func (u *Updater) httpClient() *http.Client { if u.Client != nil { return u.Client } return &http.Client{Timeout: 5 * time.Minute} } // apiURL derives :///api/v1/repos// from Repo. func (u *Updater) apiURL() (string, error) { repoURL, err := url.Parse(u.Repo) if err != nil || repoURL.Scheme == "" || repoURL.Host == "" { return "", fmt.Errorf("invalid UPDATE_REPO %q", u.Repo) } ownerName := strings.Trim(repoURL.Path, "/") if len(strings.Split(ownerName, "/")) != 2 { return "", fmt.Errorf("UPDATE_REPO %q: expected path //", u.Repo) } return fmt.Sprintf("%s://%s/api/v1/repos/%s", repoURL.Scheme, repoURL.Host, ownerName), nil } // newerVersion reports whether latest is a higher vX.Y.Z version than // current. Both may carry a leading "v". func newerVersion(current, latest string) (bool, error) { parse := func(s string) ([3]int, error) { var v [3]int parts := strings.Split(strings.TrimPrefix(s, "v"), ".") if len(parts) != 3 { return v, fmt.Errorf("not a vX.Y.Z version: %q", s) } for i, p := range parts { n, err := strconv.Atoi(p) if err != nil { return v, fmt.Errorf("not a vX.Y.Z version: %q", s) } v[i] = n } return v, nil } cur, err := parse(current) if err != nil { return false, err } lat, err := parse(latest) if err != nil { return false, err } for i := 0; i < 3; i++ { if lat[i] != cur[i] { return lat[i] > cur[i], nil } } return false, nil } func (u *Updater) get(ctx context.Context, url string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err } resp, err := u.httpClient().Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { io.Copy(io.Discard, resp.Body) return nil, fmt.Errorf("GET %s: %s", url, resp.Status) } return io.ReadAll(io.LimitReader(resp.Body, maxAssetSize)) } func verifySignature(pubKeyPEM string, data, sig []byte) error { block, _ := pem.Decode([]byte(pubKeyPEM)) if block == nil { return fmt.Errorf("invalid embedded public key PEM") } key, err := x509.ParsePKIXPublicKey(block.Bytes) if err != nil { return fmt.Errorf("parse public key: %w", err) } pub, ok := key.(ed25519.PublicKey) if !ok { return fmt.Errorf("public key is not Ed25519") } if !ed25519.Verify(pub, data, sig) { return fmt.Errorf("signature verification failed") } return nil } // stage swaps data into place at exePath: the running executable is // renamed aside (allowed on Windows) and the new file takes its name. func stage(exePath string, data []byte) error { newPath := exePath + ".new" oldPath := exePath + ".old" os.Remove(oldPath) // leftover from a previous update if err := os.WriteFile(newPath, data, 0o755); err != nil { return err } if err := os.Rename(exePath, oldPath); err != nil { os.Remove(newPath) return err } if err := os.Rename(newPath, exePath); err != nil { os.Rename(oldPath, exePath) // roll back return err } return nil } // CleanupOld removes the .old binary left behind by a staged update. // Call once at startup. func CleanupOld(exePath string) { os.Remove(exePath + ".old") os.Remove(exePath + ".new") } // Check performs a single update check. staged is true when a // signature-verified binary has been swapped into place at exePath; the // caller should then restart the process. to is the release tag the check // resolved (the latest release or the pinned tag), set once the release // fetch succeeded — even when staging afterwards fails. A nil error with // staged=false means "no action" (up to date, APP_VER=dev, or no embedded // public key); a non-nil error means the check failed and the running // binary is untouched. func (u *Updater) Check(ctx context.Context, exePath string) (staged bool, to string, err error) { log := u.logger() desired := u.Desired if desired == "" { desired = "stable" } if desired == "dev" { log.Debug("auto-update: APP_VER=dev, skipping") return false, "", nil } if publicKeyPEM == "" { log.Debug("auto-update: no public key embedded, skipping") return false, "", nil } api, err := u.apiURL() if err != nil { return false, "", err } pinned := desired != "stable" endpoint := api + "/releases/latest" if pinned { endpoint = api + "/releases/tags/" + desired } body, err := u.get(ctx, endpoint) if err != nil { return false, "", fmt.Errorf("fetch release: %w", err) } var rel release if err := json.Unmarshal(body, &rel); err != nil { return false, "", fmt.Errorf("parse release: %w", err) } to = rel.TagName if pinned { // Pin mode: any difference from the target tag means stage it — // including downgrades and replacing a dev binary. if u.Version == rel.TagName { log.Debug("auto-update: already on pinned version", "version", u.Version) return false, to, nil } } else if u.Version != "" && u.Version != "dev" { // Stable mode: only strictly newer releases count; a dev binary // cannot be compared and is always replaced by the latest release. newer, err := newerVersion(u.Version, rel.TagName) if err != nil { return false, to, err } if !newer { log.Debug("auto-update: up to date", "version", u.Version, "latest", rel.TagName) return false, to, nil } } urls := make(map[string]string, len(rel.Assets)) for _, a := range rel.Assets { urls[a.Name] = a.BrowserDownloadURL } assetURL, ok := urls[u.Asset] if !ok { return false, to, fmt.Errorf("release %s has no asset %q", rel.TagName, u.Asset) } sigURL, ok := urls[u.Asset+".sig"] if !ok { return false, to, fmt.Errorf("release %s has no signature asset %q", rel.TagName, u.Asset+".sig") } data, err := u.get(ctx, assetURL) if err != nil { return false, to, fmt.Errorf("download %s: %w", u.Asset, err) } sig, err := u.get(ctx, sigURL) if err != nil { return false, to, fmt.Errorf("download signature: %w", err) } if sumURL, ok := urls[u.Asset+".sha256"]; ok { sumText, err := u.get(ctx, sumURL) if err != nil { return false, to, fmt.Errorf("download checksum: %w", err) } want := strings.Fields(string(sumText))[0] got := hex.EncodeToString(sha256Bytes(data)) if !strings.EqualFold(want, got) { return false, to, fmt.Errorf("sha256 mismatch: got %s, want %s", got, want) } } if err := verifySignature(publicKeyPEM, data, sig); err != nil { return false, to, err } if err := stage(exePath, data); err != nil { return false, to, fmt.Errorf("stage update: %w", err) } log.Info("auto-update: new version staged", "from", u.Version, "to", rel.TagName) return true, to, nil } func sha256Bytes(data []byte) []byte { sum := sha256.Sum256(data) return sum[:] }