280 lines
7.7 KiB
Go
280 lines
7.7 KiB
Go
// 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 <scheme>://<host>/api/v1/repos/<owner>/<name> 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 /<owner>/<name>", 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. 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, 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)
|
|
}
|
|
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, 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, err
|
|
}
|
|
if !newer {
|
|
log.Debug("auto-update: up to date", "version", u.Version, "latest", rel.TagName)
|
|
return false, 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, fmt.Errorf("release %s has no asset %q", rel.TagName, u.Asset)
|
|
}
|
|
sigURL, ok := urls[u.Asset+".sig"]
|
|
if !ok {
|
|
return false, 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, fmt.Errorf("download %s: %w", u.Asset, err)
|
|
}
|
|
sig, err := u.get(ctx, sigURL)
|
|
if err != nil {
|
|
return false, 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, fmt.Errorf("download checksum: %w", err)
|
|
}
|
|
want := strings.Fields(string(sumText))[0]
|
|
got := hex.EncodeToString(sha256Bytes(data))
|
|
if !strings.EqualFold(want, got) {
|
|
return false, fmt.Errorf("sha256 mismatch: got %s, want %s", got, want)
|
|
}
|
|
}
|
|
if err := verifySignature(publicKeyPEM, data, sig); err != nil {
|
|
return false, err
|
|
}
|
|
|
|
if err := stage(exePath, data); err != nil {
|
|
return false, fmt.Errorf("stage update: %w", err)
|
|
}
|
|
log.Info("auto-update: new version staged", "from", u.Version, "to", rel.TagName)
|
|
return true, nil
|
|
}
|
|
|
|
func sha256Bytes(data []byte) []byte {
|
|
sum := sha256.Sum256(data)
|
|
return sum[:]
|
|
}
|